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