@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/testing.mjs
ADDED
|
@@ -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 };
|