@moku-labs/web 1.15.1 → 1.16.1

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.cjs CHANGED
@@ -9156,6 +9156,18 @@ function createApi(ctx) {
9156
9156
  */
9157
9157
  current() {
9158
9158
  return ctx.state.currentUrl;
9159
+ },
9160
+ /**
9161
+ * Resolve a registered island's api by name (the cross-island seam). Returns
9162
+ * `undefined` when no provider with that name is currently registered.
9163
+ *
9164
+ * @param name - The provider island's component name.
9165
+ * @returns The provider's api, or `undefined`.
9166
+ * @example
9167
+ * app.spa.component("lightbox");
9168
+ */
9169
+ component(name) {
9170
+ return ctx.state.componentApis.get(name);
9159
9171
  }
9160
9172
  };
9161
9173
  }
@@ -9192,10 +9204,26 @@ const COMPONENT_HOOK_NAMES = [
9192
9204
  ];
9193
9205
  //#endregion
9194
9206
  //#region src/plugins/spa/components.ts
9207
+ /**
9208
+ * @file spa plugin — component lifecycle, mounting, the plugin-mirror authoring
9209
+ * surface (`createComponent` with a typed `{ state, render, events, api }` spec),
9210
+ * the per-instance state + microtask-batched render scheduler, declarative
9211
+ * delegated events, and the cross-island api registry.
9212
+ * @see README.md
9213
+ */
9195
9214
  /** Error prefix for spa fail-fast failures (spec/11 Part-3). */
9196
9215
  const ERROR_PREFIX$2 = "[web]";
9197
9216
  /** The set of legal hook names, frozen for O(1) membership checks. */
9198
9217
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
9218
+ /** The spec-only keys that select the plugin-mirror form of {@link createComponent}. */
9219
+ const SPEC_KEYS = new Set([
9220
+ "state",
9221
+ "render",
9222
+ "events",
9223
+ "api"
9224
+ ]);
9225
+ /** Synchronous re-entrancy cap for the render scheduler (a render that calls `ctx.flush`). */
9226
+ const MAX_RENDER_DEPTH = 25;
9199
9227
  /**
9200
9228
  * No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
9201
9229
  *
@@ -9214,40 +9242,346 @@ const EMPTY_ROUTE = {
9214
9242
  url: noUrl
9215
9243
  };
9216
9244
  /**
9245
+ * No-op placeholder for an instance's `flush` slot until the real one is bound at mount.
9246
+ *
9247
+ * @example
9248
+ * const instance = { flush: noop };
9249
+ */
9250
+ function noop() {}
9251
+ /** Cached promise for the lazy `./render` chunk (loaded at most once per module). */
9252
+ let renderChunk;
9253
+ /** The resolved VNode committer once the chunk loads (undefined until then). */
9254
+ let commitVNodeFunction;
9255
+ /**
9256
+ * Load the lazy `./render` chunk (once) and cache its `commitVNode` for synchronous
9257
+ * use by later renders. Awaited by a component's `mountPromise` so the test harness's
9258
+ * `settle()` can deterministically flush a VNode render.
9259
+ *
9260
+ * @returns A promise that resolves once `commitVNode` is available.
9261
+ * @example
9262
+ * await loadRenderChunk();
9263
+ */
9264
+ async function loadRenderChunk() {
9265
+ renderChunk ??= Promise.resolve().then(() => require("./render-KdufA3_b.cjs"));
9266
+ commitVNodeFunction = (await renderChunk).commitVNode;
9267
+ }
9268
+ /**
9269
+ * Commit a {@link RenderResult} into a host: `string` → `innerHTML`, `Node` →
9270
+ * `replaceChildren`, `void`/`undefined` → no-op (the render mutated the DOM itself), and
9271
+ * a Preact `VNode` → committed through the lazy gate (loading it on demand if needed).
9272
+ *
9273
+ * @param host - The island host element to render into.
9274
+ * @param result - The value returned by the component's `render`.
9275
+ * @example
9276
+ * commitResult(host, h(View, { items }));
9277
+ */
9278
+ function commitResult(host, result) {
9279
+ if (result === void 0) return;
9280
+ if (typeof result === "string") {
9281
+ host.innerHTML = result;
9282
+ return;
9283
+ }
9284
+ if (result instanceof Node) {
9285
+ host.replaceChildren(result);
9286
+ return;
9287
+ }
9288
+ const vnode = result;
9289
+ if (commitVNodeFunction) {
9290
+ commitVNodeFunction(vnode, host);
9291
+ return;
9292
+ }
9293
+ loadRenderChunk().then(() => commitVNodeFunction?.(vnode, host)).catch(() => {});
9294
+ }
9295
+ /**
9296
+ * Run a component's `render(state, ctx)` and commit the result now. Guards against
9297
+ * synchronous re-entrancy (a render that calls `ctx.flush`) with a depth cap.
9298
+ *
9299
+ * @param instance - The instance to render.
9300
+ * @throws {Error} When the synchronous render depth exceeds {@link MAX_RENDER_DEPTH}.
9301
+ * @example
9302
+ * runRender(instance);
9303
+ */
9304
+ function runRender(instance) {
9305
+ const render = instance.def.spec?.render;
9306
+ if (!render) return;
9307
+ 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)`);
9308
+ instance.renderDepth += 1;
9309
+ try {
9310
+ commitResult(instance.el, render(instance.state ?? {}, instance.ctx));
9311
+ } finally {
9312
+ instance.renderDepth -= 1;
9313
+ }
9314
+ }
9315
+ /**
9316
+ * Schedule a microtask-batched render for an instance (no-op when it has no `render`).
9317
+ * Multiple `ctx.set` calls in the same tick coalesce into a single render.
9318
+ *
9319
+ * @param instance - The instance to schedule a render for.
9320
+ * @example
9321
+ * scheduleRender(instance);
9322
+ */
9323
+ function scheduleRender(instance) {
9324
+ if (!instance.def.spec?.render || instance.renderScheduled) return;
9325
+ instance.renderScheduled = true;
9326
+ queueMicrotask(() => {
9327
+ if (!instance.renderScheduled) return;
9328
+ instance.renderScheduled = false;
9329
+ runRender(instance);
9330
+ });
9331
+ }
9332
+ /**
9333
+ * Build the single per-instance {@link ComponentContext} reused by every hook, event
9334
+ * handler, and render. Route fields (`params`/`meta`/`locale`/`url`) and `data` read
9335
+ * through the instance so a navigation update is reflected without rebuilding the ctx;
9336
+ * `state`/`set`/`flush`/`cleanup`/`component` are bound to the instance + plugin state.
9337
+ *
9338
+ * @param state - The plugin state (for the cross-island `component` resolver).
9339
+ * @param instance - The instance the context is bound to.
9340
+ * @returns The instance-bound context.
9341
+ * @example
9342
+ * instance.ctx = buildContext(state, instance);
9343
+ */
9344
+ function buildContext(state, instance) {
9345
+ return {
9346
+ el: instance.el,
9347
+ /**
9348
+ * The current page data payload (live; updated across navigations).
9349
+ *
9350
+ * @returns The page data.
9351
+ * @example
9352
+ * ctx.data;
9353
+ */
9354
+ get data() {
9355
+ return instance.data;
9356
+ },
9357
+ /**
9358
+ * The matched route's path params (live; updated across navigations).
9359
+ *
9360
+ * @returns The route params.
9361
+ * @example
9362
+ * ctx.params.id;
9363
+ */
9364
+ get params() {
9365
+ return instance.route.params;
9366
+ },
9367
+ /**
9368
+ * The matched route's `.meta()` bag (live; updated across navigations).
9369
+ *
9370
+ * @returns The route meta.
9371
+ * @example
9372
+ * ctx.meta.focus;
9373
+ */
9374
+ get meta() {
9375
+ return instance.route.meta;
9376
+ },
9377
+ /**
9378
+ * The active locale for the current route (live; updated across navigations).
9379
+ *
9380
+ * @returns The locale code.
9381
+ * @example
9382
+ * ctx.locale;
9383
+ */
9384
+ get locale() {
9385
+ return instance.route.locale;
9386
+ },
9387
+ /**
9388
+ * The named-route link builder for the current route.
9389
+ *
9390
+ * @returns The link builder.
9391
+ * @example
9392
+ * ctx.url("board", { id });
9393
+ */
9394
+ get url() {
9395
+ return instance.route.url;
9396
+ },
9397
+ /**
9398
+ * The live per-instance state (`undefined` for legacy hooks-only islands).
9399
+ *
9400
+ * @returns The current state.
9401
+ * @example
9402
+ * ctx.state.count;
9403
+ */
9404
+ get state() {
9405
+ return instance.state;
9406
+ },
9407
+ /**
9408
+ * Merge a patch into the per-instance state and schedule one batched render.
9409
+ *
9410
+ * @param patch - A partial state object, or an updater `(prev) => partial`.
9411
+ * @example
9412
+ * ctx.set(prev => ({ count: prev.count + 1 }));
9413
+ */
9414
+ set(patch) {
9415
+ const previous = instance.state ?? {};
9416
+ const next = typeof patch === "function" ? patch(previous) : patch;
9417
+ instance.state = Object.assign({}, previous, next);
9418
+ scheduleRender(instance);
9419
+ },
9420
+ /**
9421
+ * Force a synchronous render now (drains any pending scheduled render).
9422
+ *
9423
+ * @example
9424
+ * ctx.flush();
9425
+ */
9426
+ flush() {
9427
+ instance.flush();
9428
+ },
9429
+ /**
9430
+ * Register a disposer run on destroy (subscriptions, timers, manual listeners).
9431
+ *
9432
+ * @param dispose - The teardown function.
9433
+ * @example
9434
+ * ctx.cleanup(off);
9435
+ */
9436
+ cleanup(dispose) {
9437
+ instance.cleanups.push(dispose);
9438
+ },
9439
+ /**
9440
+ * Resolve another island's registered api by name (`undefined` when absent).
9441
+ *
9442
+ * @param name - The provider island's component name.
9443
+ * @returns The provider's api, or `undefined`.
9444
+ * @example
9445
+ * ctx.component("lightbox");
9446
+ */
9447
+ component(name) {
9448
+ return state.componentApis.get(name);
9449
+ }
9450
+ };
9451
+ }
9452
+ /**
9453
+ * Resolve the element a delegated handler should receive for an event: the host for a
9454
+ * host-level binding (empty selector), else the nearest ancestor of `event.target`
9455
+ * matching the selector that is still inside the host.
9456
+ *
9457
+ * @param host - The island host element.
9458
+ * @param event - The dispatched DOM event.
9459
+ * @param selector - The key's selector (empty string → host-level).
9460
+ * @returns The matched element, or `undefined` when nothing matches inside the host.
9461
+ * @example
9462
+ * const target = matchTarget(host, event, "[data-action]");
9463
+ */
9464
+ function matchTarget(host, event, selector) {
9465
+ if (selector === "") return host;
9466
+ const target = event.target;
9467
+ if (!(target instanceof Element)) return void 0;
9468
+ const matched = target.closest(selector);
9469
+ return matched && host.contains(matched) ? matched : void 0;
9470
+ }
9471
+ /**
9472
+ * Attach a component's declarative `events` map: one real listener per event TYPE on
9473
+ * the host (dispatch walks `closest(selector)` for each registered selector), each
9474
+ * removed via the instance's cleanup registry on destroy.
9475
+ *
9476
+ * @param instance - The instance whose host the listeners attach to.
9477
+ * @param events - The declarative `{ "<type> <selector>": handler }` map.
9478
+ * @throws {Error} When a key has no event type.
9479
+ * @example
9480
+ * attachEvents(instance, { "click [data-action]": (ctx, e, el) => {} });
9481
+ */
9482
+ function attachEvents(instance, events) {
9483
+ const host = instance.el;
9484
+ const byType = /* @__PURE__ */ new Map();
9485
+ for (const [key, handler] of Object.entries(events)) {
9486
+ const space = key.indexOf(" ");
9487
+ const type = (space === -1 ? key : key.slice(0, space)).trim();
9488
+ const selector = space === -1 ? "" : key.slice(space + 1).trim();
9489
+ 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]")`);
9490
+ const list = byType.get(type) ?? [];
9491
+ list.push({
9492
+ selector,
9493
+ handler
9494
+ });
9495
+ byType.set(type, list);
9496
+ }
9497
+ for (const [type, handlers] of byType) {
9498
+ const listener = (event) => {
9499
+ for (const { selector, handler } of handlers) {
9500
+ const target = matchTarget(host, event, selector);
9501
+ if (target) handler(instance.ctx, event, target);
9502
+ }
9503
+ };
9504
+ host.addEventListener(type, listener);
9505
+ instance.cleanups.push(() => host.removeEventListener(type, listener));
9506
+ }
9507
+ }
9508
+ /**
9217
9509
  * Validate a single hook entry: its key must be a known hook name and its value
9218
9510
  * must be a function. Throws fail-fast on the first violation.
9219
9511
  *
9220
9512
  * @param componentName - The owning component name (for error messages).
9221
- * @param hooks - The hooks object being validated.
9513
+ * @param source - The raw authoring object being validated.
9222
9514
  * @param key - The hook key to validate.
9223
9515
  * @throws {Error} If `key` is not in `COMPONENT_HOOK_NAMES`.
9224
9516
  * @throws {TypeError} If the hook value is not a function.
9225
9517
  * @example
9226
- * validateHookEntry("counter", hooks, "onMount");
9518
+ * validateHookEntry("counter", source, "onMount");
9227
9519
  */
9228
- function validateHookEntry(componentName, hooks, key) {
9229
- 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(", ")}`);
9230
- 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`);
9520
+ function validateHookEntry(componentName, source, key) {
9521
+ 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`);
9522
+ 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`);
9523
+ }
9524
+ /**
9525
+ * Validate the spec extras (`state`/`render`/`api` must be functions; `events` must be
9526
+ * a plain object of functions). Throws fail-fast on the first violation.
9527
+ *
9528
+ * @param componentName - The owning component name (for error messages).
9529
+ * @param extras - The partitioned spec extras to validate.
9530
+ * @throws {TypeError} If a present extra has the wrong shape.
9531
+ * @example
9532
+ * validateSpecExtras("board", { state: () => ({}) });
9533
+ */
9534
+ function validateSpecExtras(componentName, extras) {
9535
+ for (const key of [
9536
+ "state",
9537
+ "render",
9538
+ "api"
9539
+ ]) 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`);
9540
+ if (extras.events !== void 0) {
9541
+ const events = extras.events;
9542
+ if (!(typeof events === "object")) throw new TypeError(`${ERROR_PREFIX$2} component "events" on "${componentName}" must be an object of handlers`);
9543
+ 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`);
9544
+ }
9231
9545
  }
9232
9546
  /**
9233
- * Create a validated component definition. Validates hook names at registration
9234
- * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
9235
- * each provided hook is a function.
9547
+ * Create a validated component definition. Accepts either the legacy hooks-only form
9548
+ * (`createComponent("counter", { onMount() {} })`) or the plugin-mirror spec form
9549
+ * (`createComponent("board", { state, render, events, api, ...hooks })`). Spec-only
9550
+ * keys (`state`/`render`/`events`/`api`) are partitioned out before hook-name
9551
+ * validation, so a real typo (e.g. `onMout`) still throws immediately while the spec
9552
+ * keys are accepted.
9236
9553
  *
9237
9554
  * @param name - Unique component name.
9238
- * @param hooks - Lifecycle hook implementations.
9555
+ * @param spec - Lifecycle hooks, or the `{ state, render, events, api, ...hooks }` spec.
9239
9556
  * @returns A `ComponentDef` ready to `register`.
9240
- * @throws {Error} If `name` is empty, any hook key is not in
9241
- * `COMPONENT_HOOK_NAMES`, or any provided hook value is not a function.
9557
+ * @throws {Error} If `name` is empty, a hook key is unknown, or an extra/hook value has the wrong shape.
9242
9558
  * @example
9243
- * const counter = createComponent("counter", {
9244
- * onMount({ el }) { el.textContent = "0"; }
9559
+ * const counter = createComponent("counter", { onMount({ el }) { el.textContent = "0"; } });
9560
+ * @example
9561
+ * const list = createComponent<{ items: string[] }>("list", {
9562
+ * state: () => ({ items: [] }),
9563
+ * render: (s) => h(List, { items: s.items })
9245
9564
  * });
9246
9565
  */
9247
- function createComponent(name, hooks) {
9566
+ function createComponent(name, spec) {
9248
9567
  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)`);
9249
- for (const key of Object.keys(hooks)) validateHookEntry(name, hooks, key);
9250
- return {
9568
+ const source = spec;
9569
+ const hooks = {};
9570
+ const extras = {};
9571
+ for (const key of Object.keys(source)) {
9572
+ if (SPEC_KEYS.has(key)) {
9573
+ extras[key] = source[key];
9574
+ continue;
9575
+ }
9576
+ validateHookEntry(name, source, key);
9577
+ hooks[key] = source[key];
9578
+ }
9579
+ validateSpecExtras(name, extras);
9580
+ return Object.keys(extras).length > 0 ? {
9581
+ name,
9582
+ hooks,
9583
+ spec: extras
9584
+ } : {
9251
9585
  name,
9252
9586
  hooks
9253
9587
  };
@@ -9271,64 +9605,53 @@ function extractPageData(doc) {
9271
9605
  }
9272
9606
  }
9273
9607
  /**
9274
- * Builds a live component instance bound to an element.
9608
+ * Read the current page data, or `{}` in a headless (non-browser) context.
9275
9609
  *
9276
- * @param definition - The component definition.
9277
- * @param element - The element the instance binds to.
9278
- * @param persistent - Whether the instance survives navigation.
9279
- * @returns The constructed (not-yet-mounted) instance.
9610
+ * @returns The current page data payload.
9280
9611
  * @example
9281
- * const inst = createInstance(definition, element, false);
9612
+ * const data = currentPageData();
9282
9613
  */
9283
- function createInstance(definition, element, persistent) {
9284
- return {
9285
- def: definition,
9286
- el: element,
9287
- persistent
9288
- };
9614
+ function currentPageData() {
9615
+ return typeof document === "undefined" ? {} : extractPageData(document);
9289
9616
  }
9290
9617
  /**
9291
- * Invokes a single lifecycle hook on an instance with its component context.
9292
- * Missing hooks are skipped silently.
9618
+ * Invokes a single lifecycle hook on an instance with its bound context. Missing
9619
+ * hooks are skipped silently.
9293
9620
  *
9294
9621
  * @param instance - The instance whose hook to run.
9295
9622
  * @param hook - The hook name to invoke.
9296
- * @param ctx - The component context passed to the hook.
9297
9623
  * @example
9298
- * runHook(instance, "onMount", ctx);
9624
+ * runHook(instance, "onDestroy");
9299
9625
  */
9300
- function runHook(instance, hook, ctx) {
9301
- instance.def.hooks[hook]?.(ctx);
9626
+ function runHook(instance, hook) {
9627
+ instance.def.hooks[hook]?.(instance.ctx);
9302
9628
  }
9303
9629
  /**
9304
- * Builds the component context handed to a hook: the bound element + page data, merged
9305
- * with the matched route's slice (params/meta/locale/url). Defaults to {@link EMPTY_ROUTE}
9306
- * when no route is supplied (headless, tests, public `scan()`).
9630
+ * Run an instance's registered cleanup disposers (LIFO) and unregister its api. Each
9631
+ * disposer runs in isolation so a throwing one never strands the others during teardown.
9307
9632
  *
9308
- * @param element - The element the instance is bound to.
9309
- * @param data - The current page data payload.
9310
- * @param route - The matched-route slice for the current URL.
9311
- * @returns The hook context.
9633
+ * @param state - The plugin state (for the api registry).
9634
+ * @param instance - The instance being disposed.
9312
9635
  * @example
9313
- * const ctx = makeContext(element, data, route);
9636
+ * disposeInstance(state, instance);
9314
9637
  */
9315
- function makeContext(element, data, route = EMPTY_ROUTE) {
9316
- return {
9317
- el: element,
9318
- data,
9319
- params: route.params,
9320
- meta: route.meta,
9321
- locale: route.locale,
9322
- url: route.url
9323
- };
9638
+ function disposeInstance(state, instance) {
9639
+ for (let index = instance.cleanups.length - 1; index >= 0; index -= 1) try {
9640
+ instance.cleanups[index]?.();
9641
+ } catch {}
9642
+ instance.cleanups.length = 0;
9643
+ instance.renderScheduled = false;
9644
+ if (instance.api !== void 0 && state.componentApis.get(instance.def.name) === instance.api) state.componentApis.delete(instance.def.name);
9324
9645
  }
9325
9646
  /**
9326
- * Mounts a single `data-component` element: classifies persistent vs
9327
- * page-specific, builds the instance, fires `onCreate` then `onMount`, records
9328
- * it in state, and emits `spa:component-mount`. No-ops if the element is already
9647
+ * Mounts a single `data-component` element: classifies persistent vs page-specific,
9648
+ * builds the instance + its bound context, initializes per-instance `state`, registers
9649
+ * its `api`, attaches declarative `events`, fires `onCreate` then `onMount` (capturing
9650
+ * an async `onMount` + render-chunk load as `mountPromise`), schedules the initial
9651
+ * render, records it, and emits `spa:component-mount`. No-ops if the element is already
9329
9652
  * mounted, has no component name, or names an unregistered component.
9330
9653
  *
9331
- * @param state - The plugin state (registeredComponents + instances).
9654
+ * @param state - The plugin state (registeredComponents + instances + componentApis).
9332
9655
  * @param emit - The event emitter for spa:component-mount.
9333
9656
  * @param swapArea - The swap-region element, or null when none was found.
9334
9657
  * @param data - The current page data payload.
@@ -9343,10 +9666,40 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
9343
9666
  if (!name) return;
9344
9667
  const definition = state.registeredComponents.get(name);
9345
9668
  if (!definition) return;
9346
- const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
9347
- const ctx = makeContext(element, data, route);
9348
- runHook(instance, "onCreate", ctx);
9349
- runHook(instance, "onMount", ctx);
9669
+ const instance = {
9670
+ def: definition,
9671
+ el: element,
9672
+ persistent: swapArea ? !swapArea.contains(element) : true,
9673
+ ctx: void 0,
9674
+ state: void 0,
9675
+ api: void 0,
9676
+ route,
9677
+ data,
9678
+ cleanups: [],
9679
+ flush: noop,
9680
+ renderScheduled: false,
9681
+ renderDepth: 0,
9682
+ mountPromise: void 0
9683
+ };
9684
+ instance.ctx = buildContext(state, instance);
9685
+ instance.flush = () => {
9686
+ instance.renderScheduled = false;
9687
+ runRender(instance);
9688
+ };
9689
+ const spec = definition.spec;
9690
+ if (spec?.state) instance.state = spec.state(instance.ctx);
9691
+ if (spec?.api) {
9692
+ instance.api = spec.api(instance.ctx);
9693
+ state.componentApis.set(definition.name, instance.api);
9694
+ }
9695
+ if (spec?.events) attachEvents(instance, spec.events);
9696
+ runHook(instance, "onCreate");
9697
+ const onMountResult = definition.hooks.onMount?.(instance.ctx);
9698
+ if (spec?.render) scheduleRender(instance);
9699
+ const pending = [];
9700
+ if (spec?.render) pending.push(loadRenderChunk());
9701
+ if (onMountResult && typeof onMountResult.then === "function") pending.push(onMountResult);
9702
+ instance.mountPromise = pending.length > 0 ? Promise.all(pending).then(() => {}) : void 0;
9350
9703
  state.instances.set(element, instance);
9351
9704
  emit("spa:component-mount", {
9352
9705
  name: definition.name,
@@ -9354,12 +9707,12 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
9354
9707
  });
9355
9708
  }
9356
9709
  /**
9357
- * Scans the swap region, mounts components for matching `data-component`
9358
- * elements, classifies persistent (outside swap area) vs page-specific (inside),
9359
- * fires `onCreate` then `onMount`, and emits `spa:component-mount` per instance.
9710
+ * Scans the swap region, mounts components for matching `data-component` elements,
9711
+ * classifies persistent (outside swap area) vs page-specific (inside), runs
9712
+ * `onCreate`/`onMount` + initial render, and emits `spa:component-mount` per instance.
9360
9713
  * Already-mounted elements are skipped.
9361
9714
  *
9362
- * @param state - The plugin state (registeredComponents + instances).
9715
+ * @param state - The plugin state (registeredComponents + instances + componentApis).
9363
9716
  * @param emit - The event emitter for spa:component-mount.
9364
9717
  * @param swapSelector - CSS selector bounding page-specific components.
9365
9718
  * @param route - The matched-route slice for the current URL (params/meta/locale/url).
@@ -9373,9 +9726,10 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
9373
9726
  for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
9374
9727
  }
9375
9728
  /**
9376
- * Unmounts page-specific instances inside the swap region (runs `onUnMount`
9377
- * then `onDestroy`), removes them from state, and emits `spa:component-unmount`.
9378
- * Persistent instances (outside the swap area) are left in place.
9729
+ * Unmounts page-specific instances inside the swap region (runs `onUnMount` then
9730
+ * `onDestroy`, then their cleanup disposers + api unregister), removes them from state,
9731
+ * and emits `spa:component-unmount`. Persistent instances (outside the swap area) are
9732
+ * left in place.
9379
9733
  *
9380
9734
  * @param state - The plugin state holding live instances.
9381
9735
  * @param emit - The event emitter for spa:component-unmount.
@@ -9383,12 +9737,13 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
9383
9737
  * unmountPageSpecific(state, emit);
9384
9738
  */
9385
9739
  function unmountPageSpecific(state, emit) {
9386
- const data = typeof document === "undefined" ? {} : extractPageData(document);
9740
+ const data = currentPageData();
9387
9741
  for (const [element, instance] of state.instances) {
9388
9742
  if (instance.persistent) continue;
9389
- const ctx = makeContext(element, data);
9390
- runHook(instance, "onUnMount", ctx);
9391
- runHook(instance, "onDestroy", ctx);
9743
+ instance.data = data;
9744
+ runHook(instance, "onUnMount");
9745
+ runHook(instance, "onDestroy");
9746
+ disposeInstance(state, instance);
9392
9747
  state.instances.delete(element);
9393
9748
  emit("spa:component-unmount", {
9394
9749
  name: instance.def.name,
@@ -9397,9 +9752,10 @@ function unmountPageSpecific(state, emit) {
9397
9752
  }
9398
9753
  }
9399
9754
  /**
9400
- * Disposes ALL live instances (persistent and page-specific) on teardown:
9401
- * runs `onUnMount` then `onDestroy`, emits `spa:component-unmount`, and clears
9402
- * the instance map. Used by the kernel's `dispose` on plugin stop.
9755
+ * Disposes ALL live instances (persistent and page-specific) on teardown: runs
9756
+ * `onUnMount` then `onDestroy`, then their cleanup disposers + api unregister, emits
9757
+ * `spa:component-unmount`, and clears the instance + api maps. Used by the kernel's
9758
+ * `dispose` on plugin stop.
9403
9759
  *
9404
9760
  * @param state - The plugin state holding live instances.
9405
9761
  * @param emit - The event emitter for spa:component-unmount.
@@ -9407,17 +9763,19 @@ function unmountPageSpecific(state, emit) {
9407
9763
  * unmountAll(state, emit);
9408
9764
  */
9409
9765
  function unmountAll(state, emit) {
9410
- const data = typeof document === "undefined" ? {} : extractPageData(document);
9766
+ const data = currentPageData();
9411
9767
  for (const [element, instance] of state.instances) {
9412
- const ctx = makeContext(element, data);
9413
- runHook(instance, "onUnMount", ctx);
9414
- runHook(instance, "onDestroy", ctx);
9768
+ instance.data = data;
9769
+ runHook(instance, "onUnMount");
9770
+ runHook(instance, "onDestroy");
9771
+ disposeInstance(state, instance);
9415
9772
  emit("spa:component-unmount", {
9416
9773
  name: instance.def.name,
9417
9774
  el: element
9418
9775
  });
9419
9776
  }
9420
9777
  state.instances.clear();
9778
+ state.componentApis.clear();
9421
9779
  }
9422
9780
  /**
9423
9781
  * Fires `onNavStart` on every currently-mounted instance (persistent instances
@@ -9428,12 +9786,16 @@ function unmountAll(state, emit) {
9428
9786
  * notifyNavStart(state);
9429
9787
  */
9430
9788
  function notifyNavStart(state) {
9431
- const data = typeof document === "undefined" ? {} : extractPageData(document);
9432
- for (const [element, instance] of state.instances) runHook(instance, "onNavStart", makeContext(element, data));
9789
+ const data = currentPageData();
9790
+ for (const instance of state.instances.values()) {
9791
+ instance.data = data;
9792
+ runHook(instance, "onNavStart");
9793
+ }
9433
9794
  }
9434
9795
  /**
9435
9796
  * Fires `onNavEnd` on persistent instances that survived the swap (page-specific
9436
- * instances were already destroyed and re-created by the swap).
9797
+ * instances were already destroyed and re-created by the swap), updating their route
9798
+ * slice to the destination first.
9437
9799
  *
9438
9800
  * @param state - The plugin state holding live instances.
9439
9801
  * @param route - The matched-route slice for the destination URL (params/meta/locale/url).
@@ -9441,8 +9803,13 @@ function notifyNavStart(state) {
9441
9803
  * notifyNavEnd(state, route);
9442
9804
  */
9443
9805
  function notifyNavEnd(state, route = EMPTY_ROUTE) {
9444
- const data = typeof document === "undefined" ? {} : extractPageData(document);
9445
- for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data, route));
9806
+ const data = currentPageData();
9807
+ for (const instance of state.instances.values()) {
9808
+ if (!instance.persistent) continue;
9809
+ instance.data = data;
9810
+ instance.route = route;
9811
+ runHook(instance, "onNavEnd");
9812
+ }
9446
9813
  }
9447
9814
  //#endregion
9448
9815
  //#region src/plugins/spa/head.ts
@@ -9983,6 +10350,7 @@ function createState(_ctx) {
9983
10350
  return {
9984
10351
  registeredComponents: /* @__PURE__ */ new Map(),
9985
10352
  instances: /* @__PURE__ */ new Map(),
10353
+ componentApis: /* @__PURE__ */ new Map(),
9986
10354
  currentUrl: "",
9987
10355
  destroyRouter: null,
9988
10356
  started: false,
@@ -10221,7 +10589,7 @@ function createSpaKernel(state, config, emit, deps) {
10221
10589
  const commitDataRender = async (pathname, resolvedRender, signal) => {
10222
10590
  if (signal?.aborted) return;
10223
10591
  const { route, vnode, routeContext, region } = resolvedRender;
10224
- const { renderVNode } = await Promise.resolve().then(() => require("./render-DLZEOe4M.cjs"));
10592
+ const { renderVNode } = await Promise.resolve().then(() => require("./render-KdufA3_b.cjs"));
10225
10593
  if (signal?.aborted) return;
10226
10594
  syncDataHead(deps.head, route, routeContext);
10227
10595
  unmountPageSpecific(state, emit);
@@ -10290,7 +10658,7 @@ function createSpaKernel(state, config, emit, deps) {
10290
10658
  return;
10291
10659
  }
10292
10660
  const { vnode, region } = resolvedRender;
10293
- const { renderVNode } = await Promise.resolve().then(() => require("./render-DLZEOe4M.cjs"));
10661
+ const { renderVNode } = await Promise.resolve().then(() => require("./render-KdufA3_b.cjs"));
10294
10662
  renderVNode(vnode, region);
10295
10663
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10296
10664
  };