@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.
@@ -0,0 +1,847 @@
1
+ import { render } from "preact";
2
+ import { act } from "preact/test-utils";
3
+ //#region src/plugins/spa/types.ts
4
+ /** Allowed hook names — single source of truth for fail-fast validation. */
5
+ const COMPONENT_HOOK_NAMES = [
6
+ "onCreate",
7
+ "onMount",
8
+ "onNavStart",
9
+ "onNavEnd",
10
+ "onUnMount",
11
+ "onDestroy"
12
+ ];
13
+ //#endregion
14
+ //#region src/plugins/spa/components.ts
15
+ /** Error prefix for spa fail-fast failures (spec/11 Part-3). */
16
+ const ERROR_PREFIX = "[web]";
17
+ new Set(COMPONENT_HOOK_NAMES);
18
+ /** Synchronous re-entrancy cap for the render scheduler (a render that calls `ctx.flush`). */
19
+ const MAX_RENDER_DEPTH = 25;
20
+ /**
21
+ * No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
22
+ *
23
+ * @returns An empty string.
24
+ * @example
25
+ * const href = noUrl();
26
+ */
27
+ function noUrl() {
28
+ return "";
29
+ }
30
+ /** Empty route slice — used for mounts with no matched route (headless, tests, public `scan()`). */
31
+ const EMPTY_ROUTE = {
32
+ params: {},
33
+ meta: {},
34
+ locale: "",
35
+ url: noUrl
36
+ };
37
+ /**
38
+ * No-op placeholder for an instance's `flush` slot until the real one is bound at mount.
39
+ *
40
+ * @example
41
+ * const instance = { flush: noop };
42
+ */
43
+ function noop() {}
44
+ /** Cached promise for the lazy `./render` chunk (loaded at most once per module). */
45
+ let renderChunk;
46
+ /** The resolved VNode committer once the chunk loads (undefined until then). */
47
+ let commitVNodeFunction;
48
+ /**
49
+ * Load the lazy `./render` chunk (once) and cache its `commitVNode` for synchronous
50
+ * use by later renders. Awaited by a component's `mountPromise` so the test harness's
51
+ * `settle()` can deterministically flush a VNode render.
52
+ *
53
+ * @returns A promise that resolves once `commitVNode` is available.
54
+ * @example
55
+ * await loadRenderChunk();
56
+ */
57
+ async function loadRenderChunk() {
58
+ renderChunk ??= import("./render-UO4nimWr.mjs");
59
+ commitVNodeFunction = (await renderChunk).commitVNode;
60
+ }
61
+ /**
62
+ * Commit a {@link RenderResult} into a host: `string` → `innerHTML`, `Node` →
63
+ * `replaceChildren`, `void`/`undefined` → no-op (the render mutated the DOM itself), and
64
+ * a Preact `VNode` → committed through the lazy gate (loading it on demand if needed).
65
+ *
66
+ * @param host - The island host element to render into.
67
+ * @param result - The value returned by the component's `render`.
68
+ * @example
69
+ * commitResult(host, h(View, { items }));
70
+ */
71
+ function commitResult(host, result) {
72
+ if (result === void 0) return;
73
+ if (typeof result === "string") {
74
+ host.innerHTML = result;
75
+ return;
76
+ }
77
+ if (result instanceof Node) {
78
+ host.replaceChildren(result);
79
+ return;
80
+ }
81
+ const vnode = result;
82
+ if (commitVNodeFunction) {
83
+ commitVNodeFunction(vnode, host);
84
+ return;
85
+ }
86
+ loadRenderChunk().then(() => commitVNodeFunction?.(vnode, host)).catch(() => {});
87
+ }
88
+ /**
89
+ * Run a component's `render(state, ctx)` and commit the result now. Guards against
90
+ * synchronous re-entrancy (a render that calls `ctx.flush`) with a depth cap.
91
+ *
92
+ * @param instance - The instance to render.
93
+ * @throws {Error} When the synchronous render depth exceeds {@link MAX_RENDER_DEPTH}.
94
+ * @example
95
+ * runRender(instance);
96
+ */
97
+ function runRender(instance) {
98
+ const render = instance.def.spec?.render;
99
+ if (!render) return;
100
+ if (instance.renderDepth > MAX_RENDER_DEPTH) throw new Error(`${ERROR_PREFIX} 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)`);
101
+ instance.renderDepth += 1;
102
+ try {
103
+ commitResult(instance.el, render(instance.state ?? {}, instance.ctx));
104
+ } finally {
105
+ instance.renderDepth -= 1;
106
+ }
107
+ }
108
+ /**
109
+ * Schedule a microtask-batched render for an instance (no-op when it has no `render`).
110
+ * Multiple `ctx.set` calls in the same tick coalesce into a single render.
111
+ *
112
+ * @param instance - The instance to schedule a render for.
113
+ * @example
114
+ * scheduleRender(instance);
115
+ */
116
+ function scheduleRender(instance) {
117
+ if (!instance.def.spec?.render || instance.renderScheduled) return;
118
+ instance.renderScheduled = true;
119
+ queueMicrotask(() => {
120
+ if (!instance.renderScheduled) return;
121
+ instance.renderScheduled = false;
122
+ runRender(instance);
123
+ });
124
+ }
125
+ /**
126
+ * Build the single per-instance {@link ComponentContext} reused by every hook, event
127
+ * handler, and render. Route fields (`params`/`meta`/`locale`/`url`) and `data` read
128
+ * through the instance so a navigation update is reflected without rebuilding the ctx;
129
+ * `state`/`set`/`flush`/`cleanup`/`component` are bound to the instance + plugin state.
130
+ *
131
+ * @param state - The plugin state (for the cross-island `component` resolver).
132
+ * @param instance - The instance the context is bound to.
133
+ * @returns The instance-bound context.
134
+ * @example
135
+ * instance.ctx = buildContext(state, instance);
136
+ */
137
+ function buildContext(state, instance) {
138
+ return {
139
+ el: instance.el,
140
+ /**
141
+ * The current page data payload (live; updated across navigations).
142
+ *
143
+ * @returns The page data.
144
+ * @example
145
+ * ctx.data;
146
+ */
147
+ get data() {
148
+ return instance.data;
149
+ },
150
+ /**
151
+ * The matched route's path params (live; updated across navigations).
152
+ *
153
+ * @returns The route params.
154
+ * @example
155
+ * ctx.params.id;
156
+ */
157
+ get params() {
158
+ return instance.route.params;
159
+ },
160
+ /**
161
+ * The matched route's `.meta()` bag (live; updated across navigations).
162
+ *
163
+ * @returns The route meta.
164
+ * @example
165
+ * ctx.meta.focus;
166
+ */
167
+ get meta() {
168
+ return instance.route.meta;
169
+ },
170
+ /**
171
+ * The active locale for the current route (live; updated across navigations).
172
+ *
173
+ * @returns The locale code.
174
+ * @example
175
+ * ctx.locale;
176
+ */
177
+ get locale() {
178
+ return instance.route.locale;
179
+ },
180
+ /**
181
+ * The named-route link builder for the current route.
182
+ *
183
+ * @returns The link builder.
184
+ * @example
185
+ * ctx.url("board", { id });
186
+ */
187
+ get url() {
188
+ return instance.route.url;
189
+ },
190
+ /**
191
+ * The live per-instance state (`undefined` for legacy hooks-only islands).
192
+ *
193
+ * @returns The current state.
194
+ * @example
195
+ * ctx.state.count;
196
+ */
197
+ get state() {
198
+ return instance.state;
199
+ },
200
+ /**
201
+ * Merge a patch into the per-instance state and schedule one batched render.
202
+ *
203
+ * @param patch - A partial state object, or an updater `(prev) => partial`.
204
+ * @example
205
+ * ctx.set(prev => ({ count: prev.count + 1 }));
206
+ */
207
+ set(patch) {
208
+ const previous = instance.state ?? {};
209
+ const next = typeof patch === "function" ? patch(previous) : patch;
210
+ instance.state = Object.assign({}, previous, next);
211
+ scheduleRender(instance);
212
+ },
213
+ /**
214
+ * Force a synchronous render now (drains any pending scheduled render).
215
+ *
216
+ * @example
217
+ * ctx.flush();
218
+ */
219
+ flush() {
220
+ instance.flush();
221
+ },
222
+ /**
223
+ * Register a disposer run on destroy (subscriptions, timers, manual listeners).
224
+ *
225
+ * @param dispose - The teardown function.
226
+ * @example
227
+ * ctx.cleanup(off);
228
+ */
229
+ cleanup(dispose) {
230
+ instance.cleanups.push(dispose);
231
+ },
232
+ /**
233
+ * Resolve another island's registered api by name (`undefined` when absent).
234
+ *
235
+ * @param name - The provider island's component name.
236
+ * @returns The provider's api, or `undefined`.
237
+ * @example
238
+ * ctx.component("lightbox");
239
+ */
240
+ component(name) {
241
+ return state.componentApis.get(name);
242
+ }
243
+ };
244
+ }
245
+ /**
246
+ * Resolve the element a delegated handler should receive for an event: the host for a
247
+ * host-level binding (empty selector), else the nearest ancestor of `event.target`
248
+ * matching the selector that is still inside the host.
249
+ *
250
+ * @param host - The island host element.
251
+ * @param event - The dispatched DOM event.
252
+ * @param selector - The key's selector (empty string → host-level).
253
+ * @returns The matched element, or `undefined` when nothing matches inside the host.
254
+ * @example
255
+ * const target = matchTarget(host, event, "[data-action]");
256
+ */
257
+ function matchTarget(host, event, selector) {
258
+ if (selector === "") return host;
259
+ const target = event.target;
260
+ if (!(target instanceof Element)) return void 0;
261
+ const matched = target.closest(selector);
262
+ return matched && host.contains(matched) ? matched : void 0;
263
+ }
264
+ /**
265
+ * Attach a component's declarative `events` map: one real listener per event TYPE on
266
+ * the host (dispatch walks `closest(selector)` for each registered selector), each
267
+ * removed via the instance's cleanup registry on destroy.
268
+ *
269
+ * @param instance - The instance whose host the listeners attach to.
270
+ * @param events - The declarative `{ "<type> <selector>": handler }` map.
271
+ * @throws {Error} When a key has no event type.
272
+ * @example
273
+ * attachEvents(instance, { "click [data-action]": (ctx, e, el) => {} });
274
+ */
275
+ function attachEvents(instance, events) {
276
+ const host = instance.el;
277
+ const byType = /* @__PURE__ */ new Map();
278
+ for (const [key, handler] of Object.entries(events)) {
279
+ const space = key.indexOf(" ");
280
+ const type = (space === -1 ? key : key.slice(0, space)).trim();
281
+ const selector = space === -1 ? "" : key.slice(space + 1).trim();
282
+ if (type === "") throw new Error(`${ERROR_PREFIX} component "${instance.def.name}" event key must start with an event type: "${key}"\n → use "<type>" or "<type> <selector>" (e.g. "click [data-action]")`);
283
+ const list = byType.get(type) ?? [];
284
+ list.push({
285
+ selector,
286
+ handler
287
+ });
288
+ byType.set(type, list);
289
+ }
290
+ for (const [type, handlers] of byType) {
291
+ const listener = (event) => {
292
+ for (const { selector, handler } of handlers) {
293
+ const target = matchTarget(host, event, selector);
294
+ if (target) handler(instance.ctx, event, target);
295
+ }
296
+ };
297
+ host.addEventListener(type, listener);
298
+ instance.cleanups.push(() => host.removeEventListener(type, listener));
299
+ }
300
+ }
301
+ /**
302
+ * Extracts the page data payload from the inline `script#__DATA__` element.
303
+ * Returns an empty object when the script is absent, empty, or invalid JSON.
304
+ *
305
+ * @param doc - The document to read the data script from.
306
+ * @returns The parsed page data, or `{}` when unavailable.
307
+ * @example
308
+ * const data = extractPageData(document);
309
+ */
310
+ function extractPageData(doc) {
311
+ const text = doc.querySelector("script#__DATA__")?.textContent;
312
+ if (!text) return {};
313
+ try {
314
+ return JSON.parse(text);
315
+ } catch {
316
+ return {};
317
+ }
318
+ }
319
+ /**
320
+ * Read the current page data, or `{}` in a headless (non-browser) context.
321
+ *
322
+ * @returns The current page data payload.
323
+ * @example
324
+ * const data = currentPageData();
325
+ */
326
+ function currentPageData() {
327
+ return typeof document === "undefined" ? {} : extractPageData(document);
328
+ }
329
+ /**
330
+ * Invokes a single lifecycle hook on an instance with its bound context. Missing
331
+ * hooks are skipped silently.
332
+ *
333
+ * @param instance - The instance whose hook to run.
334
+ * @param hook - The hook name to invoke.
335
+ * @example
336
+ * runHook(instance, "onDestroy");
337
+ */
338
+ function runHook(instance, hook) {
339
+ instance.def.hooks[hook]?.(instance.ctx);
340
+ }
341
+ /**
342
+ * Run an instance's registered cleanup disposers (LIFO) and unregister its api. Each
343
+ * disposer runs in isolation so a throwing one never strands the others during teardown.
344
+ *
345
+ * @param state - The plugin state (for the api registry).
346
+ * @param instance - The instance being disposed.
347
+ * @example
348
+ * disposeInstance(state, instance);
349
+ */
350
+ function disposeInstance(state, instance) {
351
+ for (let index = instance.cleanups.length - 1; index >= 0; index -= 1) try {
352
+ instance.cleanups[index]?.();
353
+ } catch {}
354
+ instance.cleanups.length = 0;
355
+ instance.renderScheduled = false;
356
+ if (instance.api !== void 0 && state.componentApis.get(instance.def.name) === instance.api) state.componentApis.delete(instance.def.name);
357
+ }
358
+ /**
359
+ * Mounts a single `data-component` element: classifies persistent vs page-specific,
360
+ * builds the instance + its bound context, initializes per-instance `state`, registers
361
+ * its `api`, attaches declarative `events`, fires `onCreate` then `onMount` (capturing
362
+ * an async `onMount` + render-chunk load as `mountPromise`), schedules the initial
363
+ * render, records it, and emits `spa:component-mount`. No-ops if the element is already
364
+ * mounted, has no component name, or names an unregistered component.
365
+ *
366
+ * @param state - The plugin state (registeredComponents + instances + componentApis).
367
+ * @param emit - The event emitter for spa:component-mount.
368
+ * @param swapArea - The swap-region element, or null when none was found.
369
+ * @param data - The current page data payload.
370
+ * @param element - The candidate element carrying a `data-component` attribute.
371
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
372
+ * @example
373
+ * mountElement(state, emit, swapArea, data, element, route);
374
+ */
375
+ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE) {
376
+ if (state.instances.has(element)) return;
377
+ const name = element.dataset.component;
378
+ if (!name) return;
379
+ const definition = state.registeredComponents.get(name);
380
+ if (!definition) return;
381
+ const instance = {
382
+ def: definition,
383
+ el: element,
384
+ persistent: swapArea ? !swapArea.contains(element) : true,
385
+ ctx: void 0,
386
+ state: void 0,
387
+ api: void 0,
388
+ route,
389
+ data,
390
+ cleanups: [],
391
+ flush: noop,
392
+ renderScheduled: false,
393
+ renderDepth: 0,
394
+ mountPromise: void 0
395
+ };
396
+ instance.ctx = buildContext(state, instance);
397
+ instance.flush = () => {
398
+ instance.renderScheduled = false;
399
+ runRender(instance);
400
+ };
401
+ const spec = definition.spec;
402
+ if (spec?.state) instance.state = spec.state(instance.ctx);
403
+ if (spec?.api) {
404
+ instance.api = spec.api(instance.ctx);
405
+ state.componentApis.set(definition.name, instance.api);
406
+ }
407
+ if (spec?.events) attachEvents(instance, spec.events);
408
+ runHook(instance, "onCreate");
409
+ const onMountResult = definition.hooks.onMount?.(instance.ctx);
410
+ if (spec?.render) scheduleRender(instance);
411
+ const pending = [];
412
+ if (spec?.render) pending.push(loadRenderChunk());
413
+ if (onMountResult && typeof onMountResult.then === "function") pending.push(onMountResult);
414
+ instance.mountPromise = pending.length > 0 ? Promise.all(pending).then(() => {}) : void 0;
415
+ state.instances.set(element, instance);
416
+ emit("spa:component-mount", {
417
+ name: definition.name,
418
+ el: element
419
+ });
420
+ }
421
+ /**
422
+ * Scans the swap region, mounts components for matching `data-component` elements,
423
+ * classifies persistent (outside swap area) vs page-specific (inside), runs
424
+ * `onCreate`/`onMount` + initial render, and emits `spa:component-mount` per instance.
425
+ * Already-mounted elements are skipped.
426
+ *
427
+ * @param state - The plugin state (registeredComponents + instances + componentApis).
428
+ * @param emit - The event emitter for spa:component-mount.
429
+ * @param swapSelector - CSS selector bounding page-specific components.
430
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
431
+ * @example
432
+ * scanAndMount(state, emit, "main > section", route);
433
+ */
434
+ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
435
+ if (typeof document === "undefined") return;
436
+ const swapArea = document.querySelector(swapSelector);
437
+ const data = extractPageData(document);
438
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
439
+ }
440
+ /**
441
+ * Unmounts page-specific instances inside the swap region (runs `onUnMount` then
442
+ * `onDestroy`, then their cleanup disposers + api unregister), removes them from state,
443
+ * and emits `spa:component-unmount`. Persistent instances (outside the swap area) are
444
+ * left in place.
445
+ *
446
+ * @param state - The plugin state holding live instances.
447
+ * @param emit - The event emitter for spa:component-unmount.
448
+ * @example
449
+ * unmountPageSpecific(state, emit);
450
+ */
451
+ function unmountPageSpecific(state, emit) {
452
+ const data = currentPageData();
453
+ for (const [element, instance] of state.instances) {
454
+ if (instance.persistent) continue;
455
+ instance.data = data;
456
+ runHook(instance, "onUnMount");
457
+ runHook(instance, "onDestroy");
458
+ disposeInstance(state, instance);
459
+ state.instances.delete(element);
460
+ emit("spa:component-unmount", {
461
+ name: instance.def.name,
462
+ el: element
463
+ });
464
+ }
465
+ }
466
+ /**
467
+ * Disposes ALL live instances (persistent and page-specific) on teardown: runs
468
+ * `onUnMount` then `onDestroy`, then their cleanup disposers + api unregister, emits
469
+ * `spa:component-unmount`, and clears the instance + api maps. Used by the kernel's
470
+ * `dispose` on plugin stop.
471
+ *
472
+ * @param state - The plugin state holding live instances.
473
+ * @param emit - The event emitter for spa:component-unmount.
474
+ * @example
475
+ * unmountAll(state, emit);
476
+ */
477
+ function unmountAll(state, emit) {
478
+ const data = currentPageData();
479
+ for (const [element, instance] of state.instances) {
480
+ instance.data = data;
481
+ runHook(instance, "onUnMount");
482
+ runHook(instance, "onDestroy");
483
+ disposeInstance(state, instance);
484
+ emit("spa:component-unmount", {
485
+ name: instance.def.name,
486
+ el: element
487
+ });
488
+ }
489
+ state.instances.clear();
490
+ state.componentApis.clear();
491
+ }
492
+ /**
493
+ * Fires `onNavStart` on every currently-mounted instance (persistent instances
494
+ * receive it across navigations; page-specific ones receive it before unmount).
495
+ *
496
+ * @param state - The plugin state holding live instances.
497
+ * @example
498
+ * notifyNavStart(state);
499
+ */
500
+ function notifyNavStart(state) {
501
+ const data = currentPageData();
502
+ for (const instance of state.instances.values()) {
503
+ instance.data = data;
504
+ runHook(instance, "onNavStart");
505
+ }
506
+ }
507
+ /**
508
+ * Fires `onNavEnd` on persistent instances that survived the swap (page-specific
509
+ * instances were already destroyed and re-created by the swap), updating their route
510
+ * slice to the destination first.
511
+ *
512
+ * @param state - The plugin state holding live instances.
513
+ * @param route - The matched-route slice for the destination URL (params/meta/locale/url).
514
+ * @example
515
+ * notifyNavEnd(state, route);
516
+ */
517
+ function notifyNavEnd(state, route = EMPTY_ROUTE) {
518
+ const data = currentPageData();
519
+ for (const instance of state.instances.values()) {
520
+ if (!instance.persistent) continue;
521
+ instance.data = data;
522
+ instance.route = route;
523
+ runHook(instance, "onNavEnd");
524
+ }
525
+ }
526
+ //#endregion
527
+ //#region src/plugins/spa/state.ts
528
+ /**
529
+ * Creates initial spa plugin state. All kernel state lives here — never module
530
+ * scope. The kernel itself is built in onInit and stored as `kernel`, so
531
+ * api/onStart/onStop all reuse the single shared instance.
532
+ *
533
+ * @param _ctx - Minimal context with global and config.
534
+ * @param _ctx.global - Global plugin registry.
535
+ * @param _ctx.config - Resolved plugin configuration.
536
+ * @returns The initial SPA state with an empty kernel slot.
537
+ * @example
538
+ * const state = createState({ global: {}, config: defaultSpaConfig });
539
+ */
540
+ function createState(_ctx) {
541
+ return {
542
+ registeredComponents: /* @__PURE__ */ new Map(),
543
+ instances: /* @__PURE__ */ new Map(),
544
+ componentApis: /* @__PURE__ */ new Map(),
545
+ currentUrl: "",
546
+ destroyRouter: null,
547
+ started: false,
548
+ kernel: null
549
+ };
550
+ }
551
+ //#endregion
552
+ //#region src/testing.ts
553
+ /**
554
+ * @file `@moku-labs/web/testing` — a tiny headless test harness for SPA islands.
555
+ *
556
+ * Mounts ONE island through the REAL spa kernel internals (`createState` +
557
+ * `scanAndMount` + `notifyNav*` + `unmountPageSpecific`/`unmountAll`) under a DOM
558
+ * (happy-dom in Vitest), so a consumer can unit-test an island in a few lines without
559
+ * reaching into framework internals or booting a whole `createApp`. It drives the
560
+ * plugin-mirror API directly: read typed `state`/`api`, dispatch declarative `events`,
561
+ * drain the `ctx.set → render` scheduler (`flush`/`settle`), simulate navigation, and
562
+ * assert auto-teardown of `events` + `ctx.cleanup` on `unmount`.
563
+ *
564
+ * This module is a SEPARATE entry — NEVER imported by `browser.ts` — so test-only code
565
+ * (and its static Preact import for {@link renderIsland}) never enters a client bundle.
566
+ * @see README.md
567
+ */
568
+ /** The swap selector the harness uses to bound page-specific islands. */
569
+ const SWAP_SELECTOR = "main > section";
570
+ /**
571
+ * Parse a `"<type> <selector>"` event spec into its event type and selector (only the
572
+ * first space splits, so descendant-combinator selectors work).
573
+ *
574
+ * @param spec - The event spec string.
575
+ * @returns The event `type` and `selector` (selector empty for host-level).
576
+ * @example
577
+ * parseEventSpec("click [data-action='x']"); // { type: "click", selector: "[data-action='x']" }
578
+ */
579
+ function parseEventSpec(spec) {
580
+ const space = spec.indexOf(" ");
581
+ return space === -1 ? {
582
+ type: spec.trim(),
583
+ selector: ""
584
+ } : {
585
+ type: spec.slice(0, space).trim(),
586
+ selector: spec.slice(space + 1).trim()
587
+ };
588
+ }
589
+ /**
590
+ * Mount ONE island headlessly through the REAL spa kernel internals under a DOM. The
591
+ * unit + light-integration tier: no `createApp`, no router, no network.
592
+ *
593
+ * @param definition - The component definition under test (from `createComponent`).
594
+ * @param options - Host HTML/element, route slice, page data, persistence, stub apis.
595
+ * @returns A handle exposing the instance's `state`/`api` + event/nav/flush drivers.
596
+ * @example
597
+ * const h = mountIsland(tabNav, { html: "<a></a><a></a><a></a>", persistent: true });
598
+ * h.navEnd({ locale: "en" });
599
+ * expect(h.el.querySelector("[aria-current]")).toBeTruthy();
600
+ */
601
+ function mountIsland(definition, options = {}) {
602
+ const state = createState({
603
+ global: {},
604
+ config: {}
605
+ });
606
+ state.registeredComponents.set(definition.name, definition);
607
+ if (options.components) for (const [name, api] of Object.entries(options.components)) state.componentApis.set(name, api);
608
+ const host = options.el ?? document.createElement("div");
609
+ host.dataset.component = definition.name;
610
+ if (options.html !== void 0) host.innerHTML = options.html;
611
+ const dataScript = options.data ? `<script id="__DATA__" type="application/json">${JSON.stringify(options.data)}<\/script>` : "";
612
+ document.body.innerHTML = `<main><section id="__moku_swap"></section></main>${dataScript}`;
613
+ if (!options.el) {
614
+ const swapRegion = document.querySelector("#__moku_swap");
615
+ (options.persistent ? document.body : swapRegion ?? document.body).append(host);
616
+ }
617
+ const emitted = [];
618
+ const emit = (event, payload) => {
619
+ emitted.push({
620
+ event,
621
+ payload
622
+ });
623
+ };
624
+ const route = {
625
+ params: options.params ?? {},
626
+ meta: options.meta ?? {},
627
+ locale: options.locale ?? "",
628
+ url: options.url ?? ((name) => `/${name}`)
629
+ };
630
+ scanAndMount(state, emit, SWAP_SELECTOR, route);
631
+ const instance = state.instances.get(host);
632
+ return {
633
+ el: host,
634
+ /**
635
+ * The live per-instance state (typed), or undefined for hooks-only islands.
636
+ *
637
+ * @returns The current state.
638
+ * @example
639
+ * handle.state?.count;
640
+ */
641
+ get state() {
642
+ return instance?.state;
643
+ },
644
+ /**
645
+ * The island's registered api (typed), or undefined when none was declared.
646
+ *
647
+ * @returns The api object.
648
+ * @example
649
+ * handle.api?.open();
650
+ */
651
+ get api() {
652
+ return instance?.api;
653
+ },
654
+ emitted,
655
+ /**
656
+ * Dispatch a delegated event by `"<type> <selector>"` spec onto the first match.
657
+ *
658
+ * @param spec - The event spec (selector optional → host-level).
659
+ * @param init - Optional event init (bubbles/cancelable default true).
660
+ * @example
661
+ * handle.fire("click [data-inc]");
662
+ */
663
+ fire(spec, init) {
664
+ const { type, selector } = parseEventSpec(spec);
665
+ (selector ? host.querySelector(selector) ?? host : host).dispatchEvent(new Event(type, {
666
+ bubbles: true,
667
+ cancelable: true,
668
+ ...init
669
+ }));
670
+ },
671
+ /**
672
+ * Dispatch a pre-built event at a selector (raw control for DragEvent/dataTransfer).
673
+ *
674
+ * @param selector - The element to dispatch on (host when no match).
675
+ * @param event - The pre-built event.
676
+ * @example
677
+ * handle.dispatch("[data-cards]", dropEvent);
678
+ */
679
+ dispatch(selector, event) {
680
+ (host.querySelector(selector) ?? host).dispatchEvent(event);
681
+ },
682
+ /**
683
+ * Synchronously drain any pending render now.
684
+ *
685
+ * @example
686
+ * handle.flush();
687
+ */
688
+ flush() {
689
+ instance?.flush();
690
+ },
691
+ /**
692
+ * Await onMount + the render-chunk load + a microtask, then flush.
693
+ *
694
+ * @returns A promise resolving once mounted and rendered.
695
+ * @example
696
+ * await handle.settle();
697
+ */
698
+ async settle() {
699
+ await instance?.mountPromise;
700
+ await Promise.resolve();
701
+ instance?.flush();
702
+ },
703
+ /**
704
+ * Fire onNavStart on the instance.
705
+ *
706
+ * @example
707
+ * handle.navStart();
708
+ */
709
+ navStart() {
710
+ notifyNavStart(state);
711
+ },
712
+ /**
713
+ * Fire onNavEnd on a persistent instance with an optional destination route.
714
+ *
715
+ * @param next - Partial route slice to merge onto the current one.
716
+ * @example
717
+ * handle.navEnd({ params: { id: "2" } });
718
+ */
719
+ navEnd(next) {
720
+ notifyNavEnd(state, {
721
+ ...route,
722
+ ...next
723
+ });
724
+ },
725
+ /**
726
+ * Run onUnMount + onDestroy (page-specific first, then persistent).
727
+ *
728
+ * @example
729
+ * handle.unmount();
730
+ */
731
+ unmount() {
732
+ unmountPageSpecific(state, emit);
733
+ unmountAll(state, emit);
734
+ }
735
+ };
736
+ }
737
+ /**
738
+ * Commit a {@link RenderResult} into a host for {@link renderIsland} (the pure tier):
739
+ * `string` → innerHTML, `Node` → replaceChildren, VNode → Preact `render` (wrapped in
740
+ * `act` so effects flush), `void` → no-op.
741
+ *
742
+ * @param host - The host element to render into.
743
+ * @param result - The render result.
744
+ * @example
745
+ * commit(host, h(View, { items }));
746
+ */
747
+ function commit(host, result) {
748
+ if (result === void 0) return;
749
+ if (typeof result === "string") {
750
+ host.innerHTML = result;
751
+ return;
752
+ }
753
+ if (result instanceof Node) {
754
+ host.replaceChildren(result);
755
+ return;
756
+ }
757
+ act(() => {
758
+ render(result, host);
759
+ });
760
+ }
761
+ /**
762
+ * No-op stand-in for a context method the pure `renderIsland` tier never invokes.
763
+ *
764
+ * @example
765
+ * const ctx = { set: noopStub };
766
+ */
767
+ function noopStub() {}
768
+ /**
769
+ * Stand-in link builder for the pure `renderIsland` tier.
770
+ *
771
+ * @param name - The route name.
772
+ * @returns A `/<name>` placeholder href.
773
+ * @example
774
+ * stubUrl("board"); // "/board"
775
+ */
776
+ function stubUrl(name) {
777
+ return `/${name}`;
778
+ }
779
+ /**
780
+ * The cheapest unit tier: render a controller/view island's pure `render(state, ctx)`
781
+ * against fixture state, with no kernel and no `mountIsland`. Uses `preact/test-utils`
782
+ * `act` (which ships WITH Preact — no new dependency) so effects flush deterministically.
783
+ *
784
+ * @param render - The island's `render` function (e.g. `boardList.spec.render`).
785
+ * @param input - Fixture inputs.
786
+ * @param input.state - The fixture per-instance state to render.
787
+ * @param input.ctx - Optional partial context overrides.
788
+ * @returns A {@link RenderIslandResult} for asserting the rendered DOM.
789
+ * @example
790
+ * const r = renderIsland(render, { state: { boards: [{ id: "1", title: "Alpha" }] } });
791
+ * expect(r.find("[data-board]")).toBeTruthy();
792
+ */
793
+ function renderIsland(render$1, input) {
794
+ const host = document.createElement("div");
795
+ document.body.append(host);
796
+ const ctx = {
797
+ el: host,
798
+ data: {},
799
+ params: {},
800
+ meta: {},
801
+ locale: "",
802
+ url: stubUrl,
803
+ state: input.state,
804
+ set: noopStub,
805
+ flush: noopStub,
806
+ cleanup: noopStub,
807
+ component: noopStub,
808
+ ...input.ctx
809
+ };
810
+ commit(host, render$1(input.state, ctx));
811
+ return {
812
+ host,
813
+ /**
814
+ * The host's current `innerHTML`.
815
+ *
816
+ * @returns The serialized markup.
817
+ * @example
818
+ * result.html();
819
+ */
820
+ html() {
821
+ return host.innerHTML;
822
+ },
823
+ /**
824
+ * Query the host for the first element matching a selector.
825
+ *
826
+ * @param selector - A CSS selector.
827
+ * @returns The first match, or `null`.
828
+ * @example
829
+ * result.find("[data-board]");
830
+ */
831
+ find(selector) {
832
+ return host.querySelector(selector);
833
+ },
834
+ /**
835
+ * Unmount the Preact tree and remove the host from the document.
836
+ *
837
+ * @example
838
+ * result.unmount();
839
+ */
840
+ unmount() {
841
+ act(() => render(null, host));
842
+ host.remove();
843
+ }
844
+ };
845
+ }
846
+ //#endregion
847
+ export { mountIsland, renderIsland };