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