@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/browser.mjs CHANGED
@@ -2218,6 +2218,18 @@ function createApi(ctx) {
2218
2218
  */
2219
2219
  current() {
2220
2220
  return ctx.state.currentUrl;
2221
+ },
2222
+ /**
2223
+ * Resolve a registered island's api by name (the cross-island seam). Returns
2224
+ * `undefined` when no provider with that name is currently registered.
2225
+ *
2226
+ * @param name - The provider island's component name.
2227
+ * @returns The provider's api, or `undefined`.
2228
+ * @example
2229
+ * app.spa.component("lightbox");
2230
+ */
2231
+ component(name) {
2232
+ return ctx.state.componentApis.get(name);
2221
2233
  }
2222
2234
  };
2223
2235
  }
@@ -2510,6 +2522,15 @@ const COMPONENT_HOOK_NAMES = [
2510
2522
  const ERROR_PREFIX$2 = "[web]";
2511
2523
  /** The set of legal hook names, frozen for O(1) membership checks. */
2512
2524
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
2525
+ /** The spec-only keys that select the plugin-mirror form of {@link createComponent}. */
2526
+ const SPEC_KEYS = new Set([
2527
+ "state",
2528
+ "render",
2529
+ "events",
2530
+ "api"
2531
+ ]);
2532
+ /** Synchronous re-entrancy cap for the render scheduler (a render that calls `ctx.flush`). */
2533
+ const MAX_RENDER_DEPTH = 25;
2513
2534
  /**
2514
2535
  * No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
2515
2536
  *
@@ -2528,40 +2549,346 @@ const EMPTY_ROUTE = {
2528
2549
  url: noUrl
2529
2550
  };
2530
2551
  /**
2552
+ * No-op placeholder for an instance's `flush` slot until the real one is bound at mount.
2553
+ *
2554
+ * @example
2555
+ * const instance = { flush: noop };
2556
+ */
2557
+ function noop() {}
2558
+ /** Cached promise for the lazy `./render` chunk (loaded at most once per module). */
2559
+ let renderChunk;
2560
+ /** The resolved VNode committer once the chunk loads (undefined until then). */
2561
+ let commitVNodeFunction;
2562
+ /**
2563
+ * Load the lazy `./render` chunk (once) and cache its `commitVNode` for synchronous
2564
+ * use by later renders. Awaited by a component's `mountPromise` so the test harness's
2565
+ * `settle()` can deterministically flush a VNode render.
2566
+ *
2567
+ * @returns A promise that resolves once `commitVNode` is available.
2568
+ * @example
2569
+ * await loadRenderChunk();
2570
+ */
2571
+ async function loadRenderChunk() {
2572
+ renderChunk ??= import("./render-UO4nimWr.mjs");
2573
+ commitVNodeFunction = (await renderChunk).commitVNode;
2574
+ }
2575
+ /**
2576
+ * Commit a {@link RenderResult} into a host: `string` → `innerHTML`, `Node` →
2577
+ * `replaceChildren`, `void`/`undefined` → no-op (the render mutated the DOM itself), and
2578
+ * a Preact `VNode` → committed through the lazy gate (loading it on demand if needed).
2579
+ *
2580
+ * @param host - The island host element to render into.
2581
+ * @param result - The value returned by the component's `render`.
2582
+ * @example
2583
+ * commitResult(host, h(View, { items }));
2584
+ */
2585
+ function commitResult(host, result) {
2586
+ if (result === void 0) return;
2587
+ if (typeof result === "string") {
2588
+ host.innerHTML = result;
2589
+ return;
2590
+ }
2591
+ if (result instanceof Node) {
2592
+ host.replaceChildren(result);
2593
+ return;
2594
+ }
2595
+ const vnode = result;
2596
+ if (commitVNodeFunction) {
2597
+ commitVNodeFunction(vnode, host);
2598
+ return;
2599
+ }
2600
+ loadRenderChunk().then(() => commitVNodeFunction?.(vnode, host)).catch(() => {});
2601
+ }
2602
+ /**
2603
+ * Run a component's `render(state, ctx)` and commit the result now. Guards against
2604
+ * synchronous re-entrancy (a render that calls `ctx.flush`) with a depth cap.
2605
+ *
2606
+ * @param instance - The instance to render.
2607
+ * @throws {Error} When the synchronous render depth exceeds {@link MAX_RENDER_DEPTH}.
2608
+ * @example
2609
+ * runRender(instance);
2610
+ */
2611
+ function runRender(instance) {
2612
+ const render = instance.def.spec?.render;
2613
+ if (!render) return;
2614
+ 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)`);
2615
+ instance.renderDepth += 1;
2616
+ try {
2617
+ commitResult(instance.el, render(instance.state ?? {}, instance.ctx));
2618
+ } finally {
2619
+ instance.renderDepth -= 1;
2620
+ }
2621
+ }
2622
+ /**
2623
+ * Schedule a microtask-batched render for an instance (no-op when it has no `render`).
2624
+ * Multiple `ctx.set` calls in the same tick coalesce into a single render.
2625
+ *
2626
+ * @param instance - The instance to schedule a render for.
2627
+ * @example
2628
+ * scheduleRender(instance);
2629
+ */
2630
+ function scheduleRender(instance) {
2631
+ if (!instance.def.spec?.render || instance.renderScheduled) return;
2632
+ instance.renderScheduled = true;
2633
+ queueMicrotask(() => {
2634
+ if (!instance.renderScheduled) return;
2635
+ instance.renderScheduled = false;
2636
+ runRender(instance);
2637
+ });
2638
+ }
2639
+ /**
2640
+ * Build the single per-instance {@link ComponentContext} reused by every hook, event
2641
+ * handler, and render. Route fields (`params`/`meta`/`locale`/`url`) and `data` read
2642
+ * through the instance so a navigation update is reflected without rebuilding the ctx;
2643
+ * `state`/`set`/`flush`/`cleanup`/`component` are bound to the instance + plugin state.
2644
+ *
2645
+ * @param state - The plugin state (for the cross-island `component` resolver).
2646
+ * @param instance - The instance the context is bound to.
2647
+ * @returns The instance-bound context.
2648
+ * @example
2649
+ * instance.ctx = buildContext(state, instance);
2650
+ */
2651
+ function buildContext(state, instance) {
2652
+ return {
2653
+ el: instance.el,
2654
+ /**
2655
+ * The current page data payload (live; updated across navigations).
2656
+ *
2657
+ * @returns The page data.
2658
+ * @example
2659
+ * ctx.data;
2660
+ */
2661
+ get data() {
2662
+ return instance.data;
2663
+ },
2664
+ /**
2665
+ * The matched route's path params (live; updated across navigations).
2666
+ *
2667
+ * @returns The route params.
2668
+ * @example
2669
+ * ctx.params.id;
2670
+ */
2671
+ get params() {
2672
+ return instance.route.params;
2673
+ },
2674
+ /**
2675
+ * The matched route's `.meta()` bag (live; updated across navigations).
2676
+ *
2677
+ * @returns The route meta.
2678
+ * @example
2679
+ * ctx.meta.focus;
2680
+ */
2681
+ get meta() {
2682
+ return instance.route.meta;
2683
+ },
2684
+ /**
2685
+ * The active locale for the current route (live; updated across navigations).
2686
+ *
2687
+ * @returns The locale code.
2688
+ * @example
2689
+ * ctx.locale;
2690
+ */
2691
+ get locale() {
2692
+ return instance.route.locale;
2693
+ },
2694
+ /**
2695
+ * The named-route link builder for the current route.
2696
+ *
2697
+ * @returns The link builder.
2698
+ * @example
2699
+ * ctx.url("board", { id });
2700
+ */
2701
+ get url() {
2702
+ return instance.route.url;
2703
+ },
2704
+ /**
2705
+ * The live per-instance state (`undefined` for legacy hooks-only islands).
2706
+ *
2707
+ * @returns The current state.
2708
+ * @example
2709
+ * ctx.state.count;
2710
+ */
2711
+ get state() {
2712
+ return instance.state;
2713
+ },
2714
+ /**
2715
+ * Merge a patch into the per-instance state and schedule one batched render.
2716
+ *
2717
+ * @param patch - A partial state object, or an updater `(prev) => partial`.
2718
+ * @example
2719
+ * ctx.set(prev => ({ count: prev.count + 1 }));
2720
+ */
2721
+ set(patch) {
2722
+ const previous = instance.state ?? {};
2723
+ const next = typeof patch === "function" ? patch(previous) : patch;
2724
+ instance.state = Object.assign({}, previous, next);
2725
+ scheduleRender(instance);
2726
+ },
2727
+ /**
2728
+ * Force a synchronous render now (drains any pending scheduled render).
2729
+ *
2730
+ * @example
2731
+ * ctx.flush();
2732
+ */
2733
+ flush() {
2734
+ instance.flush();
2735
+ },
2736
+ /**
2737
+ * Register a disposer run on destroy (subscriptions, timers, manual listeners).
2738
+ *
2739
+ * @param dispose - The teardown function.
2740
+ * @example
2741
+ * ctx.cleanup(off);
2742
+ */
2743
+ cleanup(dispose) {
2744
+ instance.cleanups.push(dispose);
2745
+ },
2746
+ /**
2747
+ * Resolve another island's registered api by name (`undefined` when absent).
2748
+ *
2749
+ * @param name - The provider island's component name.
2750
+ * @returns The provider's api, or `undefined`.
2751
+ * @example
2752
+ * ctx.component("lightbox");
2753
+ */
2754
+ component(name) {
2755
+ return state.componentApis.get(name);
2756
+ }
2757
+ };
2758
+ }
2759
+ /**
2760
+ * Resolve the element a delegated handler should receive for an event: the host for a
2761
+ * host-level binding (empty selector), else the nearest ancestor of `event.target`
2762
+ * matching the selector that is still inside the host.
2763
+ *
2764
+ * @param host - The island host element.
2765
+ * @param event - The dispatched DOM event.
2766
+ * @param selector - The key's selector (empty string → host-level).
2767
+ * @returns The matched element, or `undefined` when nothing matches inside the host.
2768
+ * @example
2769
+ * const target = matchTarget(host, event, "[data-action]");
2770
+ */
2771
+ function matchTarget(host, event, selector) {
2772
+ if (selector === "") return host;
2773
+ const target = event.target;
2774
+ if (!(target instanceof Element)) return void 0;
2775
+ const matched = target.closest(selector);
2776
+ return matched && host.contains(matched) ? matched : void 0;
2777
+ }
2778
+ /**
2779
+ * Attach a component's declarative `events` map: one real listener per event TYPE on
2780
+ * the host (dispatch walks `closest(selector)` for each registered selector), each
2781
+ * removed via the instance's cleanup registry on destroy.
2782
+ *
2783
+ * @param instance - The instance whose host the listeners attach to.
2784
+ * @param events - The declarative `{ "<type> <selector>": handler }` map.
2785
+ * @throws {Error} When a key has no event type.
2786
+ * @example
2787
+ * attachEvents(instance, { "click [data-action]": (ctx, e, el) => {} });
2788
+ */
2789
+ function attachEvents(instance, events) {
2790
+ const host = instance.el;
2791
+ const byType = /* @__PURE__ */ new Map();
2792
+ for (const [key, handler] of Object.entries(events)) {
2793
+ const space = key.indexOf(" ");
2794
+ const type = (space === -1 ? key : key.slice(0, space)).trim();
2795
+ const selector = space === -1 ? "" : key.slice(space + 1).trim();
2796
+ 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]")`);
2797
+ const list = byType.get(type) ?? [];
2798
+ list.push({
2799
+ selector,
2800
+ handler
2801
+ });
2802
+ byType.set(type, list);
2803
+ }
2804
+ for (const [type, handlers] of byType) {
2805
+ const listener = (event) => {
2806
+ for (const { selector, handler } of handlers) {
2807
+ const target = matchTarget(host, event, selector);
2808
+ if (target) handler(instance.ctx, event, target);
2809
+ }
2810
+ };
2811
+ host.addEventListener(type, listener);
2812
+ instance.cleanups.push(() => host.removeEventListener(type, listener));
2813
+ }
2814
+ }
2815
+ /**
2531
2816
  * Validate a single hook entry: its key must be a known hook name and its value
2532
2817
  * must be a function. Throws fail-fast on the first violation.
2533
2818
  *
2534
2819
  * @param componentName - The owning component name (for error messages).
2535
- * @param hooks - The hooks object being validated.
2820
+ * @param source - The raw authoring object being validated.
2536
2821
  * @param key - The hook key to validate.
2537
2822
  * @throws {Error} If `key` is not in `COMPONENT_HOOK_NAMES`.
2538
2823
  * @throws {TypeError} If the hook value is not a function.
2539
2824
  * @example
2540
- * validateHookEntry("counter", hooks, "onMount");
2825
+ * validateHookEntry("counter", source, "onMount");
2541
2826
  */
2542
- function validateHookEntry(componentName, hooks, key) {
2543
- 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(", ")}`);
2544
- 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`);
2827
+ function validateHookEntry(componentName, source, key) {
2828
+ 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`);
2829
+ 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`);
2830
+ }
2831
+ /**
2832
+ * Validate the spec extras (`state`/`render`/`api` must be functions; `events` must be
2833
+ * a plain object of functions). Throws fail-fast on the first violation.
2834
+ *
2835
+ * @param componentName - The owning component name (for error messages).
2836
+ * @param extras - The partitioned spec extras to validate.
2837
+ * @throws {TypeError} If a present extra has the wrong shape.
2838
+ * @example
2839
+ * validateSpecExtras("board", { state: () => ({}) });
2840
+ */
2841
+ function validateSpecExtras(componentName, extras) {
2842
+ for (const key of [
2843
+ "state",
2844
+ "render",
2845
+ "api"
2846
+ ]) 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`);
2847
+ if (extras.events !== void 0) {
2848
+ const events = extras.events;
2849
+ if (!(typeof events === "object")) throw new TypeError(`${ERROR_PREFIX$2} component "events" on "${componentName}" must be an object of handlers`);
2850
+ 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`);
2851
+ }
2545
2852
  }
2546
2853
  /**
2547
- * Create a validated component definition. Validates hook names at registration
2548
- * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
2549
- * each provided hook is a function.
2854
+ * Create a validated component definition. Accepts either the legacy hooks-only form
2855
+ * (`createComponent("counter", { onMount() {} })`) or the plugin-mirror spec form
2856
+ * (`createComponent("board", { state, render, events, api, ...hooks })`). Spec-only
2857
+ * keys (`state`/`render`/`events`/`api`) are partitioned out before hook-name
2858
+ * validation, so a real typo (e.g. `onMout`) still throws immediately while the spec
2859
+ * keys are accepted.
2550
2860
  *
2551
2861
  * @param name - Unique component name.
2552
- * @param hooks - Lifecycle hook implementations.
2862
+ * @param spec - Lifecycle hooks, or the `{ state, render, events, api, ...hooks }` spec.
2553
2863
  * @returns A `ComponentDef` ready to `register`.
2554
- * @throws {Error} If `name` is empty, any hook key is not in
2555
- * `COMPONENT_HOOK_NAMES`, or any provided hook value is not a function.
2864
+ * @throws {Error} If `name` is empty, a hook key is unknown, or an extra/hook value has the wrong shape.
2865
+ * @example
2866
+ * const counter = createComponent("counter", { onMount({ el }) { el.textContent = "0"; } });
2556
2867
  * @example
2557
- * const counter = createComponent("counter", {
2558
- * onMount({ el }) { el.textContent = "0"; }
2868
+ * const list = createComponent<{ items: string[] }>("list", {
2869
+ * state: () => ({ items: [] }),
2870
+ * render: (s) => h(List, { items: s.items })
2559
2871
  * });
2560
2872
  */
2561
- function createComponent(name, hooks) {
2873
+ function createComponent(name, spec) {
2562
2874
  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)`);
2563
- for (const key of Object.keys(hooks)) validateHookEntry(name, hooks, key);
2564
- return {
2875
+ const source = spec;
2876
+ const hooks = {};
2877
+ const extras = {};
2878
+ for (const key of Object.keys(source)) {
2879
+ if (SPEC_KEYS.has(key)) {
2880
+ extras[key] = source[key];
2881
+ continue;
2882
+ }
2883
+ validateHookEntry(name, source, key);
2884
+ hooks[key] = source[key];
2885
+ }
2886
+ validateSpecExtras(name, extras);
2887
+ return Object.keys(extras).length > 0 ? {
2888
+ name,
2889
+ hooks,
2890
+ spec: extras
2891
+ } : {
2565
2892
  name,
2566
2893
  hooks
2567
2894
  };
@@ -2585,64 +2912,53 @@ function extractPageData(doc) {
2585
2912
  }
2586
2913
  }
2587
2914
  /**
2588
- * Builds a live component instance bound to an element.
2915
+ * Read the current page data, or `{}` in a headless (non-browser) context.
2589
2916
  *
2590
- * @param definition - The component definition.
2591
- * @param element - The element the instance binds to.
2592
- * @param persistent - Whether the instance survives navigation.
2593
- * @returns The constructed (not-yet-mounted) instance.
2917
+ * @returns The current page data payload.
2594
2918
  * @example
2595
- * const inst = createInstance(definition, element, false);
2919
+ * const data = currentPageData();
2596
2920
  */
2597
- function createInstance(definition, element, persistent) {
2598
- return {
2599
- def: definition,
2600
- el: element,
2601
- persistent
2602
- };
2921
+ function currentPageData() {
2922
+ return typeof document === "undefined" ? {} : extractPageData(document);
2603
2923
  }
2604
2924
  /**
2605
- * Invokes a single lifecycle hook on an instance with its component context.
2606
- * Missing hooks are skipped silently.
2925
+ * Invokes a single lifecycle hook on an instance with its bound context. Missing
2926
+ * hooks are skipped silently.
2607
2927
  *
2608
2928
  * @param instance - The instance whose hook to run.
2609
2929
  * @param hook - The hook name to invoke.
2610
- * @param ctx - The component context passed to the hook.
2611
2930
  * @example
2612
- * runHook(instance, "onMount", ctx);
2931
+ * runHook(instance, "onDestroy");
2613
2932
  */
2614
- function runHook(instance, hook, ctx) {
2615
- instance.def.hooks[hook]?.(ctx);
2933
+ function runHook(instance, hook) {
2934
+ instance.def.hooks[hook]?.(instance.ctx);
2616
2935
  }
2617
2936
  /**
2618
- * Builds the component context handed to a hook: the bound element + page data, merged
2619
- * with the matched route's slice (params/meta/locale/url). Defaults to {@link EMPTY_ROUTE}
2620
- * when no route is supplied (headless, tests, public `scan()`).
2937
+ * Run an instance's registered cleanup disposers (LIFO) and unregister its api. Each
2938
+ * disposer runs in isolation so a throwing one never strands the others during teardown.
2621
2939
  *
2622
- * @param element - The element the instance is bound to.
2623
- * @param data - The current page data payload.
2624
- * @param route - The matched-route slice for the current URL.
2625
- * @returns The hook context.
2940
+ * @param state - The plugin state (for the api registry).
2941
+ * @param instance - The instance being disposed.
2626
2942
  * @example
2627
- * const ctx = makeContext(element, data, route);
2943
+ * disposeInstance(state, instance);
2628
2944
  */
2629
- function makeContext(element, data, route = EMPTY_ROUTE) {
2630
- return {
2631
- el: element,
2632
- data,
2633
- params: route.params,
2634
- meta: route.meta,
2635
- locale: route.locale,
2636
- url: route.url
2637
- };
2945
+ function disposeInstance(state, instance) {
2946
+ for (let index = instance.cleanups.length - 1; index >= 0; index -= 1) try {
2947
+ instance.cleanups[index]?.();
2948
+ } catch {}
2949
+ instance.cleanups.length = 0;
2950
+ instance.renderScheduled = false;
2951
+ if (instance.api !== void 0 && state.componentApis.get(instance.def.name) === instance.api) state.componentApis.delete(instance.def.name);
2638
2952
  }
2639
2953
  /**
2640
- * Mounts a single `data-component` element: classifies persistent vs
2641
- * page-specific, builds the instance, fires `onCreate` then `onMount`, records
2642
- * it in state, and emits `spa:component-mount`. No-ops if the element is already
2954
+ * Mounts a single `data-component` element: classifies persistent vs page-specific,
2955
+ * builds the instance + its bound context, initializes per-instance `state`, registers
2956
+ * its `api`, attaches declarative `events`, fires `onCreate` then `onMount` (capturing
2957
+ * an async `onMount` + render-chunk load as `mountPromise`), schedules the initial
2958
+ * render, records it, and emits `spa:component-mount`. No-ops if the element is already
2643
2959
  * mounted, has no component name, or names an unregistered component.
2644
2960
  *
2645
- * @param state - The plugin state (registeredComponents + instances).
2961
+ * @param state - The plugin state (registeredComponents + instances + componentApis).
2646
2962
  * @param emit - The event emitter for spa:component-mount.
2647
2963
  * @param swapArea - The swap-region element, or null when none was found.
2648
2964
  * @param data - The current page data payload.
@@ -2657,10 +2973,40 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
2657
2973
  if (!name) return;
2658
2974
  const definition = state.registeredComponents.get(name);
2659
2975
  if (!definition) return;
2660
- const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
2661
- const ctx = makeContext(element, data, route);
2662
- runHook(instance, "onCreate", ctx);
2663
- runHook(instance, "onMount", ctx);
2976
+ const instance = {
2977
+ def: definition,
2978
+ el: element,
2979
+ persistent: swapArea ? !swapArea.contains(element) : true,
2980
+ ctx: void 0,
2981
+ state: void 0,
2982
+ api: void 0,
2983
+ route,
2984
+ data,
2985
+ cleanups: [],
2986
+ flush: noop,
2987
+ renderScheduled: false,
2988
+ renderDepth: 0,
2989
+ mountPromise: void 0
2990
+ };
2991
+ instance.ctx = buildContext(state, instance);
2992
+ instance.flush = () => {
2993
+ instance.renderScheduled = false;
2994
+ runRender(instance);
2995
+ };
2996
+ const spec = definition.spec;
2997
+ if (spec?.state) instance.state = spec.state(instance.ctx);
2998
+ if (spec?.api) {
2999
+ instance.api = spec.api(instance.ctx);
3000
+ state.componentApis.set(definition.name, instance.api);
3001
+ }
3002
+ if (spec?.events) attachEvents(instance, spec.events);
3003
+ runHook(instance, "onCreate");
3004
+ const onMountResult = definition.hooks.onMount?.(instance.ctx);
3005
+ if (spec?.render) scheduleRender(instance);
3006
+ const pending = [];
3007
+ if (spec?.render) pending.push(loadRenderChunk());
3008
+ if (onMountResult && typeof onMountResult.then === "function") pending.push(onMountResult);
3009
+ instance.mountPromise = pending.length > 0 ? Promise.all(pending).then(() => {}) : void 0;
2664
3010
  state.instances.set(element, instance);
2665
3011
  emit("spa:component-mount", {
2666
3012
  name: definition.name,
@@ -2668,12 +3014,12 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
2668
3014
  });
2669
3015
  }
2670
3016
  /**
2671
- * Scans the swap region, mounts components for matching `data-component`
2672
- * elements, classifies persistent (outside swap area) vs page-specific (inside),
2673
- * fires `onCreate` then `onMount`, and emits `spa:component-mount` per instance.
3017
+ * Scans the swap region, mounts components for matching `data-component` elements,
3018
+ * classifies persistent (outside swap area) vs page-specific (inside), runs
3019
+ * `onCreate`/`onMount` + initial render, and emits `spa:component-mount` per instance.
2674
3020
  * Already-mounted elements are skipped.
2675
3021
  *
2676
- * @param state - The plugin state (registeredComponents + instances).
3022
+ * @param state - The plugin state (registeredComponents + instances + componentApis).
2677
3023
  * @param emit - The event emitter for spa:component-mount.
2678
3024
  * @param swapSelector - CSS selector bounding page-specific components.
2679
3025
  * @param route - The matched-route slice for the current URL (params/meta/locale/url).
@@ -2687,9 +3033,10 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
2687
3033
  for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
2688
3034
  }
2689
3035
  /**
2690
- * Unmounts page-specific instances inside the swap region (runs `onUnMount`
2691
- * then `onDestroy`), removes them from state, and emits `spa:component-unmount`.
2692
- * Persistent instances (outside the swap area) are left in place.
3036
+ * Unmounts page-specific instances inside the swap region (runs `onUnMount` then
3037
+ * `onDestroy`, then their cleanup disposers + api unregister), removes them from state,
3038
+ * and emits `spa:component-unmount`. Persistent instances (outside the swap area) are
3039
+ * left in place.
2693
3040
  *
2694
3041
  * @param state - The plugin state holding live instances.
2695
3042
  * @param emit - The event emitter for spa:component-unmount.
@@ -2697,12 +3044,13 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
2697
3044
  * unmountPageSpecific(state, emit);
2698
3045
  */
2699
3046
  function unmountPageSpecific(state, emit) {
2700
- const data = typeof document === "undefined" ? {} : extractPageData(document);
3047
+ const data = currentPageData();
2701
3048
  for (const [element, instance] of state.instances) {
2702
3049
  if (instance.persistent) continue;
2703
- const ctx = makeContext(element, data);
2704
- runHook(instance, "onUnMount", ctx);
2705
- runHook(instance, "onDestroy", ctx);
3050
+ instance.data = data;
3051
+ runHook(instance, "onUnMount");
3052
+ runHook(instance, "onDestroy");
3053
+ disposeInstance(state, instance);
2706
3054
  state.instances.delete(element);
2707
3055
  emit("spa:component-unmount", {
2708
3056
  name: instance.def.name,
@@ -2711,9 +3059,10 @@ function unmountPageSpecific(state, emit) {
2711
3059
  }
2712
3060
  }
2713
3061
  /**
2714
- * Disposes ALL live instances (persistent and page-specific) on teardown:
2715
- * runs `onUnMount` then `onDestroy`, emits `spa:component-unmount`, and clears
2716
- * the instance map. Used by the kernel's `dispose` on plugin stop.
3062
+ * Disposes ALL live instances (persistent and page-specific) on teardown: runs
3063
+ * `onUnMount` then `onDestroy`, then their cleanup disposers + api unregister, emits
3064
+ * `spa:component-unmount`, and clears the instance + api maps. Used by the kernel's
3065
+ * `dispose` on plugin stop.
2717
3066
  *
2718
3067
  * @param state - The plugin state holding live instances.
2719
3068
  * @param emit - The event emitter for spa:component-unmount.
@@ -2721,17 +3070,19 @@ function unmountPageSpecific(state, emit) {
2721
3070
  * unmountAll(state, emit);
2722
3071
  */
2723
3072
  function unmountAll(state, emit) {
2724
- const data = typeof document === "undefined" ? {} : extractPageData(document);
3073
+ const data = currentPageData();
2725
3074
  for (const [element, instance] of state.instances) {
2726
- const ctx = makeContext(element, data);
2727
- runHook(instance, "onUnMount", ctx);
2728
- runHook(instance, "onDestroy", ctx);
3075
+ instance.data = data;
3076
+ runHook(instance, "onUnMount");
3077
+ runHook(instance, "onDestroy");
3078
+ disposeInstance(state, instance);
2729
3079
  emit("spa:component-unmount", {
2730
3080
  name: instance.def.name,
2731
3081
  el: element
2732
3082
  });
2733
3083
  }
2734
3084
  state.instances.clear();
3085
+ state.componentApis.clear();
2735
3086
  }
2736
3087
  /**
2737
3088
  * Fires `onNavStart` on every currently-mounted instance (persistent instances
@@ -2742,12 +3093,16 @@ function unmountAll(state, emit) {
2742
3093
  * notifyNavStart(state);
2743
3094
  */
2744
3095
  function notifyNavStart(state) {
2745
- const data = typeof document === "undefined" ? {} : extractPageData(document);
2746
- for (const [element, instance] of state.instances) runHook(instance, "onNavStart", makeContext(element, data));
3096
+ const data = currentPageData();
3097
+ for (const instance of state.instances.values()) {
3098
+ instance.data = data;
3099
+ runHook(instance, "onNavStart");
3100
+ }
2747
3101
  }
2748
3102
  /**
2749
3103
  * Fires `onNavEnd` on persistent instances that survived the swap (page-specific
2750
- * instances were already destroyed and re-created by the swap).
3104
+ * instances were already destroyed and re-created by the swap), updating their route
3105
+ * slice to the destination first.
2751
3106
  *
2752
3107
  * @param state - The plugin state holding live instances.
2753
3108
  * @param route - The matched-route slice for the destination URL (params/meta/locale/url).
@@ -2755,8 +3110,13 @@ function notifyNavStart(state) {
2755
3110
  * notifyNavEnd(state, route);
2756
3111
  */
2757
3112
  function notifyNavEnd(state, route = EMPTY_ROUTE) {
2758
- const data = typeof document === "undefined" ? {} : extractPageData(document);
2759
- for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data, route));
3113
+ const data = currentPageData();
3114
+ for (const instance of state.instances.values()) {
3115
+ if (!instance.persistent) continue;
3116
+ instance.data = data;
3117
+ instance.route = route;
3118
+ runHook(instance, "onNavEnd");
3119
+ }
2760
3120
  }
2761
3121
  //#endregion
2762
3122
  //#region src/plugins/spa/head.ts
@@ -3297,6 +3657,7 @@ function createState(_ctx) {
3297
3657
  return {
3298
3658
  registeredComponents: /* @__PURE__ */ new Map(),
3299
3659
  instances: /* @__PURE__ */ new Map(),
3660
+ componentApis: /* @__PURE__ */ new Map(),
3300
3661
  currentUrl: "",
3301
3662
  destroyRouter: null,
3302
3663
  started: false,
@@ -3535,7 +3896,7 @@ function createSpaKernel(state, config, emit, deps) {
3535
3896
  const commitDataRender = async (pathname, resolvedRender, signal) => {
3536
3897
  if (signal?.aborted) return;
3537
3898
  const { route, vnode, routeContext, region } = resolvedRender;
3538
- const { renderVNode } = await import("./render-BNe0s7fr.mjs");
3899
+ const { renderVNode } = await import("./render-UO4nimWr.mjs");
3539
3900
  if (signal?.aborted) return;
3540
3901
  syncDataHead(deps.head, route, routeContext);
3541
3902
  unmountPageSpecific(state, emit);
@@ -3604,7 +3965,7 @@ function createSpaKernel(state, config, emit, deps) {
3604
3965
  return;
3605
3966
  }
3606
3967
  const { vnode, region } = resolvedRender;
3607
- const { renderVNode } = await import("./render-BNe0s7fr.mjs");
3968
+ const { renderVNode } = await import("./render-UO4nimWr.mjs");
3608
3969
  renderVNode(vnode, region);
3609
3970
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3610
3971
  };