@moku-labs/web 1.16.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/browser.d.mts +111 -108
- package/dist/browser.mjs +189 -189
- package/dist/index.cjs +226 -206
- package/dist/index.d.cts +124 -108
- package/dist/index.d.mts +124 -108
- package/dist/index.mjs +226 -206
- package/dist/{render-KdufA3_b.cjs → render-DHUcHCYs.cjs} +4 -4
- package/dist/{render-UO4nimWr.mjs → render-yXHc9BWI.mjs} +4 -4
- package/dist/testing.d.mts +59 -56
- package/dist/testing.mjs +50 -50
- package/package.json +1 -1
|
@@ -29,17 +29,17 @@ function renderVNode(vnode, region) {
|
|
|
29
29
|
(0, preact.render)(vnode, region);
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* Commit
|
|
32
|
+
* Commit an island's VNode into its host as a DIFF — the in-interaction render
|
|
33
33
|
* path. Unlike {@link renderVNode} (which RESETS the region: unmount → clear → mount,
|
|
34
34
|
* correct for a navigation swap of static SSR markup), this is a plain Preact
|
|
35
35
|
* `render(vnode, host)` that diffs against Preact's retained vdom, preserving focus,
|
|
36
36
|
* scroll, and uncontrolled input state across re-renders triggered by `ctx.set`.
|
|
37
37
|
*
|
|
38
|
-
* Reached ONLY through the lazy `await import("./render")` gate (the
|
|
39
|
-
* scheduler in `
|
|
38
|
+
* Reached ONLY through the lazy `await import("./render")` gate (the island render
|
|
39
|
+
* scheduler in `islands.ts`), so an app whose islands never return a VNode never
|
|
40
40
|
* pulls Preact's `render` into its main bundle.
|
|
41
41
|
*
|
|
42
|
-
* @param vnode - The VNode produced by
|
|
42
|
+
* @param vnode - The VNode produced by an island's `render(state, ctx)`.
|
|
43
43
|
* @param host - The island's host element to render into.
|
|
44
44
|
* @example
|
|
45
45
|
* ```ts
|
|
@@ -29,17 +29,17 @@ function renderVNode(vnode, region) {
|
|
|
29
29
|
render(vnode, region);
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* Commit
|
|
32
|
+
* Commit an island's VNode into its host as a DIFF — the in-interaction render
|
|
33
33
|
* path. Unlike {@link renderVNode} (which RESETS the region: unmount → clear → mount,
|
|
34
34
|
* correct for a navigation swap of static SSR markup), this is a plain Preact
|
|
35
35
|
* `render(vnode, host)` that diffs against Preact's retained vdom, preserving focus,
|
|
36
36
|
* scroll, and uncontrolled input state across re-renders triggered by `ctx.set`.
|
|
37
37
|
*
|
|
38
|
-
* Reached ONLY through the lazy `await import("./render")` gate (the
|
|
39
|
-
* scheduler in `
|
|
38
|
+
* Reached ONLY through the lazy `await import("./render")` gate (the island render
|
|
39
|
+
* scheduler in `islands.ts`), so an app whose islands never return a VNode never
|
|
40
40
|
* pulls Preact's `render` into its main bundle.
|
|
41
41
|
*
|
|
42
|
-
* @param vnode - The VNode produced by
|
|
42
|
+
* @param vnode - The VNode produced by an island's `render(state, ctx)`.
|
|
43
43
|
* @param host - The island's host element to render into.
|
|
44
44
|
* @example
|
|
45
45
|
* ```ts
|
package/dist/testing.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//#region src/plugins/spa/types.d.ts
|
|
2
2
|
/**
|
|
3
|
-
* What a
|
|
3
|
+
* What a island's `render` may return:
|
|
4
4
|
* - a Preact `VNode` — committed into the host through the lazy Preact gate (`commitVNode`);
|
|
5
5
|
* - a `Node` — replaces the host's children;
|
|
6
6
|
* - a `string` — set as the host's `innerHTML`;
|
|
@@ -12,30 +12,33 @@ type RenderResult = AnyVNode | Node | string | void;
|
|
|
12
12
|
* `VNode<SomeProps>`; the props generic is invariant under `exactOptionalPropertyTypes`,
|
|
13
13
|
* so the only supertype that accepts every concrete `VNode<P>` is `VNode<any>`.
|
|
14
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
*
|
|
17
|
+
*/
|
|
15
18
|
type AnyVNode = import("preact").VNode<any>;
|
|
16
19
|
/**
|
|
17
|
-
* Factory that builds a
|
|
20
|
+
* Factory that builds a island's typed per-instance state (mirrors a plugin's
|
|
18
21
|
* `createState`). Called ONCE at mount; the returned object is stored on the
|
|
19
|
-
* {@link
|
|
22
|
+
* {@link IslandInstance} and exposed read-only as `ctx.state`.
|
|
20
23
|
*
|
|
21
|
-
* @param ctx - The
|
|
24
|
+
* @param ctx - The island context for this instance (state is not yet set).
|
|
22
25
|
* @returns The initial per-instance state.
|
|
23
26
|
* @example
|
|
24
27
|
* state: (ctx): BoardState => ({ boardId: ctx.params.id ?? "", cards: [] })
|
|
25
28
|
*/
|
|
26
|
-
type
|
|
29
|
+
type IslandStateFactory<S extends object> = (ctx: IslandContext<S>) => S;
|
|
27
30
|
/**
|
|
28
31
|
* Pure render of `(state, ctx)` → {@link RenderResult}. Called after mount-state-init
|
|
29
32
|
* and again (microtask-batched) after every `ctx.set`. Must be free of side effects
|
|
30
33
|
* beyond producing its result.
|
|
31
34
|
*
|
|
32
35
|
* @param state - The current per-instance state (read-only).
|
|
33
|
-
* @param ctx - The
|
|
36
|
+
* @param ctx - The island context for this instance.
|
|
34
37
|
* @returns The render result to commit into the host.
|
|
35
38
|
* @example
|
|
36
39
|
* render: (state) => h(BoardView, { snapshot: state.snapshot })
|
|
37
40
|
*/
|
|
38
|
-
type
|
|
41
|
+
type IslandRender<S extends object> = (state: Readonly<S>, ctx: IslandContext<S>) => RenderResult;
|
|
39
42
|
/**
|
|
40
43
|
* A delegated DOM event handler. `target` is the element matched by the key's selector
|
|
41
44
|
* (already resolved via `closest` — no `instanceof`/`closest` ceremony in the body).
|
|
@@ -43,14 +46,14 @@ type ComponentRender<S extends object> = (state: Readonly<S>, ctx: ComponentCont
|
|
|
43
46
|
* Typed `void` for ergonomics (the void-return rule accepts async handlers returning
|
|
44
47
|
* `Promise<void>` too); the kernel ignores any returned value.
|
|
45
48
|
*
|
|
46
|
-
* @param ctx - The
|
|
49
|
+
* @param ctx - The island context (carries the live per-instance `state`).
|
|
47
50
|
* @param event - The raw DOM event.
|
|
48
51
|
* @param target - The element matched by the selector (the host when no selector).
|
|
49
52
|
* @returns void (a returned promise is ignored by the kernel).
|
|
50
53
|
* @example
|
|
51
54
|
* (ctx, event, button) => { event.preventDefault(); ctx.set({ open: true }); }
|
|
52
55
|
*/
|
|
53
|
-
type
|
|
56
|
+
type IslandEventHandler<S extends object> = (ctx: IslandContext<S>, event: Event, target: Element) => void;
|
|
54
57
|
/**
|
|
55
58
|
* Declarative delegated event map. Each key is `"<type> <selector>"` (the selector is
|
|
56
59
|
* optional → a host-level listener). ONE real listener per event TYPE is attached to
|
|
@@ -63,9 +66,9 @@ type ComponentEventHandler<S extends object> = (ctx: ComponentContext<S>, event:
|
|
|
63
66
|
* "submit [data-add]": (ctx, e) => { e.preventDefault(); add(ctx); }
|
|
64
67
|
* }
|
|
65
68
|
*/
|
|
66
|
-
type
|
|
69
|
+
type IslandEvents<S extends object> = Record<string, IslandEventHandler<S>>;
|
|
67
70
|
/**
|
|
68
|
-
* Context handed to every
|
|
71
|
+
* Context handed to every island lifecycle hook, render, and event handler — the
|
|
69
72
|
* bound element + page data, plus the matched route's `params`/`meta`/`locale` and a
|
|
70
73
|
* link builder, so an island can read its route context (e.g. a `card` route's
|
|
71
74
|
* `ctx.meta.focus` + `ctx.params.id`) directly, without the page bridging it through
|
|
@@ -73,11 +76,11 @@ type ComponentEvents<S extends object> = Record<string, ComponentEventHandler<S>
|
|
|
73
76
|
*
|
|
74
77
|
* Generic over the per-instance state `S` (default `undefined` so every existing
|
|
75
78
|
* hooks-only island still type-checks). The additive members (`state`/`set`/`flush`/
|
|
76
|
-
* `cleanup`/`
|
|
79
|
+
* `cleanup`/`island`) are ALWAYS-PRESENT functions — never optional keys — so they
|
|
77
80
|
* never trip `exactOptionalPropertyTypes`.
|
|
78
81
|
*/
|
|
79
|
-
interface
|
|
80
|
-
/** The element the
|
|
82
|
+
interface IslandContext<S = undefined> {
|
|
83
|
+
/** The element the island instance is bound to. */
|
|
81
84
|
el: Element;
|
|
82
85
|
/** Page data extracted from the `script#__DATA__` payload. */
|
|
83
86
|
data: PageData;
|
|
@@ -126,107 +129,107 @@ interface ComponentContext<S = undefined> {
|
|
|
126
129
|
* Resolve another island's registered `api` by name. Returns `undefined` when no
|
|
127
130
|
* provider is registered (optional-dependency semantics, mirroring `ctx.has`).
|
|
128
131
|
*
|
|
129
|
-
* @param name - The provider island's
|
|
132
|
+
* @param name - The provider island's island name.
|
|
130
133
|
* @returns The provider's api, or `undefined`.
|
|
131
134
|
* @example
|
|
132
|
-
* ctx.
|
|
135
|
+
* ctx.island<LightboxApi>("lightbox")?.open(slides, index);
|
|
133
136
|
*/
|
|
134
|
-
|
|
137
|
+
island<T = unknown>(name: string): T | undefined;
|
|
135
138
|
}
|
|
136
|
-
/** Lifecycle hooks a
|
|
137
|
-
interface
|
|
139
|
+
/** Lifecycle hooks a island may implement. Generic over the per-instance state `S`. */
|
|
140
|
+
interface IslandHooks<S = undefined> {
|
|
138
141
|
/**
|
|
139
142
|
* Called once when the instance is created (before DOM attach).
|
|
140
143
|
*
|
|
141
|
-
* @param ctx - The
|
|
144
|
+
* @param ctx - The island context for this instance.
|
|
142
145
|
* @returns void
|
|
143
146
|
* @example
|
|
144
147
|
* onCreate({ el }) { el.dataset.ready = "1"; }
|
|
145
148
|
*/
|
|
146
|
-
onCreate?(ctx:
|
|
149
|
+
onCreate?(ctx: IslandContext<S>): void;
|
|
147
150
|
/**
|
|
148
151
|
* Called after the instance is attached to its element.
|
|
149
152
|
*
|
|
150
|
-
* @param ctx - The
|
|
153
|
+
* @param ctx - The island context for this instance.
|
|
151
154
|
* @returns void
|
|
152
155
|
* @example
|
|
153
156
|
* onMount({ el }) { el.textContent = "0"; }
|
|
154
157
|
* @example
|
|
155
158
|
* async onMount(ctx) { ctx.set({ items: await load() }); } // async is allowed; the harness awaits it via settle()
|
|
156
159
|
*/
|
|
157
|
-
onMount?(ctx:
|
|
160
|
+
onMount?(ctx: IslandContext<S>): void;
|
|
158
161
|
/**
|
|
159
162
|
* Called when a navigation begins while this instance is mounted.
|
|
160
163
|
*
|
|
161
|
-
* @param ctx - The
|
|
164
|
+
* @param ctx - The island context for this instance.
|
|
162
165
|
* @returns void
|
|
163
166
|
* @example
|
|
164
167
|
* onNavStart({ el }) { el.dataset.loading = ""; }
|
|
165
168
|
*/
|
|
166
|
-
onNavStart?(ctx:
|
|
169
|
+
onNavStart?(ctx: IslandContext<S>): void;
|
|
167
170
|
/**
|
|
168
171
|
* Called when a navigation completes while this instance is mounted.
|
|
169
172
|
*
|
|
170
|
-
* @param ctx - The
|
|
173
|
+
* @param ctx - The island context for this instance.
|
|
171
174
|
* @returns void
|
|
172
175
|
* @example
|
|
173
176
|
* onNavEnd({ el }) { delete el.dataset.loading; }
|
|
174
177
|
*/
|
|
175
|
-
onNavEnd?(ctx:
|
|
178
|
+
onNavEnd?(ctx: IslandContext<S>): void;
|
|
176
179
|
/**
|
|
177
180
|
* Called before the instance is detached from its element.
|
|
178
181
|
*
|
|
179
|
-
* @param ctx - The
|
|
182
|
+
* @param ctx - The island context for this instance.
|
|
180
183
|
* @returns void
|
|
181
184
|
* @example
|
|
182
185
|
* onUnMount({ el }) { el.replaceChildren(); }
|
|
183
186
|
*/
|
|
184
|
-
onUnMount?(ctx:
|
|
187
|
+
onUnMount?(ctx: IslandContext<S>): void;
|
|
185
188
|
/**
|
|
186
189
|
* Called once when the instance is destroyed (after detach).
|
|
187
190
|
*
|
|
188
|
-
* @param ctx - The
|
|
191
|
+
* @param ctx - The island context for this instance.
|
|
189
192
|
* @returns void
|
|
190
193
|
* @example
|
|
191
194
|
* onDestroy({ el }) { delete el.dataset.ready; }
|
|
192
195
|
*/
|
|
193
|
-
onDestroy?(ctx:
|
|
196
|
+
onDestroy?(ctx: IslandContext<S>): void;
|
|
194
197
|
}
|
|
195
198
|
/**
|
|
196
|
-
* The spec extras carried on a {@link
|
|
197
|
-
* (authors keep full `S` inference at the `
|
|
199
|
+
* The spec extras carried on a {@link IslandDef}, type-erased to `object` state
|
|
200
|
+
* (authors keep full `S` inference at the `createIsland` call site; the registry
|
|
198
201
|
* stores the runtime-only erased form). Absent for legacy `(name, hooks)` defs.
|
|
199
202
|
*/
|
|
200
|
-
interface
|
|
203
|
+
interface IslandSpecExtras {
|
|
201
204
|
/** Per-instance state factory. */
|
|
202
|
-
state?:
|
|
205
|
+
state?: IslandStateFactory<object>;
|
|
203
206
|
/** Render called on mount + after every `ctx.set`. */
|
|
204
|
-
render?:
|
|
207
|
+
render?: IslandRender<object>;
|
|
205
208
|
/** Declarative delegated events. */
|
|
206
|
-
events?:
|
|
207
|
-
/** Public api factory registered under the
|
|
208
|
-
api?: (ctx:
|
|
209
|
+
events?: IslandEvents<object>;
|
|
210
|
+
/** Public api factory registered under the island name. */
|
|
211
|
+
api?: (ctx: IslandContext<object>) => unknown;
|
|
209
212
|
}
|
|
210
|
-
/** A registered
|
|
211
|
-
interface
|
|
212
|
-
/** Unique
|
|
213
|
+
/** A registered island definition (an opaque token; author inference lives on `createIsland`). */
|
|
214
|
+
interface IslandDef {
|
|
215
|
+
/** Unique island name (matched against `data-island`). */
|
|
213
216
|
name: string;
|
|
214
217
|
/** Lifecycle hooks (the subset shared with the legacy form). */
|
|
215
|
-
hooks:
|
|
218
|
+
hooks: IslandHooks<object>;
|
|
216
219
|
/** Plugin-mirror extras (state/render/events/api). Absent for legacy `(name, hooks)` defs. */
|
|
217
|
-
spec?:
|
|
220
|
+
spec?: IslandSpecExtras;
|
|
218
221
|
}
|
|
219
222
|
/** The matched-route slice carried on a live instance (params/meta/locale + link builder). */
|
|
220
|
-
type
|
|
223
|
+
type IslandRouteSlice = Pick<IslandContext, "params" | "meta" | "locale" | "url">;
|
|
221
224
|
/** Page data payload parsed from the inline `script#__DATA__` element. */
|
|
222
225
|
type PageData = Record<string, unknown>;
|
|
223
226
|
//#endregion
|
|
224
|
-
//#region src/plugins/spa/
|
|
225
|
-
/** The matched-route slice merged onto the
|
|
226
|
-
type RouteSlice =
|
|
227
|
+
//#region src/plugins/spa/islands.d.ts
|
|
228
|
+
/** The matched-route slice merged onto the island context (params/meta/locale + link builder). */
|
|
229
|
+
type RouteSlice = IslandRouteSlice;
|
|
227
230
|
//#endregion
|
|
228
231
|
//#region src/testing.d.ts
|
|
229
|
-
/** One captured spa emit (the kernel's `spa:
|
|
232
|
+
/** One captured spa emit (the kernel's `spa:island-mount` / `-unmount`). */
|
|
230
233
|
interface CapturedEmit {
|
|
231
234
|
/** The event name. */
|
|
232
235
|
readonly event: string;
|
|
@@ -301,7 +304,7 @@ interface IslandHandle<S extends object = object, A = unknown> {
|
|
|
301
304
|
* handle.unmount();
|
|
302
305
|
*/
|
|
303
306
|
unmount(): void;
|
|
304
|
-
/** Captured `spa:
|
|
307
|
+
/** Captured `spa:island-mount` / `-unmount` emits, in order. */
|
|
305
308
|
readonly emitted: ReadonlyArray<CapturedEmit>;
|
|
306
309
|
}
|
|
307
310
|
/** Options for {@link mountIsland}. All optional; sensible headless defaults. */
|
|
@@ -322,14 +325,14 @@ interface MountIslandOptions {
|
|
|
322
325
|
url?: (name: string, params?: Record<string, string>) => string;
|
|
323
326
|
/** Mount OUTSIDE the swap area so the instance is persistent (gets `onNavEnd`). */
|
|
324
327
|
persistent?: boolean;
|
|
325
|
-
/** Stubbed sibling-island apis resolved by `ctx.
|
|
326
|
-
|
|
328
|
+
/** Stubbed sibling-island apis resolved by `ctx.island(name)`. */
|
|
329
|
+
islands?: Record<string, unknown>;
|
|
327
330
|
}
|
|
328
331
|
/**
|
|
329
332
|
* Mount ONE island headlessly through the REAL spa kernel internals under a DOM. The
|
|
330
333
|
* unit + light-integration tier: no `createApp`, no router, no network.
|
|
331
334
|
*
|
|
332
|
-
* @param definition - The
|
|
335
|
+
* @param definition - The island definition under test (from `createIsland`).
|
|
333
336
|
* @param options - Host HTML/element, route slice, page data, persistence, stub apis.
|
|
334
337
|
* @returns A handle exposing the instance's `state`/`api` + event/nav/flush drivers.
|
|
335
338
|
* @example
|
|
@@ -337,7 +340,7 @@ interface MountIslandOptions {
|
|
|
337
340
|
* h.navEnd({ locale: "en" });
|
|
338
341
|
* expect(h.el.querySelector("[aria-current]")).toBeTruthy();
|
|
339
342
|
*/
|
|
340
|
-
declare function mountIsland<S extends object = object, A = unknown>(definition:
|
|
343
|
+
declare function mountIsland<S extends object = object, A = unknown>(definition: IslandDef, options?: MountIslandOptions): IslandHandle<S, A>;
|
|
341
344
|
/** The result of {@link renderIsland} — a host plus query/flush/teardown helpers. */
|
|
342
345
|
interface RenderIslandResult {
|
|
343
346
|
/** The host element the view rendered into. */
|
|
@@ -381,9 +384,9 @@ interface RenderIslandResult {
|
|
|
381
384
|
* const r = renderIsland(render, { state: { boards: [{ id: "1", title: "Alpha" }] } });
|
|
382
385
|
* expect(r.find("[data-board]")).toBeTruthy();
|
|
383
386
|
*/
|
|
384
|
-
declare function renderIsland<S extends object>(render:
|
|
387
|
+
declare function renderIsland<S extends object>(render: IslandRender<S>, input: {
|
|
385
388
|
state: S;
|
|
386
|
-
ctx?: Partial<
|
|
389
|
+
ctx?: Partial<IslandContext<S>>;
|
|
387
390
|
}): RenderIslandResult;
|
|
388
391
|
//#endregion
|
|
389
392
|
export { CapturedEmit, IslandHandle, MountIslandOptions, RenderIslandResult, mountIsland, renderIsland };
|
package/dist/testing.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { render } from "preact";
|
|
|
2
2
|
import { act } from "preact/test-utils";
|
|
3
3
|
//#region src/plugins/spa/types.ts
|
|
4
4
|
/** Allowed hook names — single source of truth for fail-fast validation. */
|
|
5
|
-
const
|
|
5
|
+
const ISLAND_HOOK_NAMES = [
|
|
6
6
|
"onCreate",
|
|
7
7
|
"onMount",
|
|
8
8
|
"onNavStart",
|
|
@@ -11,17 +11,17 @@ const COMPONENT_HOOK_NAMES = [
|
|
|
11
11
|
"onDestroy"
|
|
12
12
|
];
|
|
13
13
|
//#endregion
|
|
14
|
-
//#region src/plugins/spa/
|
|
14
|
+
//#region src/plugins/spa/islands.ts
|
|
15
15
|
/**
|
|
16
|
-
* @file spa plugin —
|
|
17
|
-
* surface (`
|
|
16
|
+
* @file spa plugin — island lifecycle, mounting, the plugin-mirror authoring
|
|
17
|
+
* surface (`createIsland` with a typed `{ state, render, events, api }` spec),
|
|
18
18
|
* the per-instance state + microtask-batched render scheduler, declarative
|
|
19
19
|
* delegated events, and the cross-island api registry.
|
|
20
20
|
* @see README.md
|
|
21
21
|
*/
|
|
22
22
|
/** Error prefix for spa fail-fast failures (spec/11 Part-3). */
|
|
23
23
|
const ERROR_PREFIX = "[web]";
|
|
24
|
-
new Set(
|
|
24
|
+
new Set(ISLAND_HOOK_NAMES);
|
|
25
25
|
/** Synchronous re-entrancy cap for the render scheduler (a render that calls `ctx.flush`). */
|
|
26
26
|
const MAX_RENDER_DEPTH = 25;
|
|
27
27
|
/**
|
|
@@ -54,7 +54,7 @@ let renderChunk;
|
|
|
54
54
|
let commitVNodeFunction;
|
|
55
55
|
/**
|
|
56
56
|
* Load the lazy `./render` chunk (once) and cache its `commitVNode` for synchronous
|
|
57
|
-
* use by later renders. Awaited by a
|
|
57
|
+
* use by later renders. Awaited by a island's `mountPromise` so the test harness's
|
|
58
58
|
* `settle()` can deterministically flush a VNode render.
|
|
59
59
|
*
|
|
60
60
|
* @returns A promise that resolves once `commitVNode` is available.
|
|
@@ -62,7 +62,7 @@ let commitVNodeFunction;
|
|
|
62
62
|
* await loadRenderChunk();
|
|
63
63
|
*/
|
|
64
64
|
async function loadRenderChunk() {
|
|
65
|
-
renderChunk ??= import("./render-
|
|
65
|
+
renderChunk ??= import("./render-yXHc9BWI.mjs");
|
|
66
66
|
commitVNodeFunction = (await renderChunk).commitVNode;
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
@@ -71,7 +71,7 @@ async function loadRenderChunk() {
|
|
|
71
71
|
* a Preact `VNode` → committed through the lazy gate (loading it on demand if needed).
|
|
72
72
|
*
|
|
73
73
|
* @param host - The island host element to render into.
|
|
74
|
-
* @param result - The value returned by the
|
|
74
|
+
* @param result - The value returned by the island's `render`.
|
|
75
75
|
* @example
|
|
76
76
|
* commitResult(host, h(View, { items }));
|
|
77
77
|
*/
|
|
@@ -93,7 +93,7 @@ function commitResult(host, result) {
|
|
|
93
93
|
loadRenderChunk().then(() => commitVNodeFunction?.(vnode, host)).catch(() => {});
|
|
94
94
|
}
|
|
95
95
|
/**
|
|
96
|
-
* Run a
|
|
96
|
+
* Run a island's `render(state, ctx)` and commit the result now. Guards against
|
|
97
97
|
* synchronous re-entrancy (a render that calls `ctx.flush`) with a depth cap.
|
|
98
98
|
*
|
|
99
99
|
* @param instance - The instance to render.
|
|
@@ -104,7 +104,7 @@ function commitResult(host, result) {
|
|
|
104
104
|
function runRender(instance) {
|
|
105
105
|
const render = instance.def.spec?.render;
|
|
106
106
|
if (!render) return;
|
|
107
|
-
if (instance.renderDepth > MAX_RENDER_DEPTH) throw new Error(`${ERROR_PREFIX}
|
|
107
|
+
if (instance.renderDepth > MAX_RENDER_DEPTH) throw new Error(`${ERROR_PREFIX} island "${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
108
|
instance.renderDepth += 1;
|
|
109
109
|
try {
|
|
110
110
|
commitResult(instance.el, render(instance.state ?? {}, instance.ctx));
|
|
@@ -130,12 +130,12 @@ function scheduleRender(instance) {
|
|
|
130
130
|
});
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
133
|
-
* Build the single per-instance {@link
|
|
133
|
+
* Build the single per-instance {@link IslandContext} reused by every hook, event
|
|
134
134
|
* handler, and render. Route fields (`params`/`meta`/`locale`/`url`) and `data` read
|
|
135
135
|
* through the instance so a navigation update is reflected without rebuilding the ctx;
|
|
136
|
-
* `state`/`set`/`flush`/`cleanup`/`
|
|
136
|
+
* `state`/`set`/`flush`/`cleanup`/`island` are bound to the instance + plugin state.
|
|
137
137
|
*
|
|
138
|
-
* @param state - The plugin state (for the cross-island `
|
|
138
|
+
* @param state - The plugin state (for the cross-island `island` resolver).
|
|
139
139
|
* @param instance - The instance the context is bound to.
|
|
140
140
|
* @returns The instance-bound context.
|
|
141
141
|
* @example
|
|
@@ -239,13 +239,13 @@ function buildContext(state, instance) {
|
|
|
239
239
|
/**
|
|
240
240
|
* Resolve another island's registered api by name (`undefined` when absent).
|
|
241
241
|
*
|
|
242
|
-
* @param name - The provider island's
|
|
242
|
+
* @param name - The provider island's island name.
|
|
243
243
|
* @returns The provider's api, or `undefined`.
|
|
244
244
|
* @example
|
|
245
|
-
* ctx.
|
|
245
|
+
* ctx.island("lightbox");
|
|
246
246
|
*/
|
|
247
|
-
|
|
248
|
-
return state.
|
|
247
|
+
island(name) {
|
|
248
|
+
return state.islandApis.get(name);
|
|
249
249
|
}
|
|
250
250
|
};
|
|
251
251
|
}
|
|
@@ -269,7 +269,7 @@ function matchTarget(host, event, selector) {
|
|
|
269
269
|
return matched && host.contains(matched) ? matched : void 0;
|
|
270
270
|
}
|
|
271
271
|
/**
|
|
272
|
-
* Attach a
|
|
272
|
+
* Attach a island's declarative `events` map: one real listener per event TYPE on
|
|
273
273
|
* the host (dispatch walks `closest(selector)` for each registered selector), each
|
|
274
274
|
* removed via the instance's cleanup registry on destroy.
|
|
275
275
|
*
|
|
@@ -286,7 +286,7 @@ function attachEvents(instance, events) {
|
|
|
286
286
|
const space = key.indexOf(" ");
|
|
287
287
|
const type = (space === -1 ? key : key.slice(0, space)).trim();
|
|
288
288
|
const selector = space === -1 ? "" : key.slice(space + 1).trim();
|
|
289
|
-
if (type === "") throw new Error(`${ERROR_PREFIX}
|
|
289
|
+
if (type === "") throw new Error(`${ERROR_PREFIX} island "${instance.def.name}" event key must start with an event type: "${key}"\n → use "<type>" or "<type> <selector>" (e.g. "click [data-action]")`);
|
|
290
290
|
const list = byType.get(type) ?? [];
|
|
291
291
|
list.push({
|
|
292
292
|
selector,
|
|
@@ -360,30 +360,30 @@ function disposeInstance(state, instance) {
|
|
|
360
360
|
} catch {}
|
|
361
361
|
instance.cleanups.length = 0;
|
|
362
362
|
instance.renderScheduled = false;
|
|
363
|
-
if (instance.api !== void 0 && state.
|
|
363
|
+
if (instance.api !== void 0 && state.islandApis.get(instance.def.name) === instance.api) state.islandApis.delete(instance.def.name);
|
|
364
364
|
}
|
|
365
365
|
/**
|
|
366
|
-
* Mounts a single `data-
|
|
366
|
+
* Mounts a single `data-island` element: classifies persistent vs page-specific,
|
|
367
367
|
* builds the instance + its bound context, initializes per-instance `state`, registers
|
|
368
368
|
* its `api`, attaches declarative `events`, fires `onCreate` then `onMount` (capturing
|
|
369
369
|
* an async `onMount` + render-chunk load as `mountPromise`), schedules the initial
|
|
370
|
-
* render, records it, and emits `spa:
|
|
371
|
-
* mounted, has no
|
|
370
|
+
* render, records it, and emits `spa:island-mount`. No-ops if the element is already
|
|
371
|
+
* mounted, has no island name, or names an unregistered island.
|
|
372
372
|
*
|
|
373
|
-
* @param state - The plugin state (
|
|
374
|
-
* @param emit - The event emitter for spa:
|
|
373
|
+
* @param state - The plugin state (registeredIslands + instances + islandApis).
|
|
374
|
+
* @param emit - The event emitter for spa:island-mount.
|
|
375
375
|
* @param swapArea - The swap-region element, or null when none was found.
|
|
376
376
|
* @param data - The current page data payload.
|
|
377
|
-
* @param element - The candidate element carrying a `data-
|
|
377
|
+
* @param element - The candidate element carrying a `data-island` attribute.
|
|
378
378
|
* @param route - The matched-route slice for the current URL (params/meta/locale/url).
|
|
379
379
|
* @example
|
|
380
380
|
* mountElement(state, emit, swapArea, data, element, route);
|
|
381
381
|
*/
|
|
382
382
|
function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE) {
|
|
383
383
|
if (state.instances.has(element)) return;
|
|
384
|
-
const name = element.dataset.
|
|
384
|
+
const name = element.dataset.island;
|
|
385
385
|
if (!name) return;
|
|
386
|
-
const definition = state.
|
|
386
|
+
const definition = state.registeredIslands.get(name);
|
|
387
387
|
if (!definition) return;
|
|
388
388
|
const instance = {
|
|
389
389
|
def: definition,
|
|
@@ -409,7 +409,7 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
|
|
|
409
409
|
if (spec?.state) instance.state = spec.state(instance.ctx);
|
|
410
410
|
if (spec?.api) {
|
|
411
411
|
instance.api = spec.api(instance.ctx);
|
|
412
|
-
state.
|
|
412
|
+
state.islandApis.set(definition.name, instance.api);
|
|
413
413
|
}
|
|
414
414
|
if (spec?.events) attachEvents(instance, spec.events);
|
|
415
415
|
runHook(instance, "onCreate");
|
|
@@ -420,20 +420,20 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
|
|
|
420
420
|
if (onMountResult && typeof onMountResult.then === "function") pending.push(onMountResult);
|
|
421
421
|
instance.mountPromise = pending.length > 0 ? Promise.all(pending).then(() => {}) : void 0;
|
|
422
422
|
state.instances.set(element, instance);
|
|
423
|
-
emit("spa:
|
|
423
|
+
emit("spa:island-mount", {
|
|
424
424
|
name: definition.name,
|
|
425
425
|
el: element
|
|
426
426
|
});
|
|
427
427
|
}
|
|
428
428
|
/**
|
|
429
|
-
* Scans the swap region, mounts
|
|
429
|
+
* Scans the swap region, mounts islands for matching `data-island` elements,
|
|
430
430
|
* classifies persistent (outside swap area) vs page-specific (inside), runs
|
|
431
|
-
* `onCreate`/`onMount` + initial render, and emits `spa:
|
|
431
|
+
* `onCreate`/`onMount` + initial render, and emits `spa:island-mount` per instance.
|
|
432
432
|
* Already-mounted elements are skipped.
|
|
433
433
|
*
|
|
434
|
-
* @param state - The plugin state (
|
|
435
|
-
* @param emit - The event emitter for spa:
|
|
436
|
-
* @param swapSelector - CSS selector bounding page-specific
|
|
434
|
+
* @param state - The plugin state (registeredIslands + instances + islandApis).
|
|
435
|
+
* @param emit - The event emitter for spa:island-mount.
|
|
436
|
+
* @param swapSelector - CSS selector bounding page-specific islands.
|
|
437
437
|
* @param route - The matched-route slice for the current URL (params/meta/locale/url).
|
|
438
438
|
* @example
|
|
439
439
|
* scanAndMount(state, emit, "main > section", route);
|
|
@@ -442,16 +442,16 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
|
|
|
442
442
|
if (typeof document === "undefined") return;
|
|
443
443
|
const swapArea = document.querySelector(swapSelector);
|
|
444
444
|
const data = extractPageData(document);
|
|
445
|
-
for (const element of document.querySelectorAll("[data-
|
|
445
|
+
for (const element of document.querySelectorAll("[data-island]")) mountElement(state, emit, swapArea, data, element, route);
|
|
446
446
|
}
|
|
447
447
|
/**
|
|
448
448
|
* Unmounts page-specific instances inside the swap region (runs `onUnMount` then
|
|
449
449
|
* `onDestroy`, then their cleanup disposers + api unregister), removes them from state,
|
|
450
|
-
* and emits `spa:
|
|
450
|
+
* and emits `spa:island-unmount`. Persistent instances (outside the swap area) are
|
|
451
451
|
* left in place.
|
|
452
452
|
*
|
|
453
453
|
* @param state - The plugin state holding live instances.
|
|
454
|
-
* @param emit - The event emitter for spa:
|
|
454
|
+
* @param emit - The event emitter for spa:island-unmount.
|
|
455
455
|
* @example
|
|
456
456
|
* unmountPageSpecific(state, emit);
|
|
457
457
|
*/
|
|
@@ -464,7 +464,7 @@ function unmountPageSpecific(state, emit) {
|
|
|
464
464
|
runHook(instance, "onDestroy");
|
|
465
465
|
disposeInstance(state, instance);
|
|
466
466
|
state.instances.delete(element);
|
|
467
|
-
emit("spa:
|
|
467
|
+
emit("spa:island-unmount", {
|
|
468
468
|
name: instance.def.name,
|
|
469
469
|
el: element
|
|
470
470
|
});
|
|
@@ -473,11 +473,11 @@ function unmountPageSpecific(state, emit) {
|
|
|
473
473
|
/**
|
|
474
474
|
* Disposes ALL live instances (persistent and page-specific) on teardown: runs
|
|
475
475
|
* `onUnMount` then `onDestroy`, then their cleanup disposers + api unregister, emits
|
|
476
|
-
* `spa:
|
|
476
|
+
* `spa:island-unmount`, and clears the instance + api maps. Used by the kernel's
|
|
477
477
|
* `dispose` on plugin stop.
|
|
478
478
|
*
|
|
479
479
|
* @param state - The plugin state holding live instances.
|
|
480
|
-
* @param emit - The event emitter for spa:
|
|
480
|
+
* @param emit - The event emitter for spa:island-unmount.
|
|
481
481
|
* @example
|
|
482
482
|
* unmountAll(state, emit);
|
|
483
483
|
*/
|
|
@@ -488,13 +488,13 @@ function unmountAll(state, emit) {
|
|
|
488
488
|
runHook(instance, "onUnMount");
|
|
489
489
|
runHook(instance, "onDestroy");
|
|
490
490
|
disposeInstance(state, instance);
|
|
491
|
-
emit("spa:
|
|
491
|
+
emit("spa:island-unmount", {
|
|
492
492
|
name: instance.def.name,
|
|
493
493
|
el: element
|
|
494
494
|
});
|
|
495
495
|
}
|
|
496
496
|
state.instances.clear();
|
|
497
|
-
state.
|
|
497
|
+
state.islandApis.clear();
|
|
498
498
|
}
|
|
499
499
|
/**
|
|
500
500
|
* Fires `onNavStart` on every currently-mounted instance (persistent instances
|
|
@@ -546,9 +546,9 @@ function notifyNavEnd(state, route = EMPTY_ROUTE) {
|
|
|
546
546
|
*/
|
|
547
547
|
function createState(_ctx) {
|
|
548
548
|
return {
|
|
549
|
-
|
|
549
|
+
registeredIslands: /* @__PURE__ */ new Map(),
|
|
550
550
|
instances: /* @__PURE__ */ new Map(),
|
|
551
|
-
|
|
551
|
+
islandApis: /* @__PURE__ */ new Map(),
|
|
552
552
|
currentUrl: "",
|
|
553
553
|
destroyRouter: null,
|
|
554
554
|
started: false,
|
|
@@ -597,7 +597,7 @@ function parseEventSpec(spec) {
|
|
|
597
597
|
* Mount ONE island headlessly through the REAL spa kernel internals under a DOM. The
|
|
598
598
|
* unit + light-integration tier: no `createApp`, no router, no network.
|
|
599
599
|
*
|
|
600
|
-
* @param definition - The
|
|
600
|
+
* @param definition - The island definition under test (from `createIsland`).
|
|
601
601
|
* @param options - Host HTML/element, route slice, page data, persistence, stub apis.
|
|
602
602
|
* @returns A handle exposing the instance's `state`/`api` + event/nav/flush drivers.
|
|
603
603
|
* @example
|
|
@@ -610,10 +610,10 @@ function mountIsland(definition, options = {}) {
|
|
|
610
610
|
global: {},
|
|
611
611
|
config: {}
|
|
612
612
|
});
|
|
613
|
-
state.
|
|
614
|
-
if (options.
|
|
613
|
+
state.registeredIslands.set(definition.name, definition);
|
|
614
|
+
if (options.islands) for (const [name, api] of Object.entries(options.islands)) state.islandApis.set(name, api);
|
|
615
615
|
const host = options.el ?? document.createElement("div");
|
|
616
|
-
host.dataset.
|
|
616
|
+
host.dataset.island = definition.name;
|
|
617
617
|
if (options.html !== void 0) host.innerHTML = options.html;
|
|
618
618
|
const dataScript = options.data ? `<script id="__DATA__" type="application/json">${JSON.stringify(options.data)}<\/script>` : "";
|
|
619
619
|
document.body.innerHTML = `<main><section id="__moku_swap"></section></main>${dataScript}`;
|
|
@@ -811,7 +811,7 @@ function renderIsland(render$1, input) {
|
|
|
811
811
|
set: noopStub,
|
|
812
812
|
flush: noopStub,
|
|
813
813
|
cleanup: noopStub,
|
|
814
|
-
|
|
814
|
+
island: noopStub,
|
|
815
815
|
...input.ctx
|
|
816
816
|
};
|
|
817
817
|
commit(host, render$1(input.state, ctx));
|
package/package.json
CHANGED