@moku-labs/web 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -8011,6 +8011,34 @@ function createApi$1(ctx) {
8011
8011
  return result;
8012
8012
  },
8013
8013
  /**
8014
+ * Incremental dev rebuild from a set of changed paths — the fast counterpart to
8015
+ * `build()` for an external dev driver (e.g. the worker's `dev({ onChange })`). Skips
8016
+ * the destructive clean, scopes the rebuild to `changes`, and applies the same dev
8017
+ * overrides `serve()` uses (minify off; OG/sitemap/feeds off unless re-enabled). Renders
8018
+ * no header and skips the not-found assertion — the driver owns the dev TUI and the cold
8019
+ * build already asserted. Live per-phase progress still streams via the build hooks (as
8020
+ * the cold `build()` does today).
8021
+ *
8022
+ * @param changes - The paths changed since the last build (incremental rebuild hint).
8023
+ * @param options - Optional per-session dev-output opt-ins (default all off).
8024
+ * @returns The rebuild summary (`outDir`, `pageCount`, `durationMs`).
8025
+ * @throws {Error} When the underlying incremental build fails.
8026
+ * @example
8027
+ * await api.update(["src/islands/board.ts"]);
8028
+ */
8029
+ update(changes, options = {}) {
8030
+ const overrides = devBuildOverrides({
8031
+ og: options.og ?? false,
8032
+ sitemap: options.sitemap ?? false,
8033
+ feeds: options.feeds ?? false
8034
+ });
8035
+ return ctx.require(buildPlugin).run({
8036
+ skipClean: true,
8037
+ overrides,
8038
+ changed: changes
8039
+ });
8040
+ },
8041
+ /**
8014
8042
  * Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
8015
8043
  * `watchDirs`, debounced + incremental rebuild + reload. For a fast rebuild the dev
8016
8044
  * build disables minification + expensive, NON-navigational outputs (feeds / sitemap /
@@ -9115,6 +9143,18 @@ function createApi(ctx) {
9115
9143
  */
9116
9144
  current() {
9117
9145
  return ctx.state.currentUrl;
9146
+ },
9147
+ /**
9148
+ * Resolve a registered island's api by name (the cross-island seam). Returns
9149
+ * `undefined` when no provider with that name is currently registered.
9150
+ *
9151
+ * @param name - The provider island's component name.
9152
+ * @returns The provider's api, or `undefined`.
9153
+ * @example
9154
+ * app.spa.component("lightbox");
9155
+ */
9156
+ component(name) {
9157
+ return ctx.state.componentApis.get(name);
9118
9158
  }
9119
9159
  };
9120
9160
  }
@@ -9155,6 +9195,15 @@ const COMPONENT_HOOK_NAMES = [
9155
9195
  const ERROR_PREFIX$2 = "[web]";
9156
9196
  /** The set of legal hook names, frozen for O(1) membership checks. */
9157
9197
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
9198
+ /** The spec-only keys that select the plugin-mirror form of {@link createComponent}. */
9199
+ const SPEC_KEYS = new Set([
9200
+ "state",
9201
+ "render",
9202
+ "events",
9203
+ "api"
9204
+ ]);
9205
+ /** Synchronous re-entrancy cap for the render scheduler (a render that calls `ctx.flush`). */
9206
+ const MAX_RENDER_DEPTH = 25;
9158
9207
  /**
9159
9208
  * No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
9160
9209
  *
@@ -9173,40 +9222,346 @@ const EMPTY_ROUTE = {
9173
9222
  url: noUrl
9174
9223
  };
9175
9224
  /**
9225
+ * No-op placeholder for an instance's `flush` slot until the real one is bound at mount.
9226
+ *
9227
+ * @example
9228
+ * const instance = { flush: noop };
9229
+ */
9230
+ function noop() {}
9231
+ /** Cached promise for the lazy `./render` chunk (loaded at most once per module). */
9232
+ let renderChunk;
9233
+ /** The resolved VNode committer once the chunk loads (undefined until then). */
9234
+ let commitVNodeFunction;
9235
+ /**
9236
+ * Load the lazy `./render` chunk (once) and cache its `commitVNode` for synchronous
9237
+ * use by later renders. Awaited by a component's `mountPromise` so the test harness's
9238
+ * `settle()` can deterministically flush a VNode render.
9239
+ *
9240
+ * @returns A promise that resolves once `commitVNode` is available.
9241
+ * @example
9242
+ * await loadRenderChunk();
9243
+ */
9244
+ async function loadRenderChunk() {
9245
+ renderChunk ??= import("./render-UO4nimWr.mjs");
9246
+ commitVNodeFunction = (await renderChunk).commitVNode;
9247
+ }
9248
+ /**
9249
+ * Commit a {@link RenderResult} into a host: `string` → `innerHTML`, `Node` →
9250
+ * `replaceChildren`, `void`/`undefined` → no-op (the render mutated the DOM itself), and
9251
+ * a Preact `VNode` → committed through the lazy gate (loading it on demand if needed).
9252
+ *
9253
+ * @param host - The island host element to render into.
9254
+ * @param result - The value returned by the component's `render`.
9255
+ * @example
9256
+ * commitResult(host, h(View, { items }));
9257
+ */
9258
+ function commitResult(host, result) {
9259
+ if (result === void 0) return;
9260
+ if (typeof result === "string") {
9261
+ host.innerHTML = result;
9262
+ return;
9263
+ }
9264
+ if (result instanceof Node) {
9265
+ host.replaceChildren(result);
9266
+ return;
9267
+ }
9268
+ const vnode = result;
9269
+ if (commitVNodeFunction) {
9270
+ commitVNodeFunction(vnode, host);
9271
+ return;
9272
+ }
9273
+ loadRenderChunk().then(() => commitVNodeFunction?.(vnode, host)).catch(() => {});
9274
+ }
9275
+ /**
9276
+ * Run a component's `render(state, ctx)` and commit the result now. Guards against
9277
+ * synchronous re-entrancy (a render that calls `ctx.flush`) with a depth cap.
9278
+ *
9279
+ * @param instance - The instance to render.
9280
+ * @throws {Error} When the synchronous render depth exceeds {@link MAX_RENDER_DEPTH}.
9281
+ * @example
9282
+ * runRender(instance);
9283
+ */
9284
+ function runRender(instance) {
9285
+ const render = instance.def.spec?.render;
9286
+ if (!render) return;
9287
+ if (instance.renderDepth > MAX_RENDER_DEPTH) throw new Error(`${ERROR_PREFIX$2} component "${instance.def.name}" render re-entered ${MAX_RENDER_DEPTH}+ times\n → a render must not synchronously trigger its own render (avoid ctx.flush() inside render)`);
9288
+ instance.renderDepth += 1;
9289
+ try {
9290
+ commitResult(instance.el, render(instance.state ?? {}, instance.ctx));
9291
+ } finally {
9292
+ instance.renderDepth -= 1;
9293
+ }
9294
+ }
9295
+ /**
9296
+ * Schedule a microtask-batched render for an instance (no-op when it has no `render`).
9297
+ * Multiple `ctx.set` calls in the same tick coalesce into a single render.
9298
+ *
9299
+ * @param instance - The instance to schedule a render for.
9300
+ * @example
9301
+ * scheduleRender(instance);
9302
+ */
9303
+ function scheduleRender(instance) {
9304
+ if (!instance.def.spec?.render || instance.renderScheduled) return;
9305
+ instance.renderScheduled = true;
9306
+ queueMicrotask(() => {
9307
+ if (!instance.renderScheduled) return;
9308
+ instance.renderScheduled = false;
9309
+ runRender(instance);
9310
+ });
9311
+ }
9312
+ /**
9313
+ * Build the single per-instance {@link ComponentContext} reused by every hook, event
9314
+ * handler, and render. Route fields (`params`/`meta`/`locale`/`url`) and `data` read
9315
+ * through the instance so a navigation update is reflected without rebuilding the ctx;
9316
+ * `state`/`set`/`flush`/`cleanup`/`component` are bound to the instance + plugin state.
9317
+ *
9318
+ * @param state - The plugin state (for the cross-island `component` resolver).
9319
+ * @param instance - The instance the context is bound to.
9320
+ * @returns The instance-bound context.
9321
+ * @example
9322
+ * instance.ctx = buildContext(state, instance);
9323
+ */
9324
+ function buildContext(state, instance) {
9325
+ return {
9326
+ el: instance.el,
9327
+ /**
9328
+ * The current page data payload (live; updated across navigations).
9329
+ *
9330
+ * @returns The page data.
9331
+ * @example
9332
+ * ctx.data;
9333
+ */
9334
+ get data() {
9335
+ return instance.data;
9336
+ },
9337
+ /**
9338
+ * The matched route's path params (live; updated across navigations).
9339
+ *
9340
+ * @returns The route params.
9341
+ * @example
9342
+ * ctx.params.id;
9343
+ */
9344
+ get params() {
9345
+ return instance.route.params;
9346
+ },
9347
+ /**
9348
+ * The matched route's `.meta()` bag (live; updated across navigations).
9349
+ *
9350
+ * @returns The route meta.
9351
+ * @example
9352
+ * ctx.meta.focus;
9353
+ */
9354
+ get meta() {
9355
+ return instance.route.meta;
9356
+ },
9357
+ /**
9358
+ * The active locale for the current route (live; updated across navigations).
9359
+ *
9360
+ * @returns The locale code.
9361
+ * @example
9362
+ * ctx.locale;
9363
+ */
9364
+ get locale() {
9365
+ return instance.route.locale;
9366
+ },
9367
+ /**
9368
+ * The named-route link builder for the current route.
9369
+ *
9370
+ * @returns The link builder.
9371
+ * @example
9372
+ * ctx.url("board", { id });
9373
+ */
9374
+ get url() {
9375
+ return instance.route.url;
9376
+ },
9377
+ /**
9378
+ * The live per-instance state (`undefined` for legacy hooks-only islands).
9379
+ *
9380
+ * @returns The current state.
9381
+ * @example
9382
+ * ctx.state.count;
9383
+ */
9384
+ get state() {
9385
+ return instance.state;
9386
+ },
9387
+ /**
9388
+ * Merge a patch into the per-instance state and schedule one batched render.
9389
+ *
9390
+ * @param patch - A partial state object, or an updater `(prev) => partial`.
9391
+ * @example
9392
+ * ctx.set(prev => ({ count: prev.count + 1 }));
9393
+ */
9394
+ set(patch) {
9395
+ const previous = instance.state ?? {};
9396
+ const next = typeof patch === "function" ? patch(previous) : patch;
9397
+ instance.state = Object.assign({}, previous, next);
9398
+ scheduleRender(instance);
9399
+ },
9400
+ /**
9401
+ * Force a synchronous render now (drains any pending scheduled render).
9402
+ *
9403
+ * @example
9404
+ * ctx.flush();
9405
+ */
9406
+ flush() {
9407
+ instance.flush();
9408
+ },
9409
+ /**
9410
+ * Register a disposer run on destroy (subscriptions, timers, manual listeners).
9411
+ *
9412
+ * @param dispose - The teardown function.
9413
+ * @example
9414
+ * ctx.cleanup(off);
9415
+ */
9416
+ cleanup(dispose) {
9417
+ instance.cleanups.push(dispose);
9418
+ },
9419
+ /**
9420
+ * Resolve another island's registered api by name (`undefined` when absent).
9421
+ *
9422
+ * @param name - The provider island's component name.
9423
+ * @returns The provider's api, or `undefined`.
9424
+ * @example
9425
+ * ctx.component("lightbox");
9426
+ */
9427
+ component(name) {
9428
+ return state.componentApis.get(name);
9429
+ }
9430
+ };
9431
+ }
9432
+ /**
9433
+ * Resolve the element a delegated handler should receive for an event: the host for a
9434
+ * host-level binding (empty selector), else the nearest ancestor of `event.target`
9435
+ * matching the selector that is still inside the host.
9436
+ *
9437
+ * @param host - The island host element.
9438
+ * @param event - The dispatched DOM event.
9439
+ * @param selector - The key's selector (empty string → host-level).
9440
+ * @returns The matched element, or `undefined` when nothing matches inside the host.
9441
+ * @example
9442
+ * const target = matchTarget(host, event, "[data-action]");
9443
+ */
9444
+ function matchTarget(host, event, selector) {
9445
+ if (selector === "") return host;
9446
+ const target = event.target;
9447
+ if (!(target instanceof Element)) return void 0;
9448
+ const matched = target.closest(selector);
9449
+ return matched && host.contains(matched) ? matched : void 0;
9450
+ }
9451
+ /**
9452
+ * Attach a component's declarative `events` map: one real listener per event TYPE on
9453
+ * the host (dispatch walks `closest(selector)` for each registered selector), each
9454
+ * removed via the instance's cleanup registry on destroy.
9455
+ *
9456
+ * @param instance - The instance whose host the listeners attach to.
9457
+ * @param events - The declarative `{ "<type> <selector>": handler }` map.
9458
+ * @throws {Error} When a key has no event type.
9459
+ * @example
9460
+ * attachEvents(instance, { "click [data-action]": (ctx, e, el) => {} });
9461
+ */
9462
+ function attachEvents(instance, events) {
9463
+ const host = instance.el;
9464
+ const byType = /* @__PURE__ */ new Map();
9465
+ for (const [key, handler] of Object.entries(events)) {
9466
+ const space = key.indexOf(" ");
9467
+ const type = (space === -1 ? key : key.slice(0, space)).trim();
9468
+ const selector = space === -1 ? "" : key.slice(space + 1).trim();
9469
+ if (type === "") throw new Error(`${ERROR_PREFIX$2} component "${instance.def.name}" event key must start with an event type: "${key}"\n → use "<type>" or "<type> <selector>" (e.g. "click [data-action]")`);
9470
+ const list = byType.get(type) ?? [];
9471
+ list.push({
9472
+ selector,
9473
+ handler
9474
+ });
9475
+ byType.set(type, list);
9476
+ }
9477
+ for (const [type, handlers] of byType) {
9478
+ const listener = (event) => {
9479
+ for (const { selector, handler } of handlers) {
9480
+ const target = matchTarget(host, event, selector);
9481
+ if (target) handler(instance.ctx, event, target);
9482
+ }
9483
+ };
9484
+ host.addEventListener(type, listener);
9485
+ instance.cleanups.push(() => host.removeEventListener(type, listener));
9486
+ }
9487
+ }
9488
+ /**
9176
9489
  * Validate a single hook entry: its key must be a known hook name and its value
9177
9490
  * must be a function. Throws fail-fast on the first violation.
9178
9491
  *
9179
9492
  * @param componentName - The owning component name (for error messages).
9180
- * @param hooks - The hooks object being validated.
9493
+ * @param source - The raw authoring object being validated.
9181
9494
  * @param key - The hook key to validate.
9182
9495
  * @throws {Error} If `key` is not in `COMPONENT_HOOK_NAMES`.
9183
9496
  * @throws {TypeError} If the hook value is not a function.
9184
9497
  * @example
9185
- * validateHookEntry("counter", hooks, "onMount");
9498
+ * validateHookEntry("counter", source, "onMount");
9186
9499
  */
9187
- function validateHookEntry(componentName, hooks, key) {
9188
- if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${componentName}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
9189
- if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${componentName}" must be a function\n → provide a function or omit the hook`);
9500
+ function validateHookEntry(componentName, source, key) {
9501
+ if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${componentName}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}\n → spec keys: state, render, events, api`);
9502
+ if (typeof source[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${componentName}" must be a function\n → provide a function or omit the hook`);
9190
9503
  }
9191
9504
  /**
9192
- * Create a validated component definition. Validates hook names at registration
9193
- * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
9194
- * each provided hook is a function.
9505
+ * Validate the spec extras (`state`/`render`/`api` must be functions; `events` must be
9506
+ * a plain object of functions). Throws fail-fast on the first violation.
9507
+ *
9508
+ * @param componentName - The owning component name (for error messages).
9509
+ * @param extras - The partitioned spec extras to validate.
9510
+ * @throws {TypeError} If a present extra has the wrong shape.
9511
+ * @example
9512
+ * validateSpecExtras("board", { state: () => ({}) });
9513
+ */
9514
+ function validateSpecExtras(componentName, extras) {
9515
+ for (const key of [
9516
+ "state",
9517
+ "render",
9518
+ "api"
9519
+ ]) if (extras[key] !== void 0 && typeof extras[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component "${key}" on "${componentName}" must be a function\n → provide a function or omit it`);
9520
+ if (extras.events !== void 0) {
9521
+ const events = extras.events;
9522
+ if (!(typeof events === "object")) throw new TypeError(`${ERROR_PREFIX$2} component "events" on "${componentName}" must be an object of handlers`);
9523
+ for (const [key, handler] of Object.entries(events)) if (typeof handler !== "function") throw new TypeError(`${ERROR_PREFIX$2} component event "${key}" on "${componentName}" must be a function`);
9524
+ }
9525
+ }
9526
+ /**
9527
+ * Create a validated component definition. Accepts either the legacy hooks-only form
9528
+ * (`createComponent("counter", { onMount() {} })`) or the plugin-mirror spec form
9529
+ * (`createComponent("board", { state, render, events, api, ...hooks })`). Spec-only
9530
+ * keys (`state`/`render`/`events`/`api`) are partitioned out before hook-name
9531
+ * validation, so a real typo (e.g. `onMout`) still throws immediately while the spec
9532
+ * keys are accepted.
9195
9533
  *
9196
9534
  * @param name - Unique component name.
9197
- * @param hooks - Lifecycle hook implementations.
9535
+ * @param spec - Lifecycle hooks, or the `{ state, render, events, api, ...hooks }` spec.
9198
9536
  * @returns A `ComponentDef` ready to `register`.
9199
- * @throws {Error} If `name` is empty, any hook key is not in
9200
- * `COMPONENT_HOOK_NAMES`, or any provided hook value is not a function.
9537
+ * @throws {Error} If `name` is empty, a hook key is unknown, or an extra/hook value has the wrong shape.
9538
+ * @example
9539
+ * const counter = createComponent("counter", { onMount({ el }) { el.textContent = "0"; } });
9201
9540
  * @example
9202
- * const counter = createComponent("counter", {
9203
- * onMount({ el }) { el.textContent = "0"; }
9541
+ * const list = createComponent<{ items: string[] }>("list", {
9542
+ * state: () => ({ items: [] }),
9543
+ * render: (s) => h(List, { items: s.items })
9204
9544
  * });
9205
9545
  */
9206
- function createComponent(name, hooks) {
9546
+ function createComponent(name, spec) {
9207
9547
  if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2} component name must be a non-empty string\n → pass a unique name to createComponent("name", hooks)`);
9208
- for (const key of Object.keys(hooks)) validateHookEntry(name, hooks, key);
9209
- return {
9548
+ const source = spec;
9549
+ const hooks = {};
9550
+ const extras = {};
9551
+ for (const key of Object.keys(source)) {
9552
+ if (SPEC_KEYS.has(key)) {
9553
+ extras[key] = source[key];
9554
+ continue;
9555
+ }
9556
+ validateHookEntry(name, source, key);
9557
+ hooks[key] = source[key];
9558
+ }
9559
+ validateSpecExtras(name, extras);
9560
+ return Object.keys(extras).length > 0 ? {
9561
+ name,
9562
+ hooks,
9563
+ spec: extras
9564
+ } : {
9210
9565
  name,
9211
9566
  hooks
9212
9567
  };
@@ -9230,64 +9585,53 @@ function extractPageData(doc) {
9230
9585
  }
9231
9586
  }
9232
9587
  /**
9233
- * Builds a live component instance bound to an element.
9588
+ * Read the current page data, or `{}` in a headless (non-browser) context.
9234
9589
  *
9235
- * @param definition - The component definition.
9236
- * @param element - The element the instance binds to.
9237
- * @param persistent - Whether the instance survives navigation.
9238
- * @returns The constructed (not-yet-mounted) instance.
9590
+ * @returns The current page data payload.
9239
9591
  * @example
9240
- * const inst = createInstance(definition, element, false);
9592
+ * const data = currentPageData();
9241
9593
  */
9242
- function createInstance(definition, element, persistent) {
9243
- return {
9244
- def: definition,
9245
- el: element,
9246
- persistent
9247
- };
9594
+ function currentPageData() {
9595
+ return typeof document === "undefined" ? {} : extractPageData(document);
9248
9596
  }
9249
9597
  /**
9250
- * Invokes a single lifecycle hook on an instance with its component context.
9251
- * Missing hooks are skipped silently.
9598
+ * Invokes a single lifecycle hook on an instance with its bound context. Missing
9599
+ * hooks are skipped silently.
9252
9600
  *
9253
9601
  * @param instance - The instance whose hook to run.
9254
9602
  * @param hook - The hook name to invoke.
9255
- * @param ctx - The component context passed to the hook.
9256
9603
  * @example
9257
- * runHook(instance, "onMount", ctx);
9604
+ * runHook(instance, "onDestroy");
9258
9605
  */
9259
- function runHook(instance, hook, ctx) {
9260
- instance.def.hooks[hook]?.(ctx);
9606
+ function runHook(instance, hook) {
9607
+ instance.def.hooks[hook]?.(instance.ctx);
9261
9608
  }
9262
9609
  /**
9263
- * Builds the component context handed to a hook: the bound element + page data, merged
9264
- * with the matched route's slice (params/meta/locale/url). Defaults to {@link EMPTY_ROUTE}
9265
- * when no route is supplied (headless, tests, public `scan()`).
9610
+ * Run an instance's registered cleanup disposers (LIFO) and unregister its api. Each
9611
+ * disposer runs in isolation so a throwing one never strands the others during teardown.
9266
9612
  *
9267
- * @param element - The element the instance is bound to.
9268
- * @param data - The current page data payload.
9269
- * @param route - The matched-route slice for the current URL.
9270
- * @returns The hook context.
9613
+ * @param state - The plugin state (for the api registry).
9614
+ * @param instance - The instance being disposed.
9271
9615
  * @example
9272
- * const ctx = makeContext(element, data, route);
9616
+ * disposeInstance(state, instance);
9273
9617
  */
9274
- function makeContext(element, data, route = EMPTY_ROUTE) {
9275
- return {
9276
- el: element,
9277
- data,
9278
- params: route.params,
9279
- meta: route.meta,
9280
- locale: route.locale,
9281
- url: route.url
9282
- };
9618
+ function disposeInstance(state, instance) {
9619
+ for (let index = instance.cleanups.length - 1; index >= 0; index -= 1) try {
9620
+ instance.cleanups[index]?.();
9621
+ } catch {}
9622
+ instance.cleanups.length = 0;
9623
+ instance.renderScheduled = false;
9624
+ if (instance.api !== void 0 && state.componentApis.get(instance.def.name) === instance.api) state.componentApis.delete(instance.def.name);
9283
9625
  }
9284
9626
  /**
9285
- * Mounts a single `data-component` element: classifies persistent vs
9286
- * page-specific, builds the instance, fires `onCreate` then `onMount`, records
9287
- * it in state, and emits `spa:component-mount`. No-ops if the element is already
9627
+ * Mounts a single `data-component` element: classifies persistent vs page-specific,
9628
+ * builds the instance + its bound context, initializes per-instance `state`, registers
9629
+ * its `api`, attaches declarative `events`, fires `onCreate` then `onMount` (capturing
9630
+ * an async `onMount` + render-chunk load as `mountPromise`), schedules the initial
9631
+ * render, records it, and emits `spa:component-mount`. No-ops if the element is already
9288
9632
  * mounted, has no component name, or names an unregistered component.
9289
9633
  *
9290
- * @param state - The plugin state (registeredComponents + instances).
9634
+ * @param state - The plugin state (registeredComponents + instances + componentApis).
9291
9635
  * @param emit - The event emitter for spa:component-mount.
9292
9636
  * @param swapArea - The swap-region element, or null when none was found.
9293
9637
  * @param data - The current page data payload.
@@ -9302,10 +9646,40 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
9302
9646
  if (!name) return;
9303
9647
  const definition = state.registeredComponents.get(name);
9304
9648
  if (!definition) return;
9305
- const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
9306
- const ctx = makeContext(element, data, route);
9307
- runHook(instance, "onCreate", ctx);
9308
- runHook(instance, "onMount", ctx);
9649
+ const instance = {
9650
+ def: definition,
9651
+ el: element,
9652
+ persistent: swapArea ? !swapArea.contains(element) : true,
9653
+ ctx: void 0,
9654
+ state: void 0,
9655
+ api: void 0,
9656
+ route,
9657
+ data,
9658
+ cleanups: [],
9659
+ flush: noop,
9660
+ renderScheduled: false,
9661
+ renderDepth: 0,
9662
+ mountPromise: void 0
9663
+ };
9664
+ instance.ctx = buildContext(state, instance);
9665
+ instance.flush = () => {
9666
+ instance.renderScheduled = false;
9667
+ runRender(instance);
9668
+ };
9669
+ const spec = definition.spec;
9670
+ if (spec?.state) instance.state = spec.state(instance.ctx);
9671
+ if (spec?.api) {
9672
+ instance.api = spec.api(instance.ctx);
9673
+ state.componentApis.set(definition.name, instance.api);
9674
+ }
9675
+ if (spec?.events) attachEvents(instance, spec.events);
9676
+ runHook(instance, "onCreate");
9677
+ const onMountResult = definition.hooks.onMount?.(instance.ctx);
9678
+ if (spec?.render) scheduleRender(instance);
9679
+ const pending = [];
9680
+ if (spec?.render) pending.push(loadRenderChunk());
9681
+ if (onMountResult && typeof onMountResult.then === "function") pending.push(onMountResult);
9682
+ instance.mountPromise = pending.length > 0 ? Promise.all(pending).then(() => {}) : void 0;
9309
9683
  state.instances.set(element, instance);
9310
9684
  emit("spa:component-mount", {
9311
9685
  name: definition.name,
@@ -9313,12 +9687,12 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
9313
9687
  });
9314
9688
  }
9315
9689
  /**
9316
- * Scans the swap region, mounts components for matching `data-component`
9317
- * elements, classifies persistent (outside swap area) vs page-specific (inside),
9318
- * fires `onCreate` then `onMount`, and emits `spa:component-mount` per instance.
9690
+ * Scans the swap region, mounts components for matching `data-component` elements,
9691
+ * classifies persistent (outside swap area) vs page-specific (inside), runs
9692
+ * `onCreate`/`onMount` + initial render, and emits `spa:component-mount` per instance.
9319
9693
  * Already-mounted elements are skipped.
9320
9694
  *
9321
- * @param state - The plugin state (registeredComponents + instances).
9695
+ * @param state - The plugin state (registeredComponents + instances + componentApis).
9322
9696
  * @param emit - The event emitter for spa:component-mount.
9323
9697
  * @param swapSelector - CSS selector bounding page-specific components.
9324
9698
  * @param route - The matched-route slice for the current URL (params/meta/locale/url).
@@ -9332,9 +9706,10 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
9332
9706
  for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
9333
9707
  }
9334
9708
  /**
9335
- * Unmounts page-specific instances inside the swap region (runs `onUnMount`
9336
- * then `onDestroy`), removes them from state, and emits `spa:component-unmount`.
9337
- * Persistent instances (outside the swap area) are left in place.
9709
+ * Unmounts page-specific instances inside the swap region (runs `onUnMount` then
9710
+ * `onDestroy`, then their cleanup disposers + api unregister), removes them from state,
9711
+ * and emits `spa:component-unmount`. Persistent instances (outside the swap area) are
9712
+ * left in place.
9338
9713
  *
9339
9714
  * @param state - The plugin state holding live instances.
9340
9715
  * @param emit - The event emitter for spa:component-unmount.
@@ -9342,12 +9717,13 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
9342
9717
  * unmountPageSpecific(state, emit);
9343
9718
  */
9344
9719
  function unmountPageSpecific(state, emit) {
9345
- const data = typeof document === "undefined" ? {} : extractPageData(document);
9720
+ const data = currentPageData();
9346
9721
  for (const [element, instance] of state.instances) {
9347
9722
  if (instance.persistent) continue;
9348
- const ctx = makeContext(element, data);
9349
- runHook(instance, "onUnMount", ctx);
9350
- runHook(instance, "onDestroy", ctx);
9723
+ instance.data = data;
9724
+ runHook(instance, "onUnMount");
9725
+ runHook(instance, "onDestroy");
9726
+ disposeInstance(state, instance);
9351
9727
  state.instances.delete(element);
9352
9728
  emit("spa:component-unmount", {
9353
9729
  name: instance.def.name,
@@ -9356,9 +9732,10 @@ function unmountPageSpecific(state, emit) {
9356
9732
  }
9357
9733
  }
9358
9734
  /**
9359
- * Disposes ALL live instances (persistent and page-specific) on teardown:
9360
- * runs `onUnMount` then `onDestroy`, emits `spa:component-unmount`, and clears
9361
- * the instance map. Used by the kernel's `dispose` on plugin stop.
9735
+ * Disposes ALL live instances (persistent and page-specific) on teardown: runs
9736
+ * `onUnMount` then `onDestroy`, then their cleanup disposers + api unregister, emits
9737
+ * `spa:component-unmount`, and clears the instance + api maps. Used by the kernel's
9738
+ * `dispose` on plugin stop.
9362
9739
  *
9363
9740
  * @param state - The plugin state holding live instances.
9364
9741
  * @param emit - The event emitter for spa:component-unmount.
@@ -9366,17 +9743,19 @@ function unmountPageSpecific(state, emit) {
9366
9743
  * unmountAll(state, emit);
9367
9744
  */
9368
9745
  function unmountAll(state, emit) {
9369
- const data = typeof document === "undefined" ? {} : extractPageData(document);
9746
+ const data = currentPageData();
9370
9747
  for (const [element, instance] of state.instances) {
9371
- const ctx = makeContext(element, data);
9372
- runHook(instance, "onUnMount", ctx);
9373
- runHook(instance, "onDestroy", ctx);
9748
+ instance.data = data;
9749
+ runHook(instance, "onUnMount");
9750
+ runHook(instance, "onDestroy");
9751
+ disposeInstance(state, instance);
9374
9752
  emit("spa:component-unmount", {
9375
9753
  name: instance.def.name,
9376
9754
  el: element
9377
9755
  });
9378
9756
  }
9379
9757
  state.instances.clear();
9758
+ state.componentApis.clear();
9380
9759
  }
9381
9760
  /**
9382
9761
  * Fires `onNavStart` on every currently-mounted instance (persistent instances
@@ -9387,12 +9766,16 @@ function unmountAll(state, emit) {
9387
9766
  * notifyNavStart(state);
9388
9767
  */
9389
9768
  function notifyNavStart(state) {
9390
- const data = typeof document === "undefined" ? {} : extractPageData(document);
9391
- for (const [element, instance] of state.instances) runHook(instance, "onNavStart", makeContext(element, data));
9769
+ const data = currentPageData();
9770
+ for (const instance of state.instances.values()) {
9771
+ instance.data = data;
9772
+ runHook(instance, "onNavStart");
9773
+ }
9392
9774
  }
9393
9775
  /**
9394
9776
  * Fires `onNavEnd` on persistent instances that survived the swap (page-specific
9395
- * instances were already destroyed and re-created by the swap).
9777
+ * instances were already destroyed and re-created by the swap), updating their route
9778
+ * slice to the destination first.
9396
9779
  *
9397
9780
  * @param state - The plugin state holding live instances.
9398
9781
  * @param route - The matched-route slice for the destination URL (params/meta/locale/url).
@@ -9400,8 +9783,13 @@ function notifyNavStart(state) {
9400
9783
  * notifyNavEnd(state, route);
9401
9784
  */
9402
9785
  function notifyNavEnd(state, route = EMPTY_ROUTE) {
9403
- const data = typeof document === "undefined" ? {} : extractPageData(document);
9404
- for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data, route));
9786
+ const data = currentPageData();
9787
+ for (const instance of state.instances.values()) {
9788
+ if (!instance.persistent) continue;
9789
+ instance.data = data;
9790
+ instance.route = route;
9791
+ runHook(instance, "onNavEnd");
9792
+ }
9405
9793
  }
9406
9794
  //#endregion
9407
9795
  //#region src/plugins/spa/head.ts
@@ -9942,6 +10330,7 @@ function createState(_ctx) {
9942
10330
  return {
9943
10331
  registeredComponents: /* @__PURE__ */ new Map(),
9944
10332
  instances: /* @__PURE__ */ new Map(),
10333
+ componentApis: /* @__PURE__ */ new Map(),
9945
10334
  currentUrl: "",
9946
10335
  destroyRouter: null,
9947
10336
  started: false,
@@ -10180,7 +10569,7 @@ function createSpaKernel(state, config, emit, deps) {
10180
10569
  const commitDataRender = async (pathname, resolvedRender, signal) => {
10181
10570
  if (signal?.aborted) return;
10182
10571
  const { route, vnode, routeContext, region } = resolvedRender;
10183
- const { renderVNode } = await import("./render-BNe0s7fr.mjs");
10572
+ const { renderVNode } = await import("./render-UO4nimWr.mjs");
10184
10573
  if (signal?.aborted) return;
10185
10574
  syncDataHead(deps.head, route, routeContext);
10186
10575
  unmountPageSpecific(state, emit);
@@ -10249,7 +10638,7 @@ function createSpaKernel(state, config, emit, deps) {
10249
10638
  return;
10250
10639
  }
10251
10640
  const { vnode, region } = resolvedRender;
10252
- const { renderVNode } = await import("./render-BNe0s7fr.mjs");
10641
+ const { renderVNode } = await import("./render-UO4nimWr.mjs");
10253
10642
  renderVNode(vnode, region);
10254
10643
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10255
10644
  };