@moku-labs/web 1.15.1 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,5 +28,28 @@ function renderVNode(vnode, region) {
28
28
  region.replaceChildren();
29
29
  (0, preact.render)(vnode, region);
30
30
  }
31
+ /**
32
+ * Commit a component's VNode into its host as a DIFF — the in-interaction render
33
+ * path. Unlike {@link renderVNode} (which RESETS the region: unmount → clear → mount,
34
+ * correct for a navigation swap of static SSR markup), this is a plain Preact
35
+ * `render(vnode, host)` that diffs against Preact's retained vdom, preserving focus,
36
+ * scroll, and uncontrolled input state across re-renders triggered by `ctx.set`.
37
+ *
38
+ * Reached ONLY through the lazy `await import("./render")` gate (the component render
39
+ * scheduler in `components.ts`), so an app whose islands never return a VNode never
40
+ * pulls Preact's `render` into its main bundle.
41
+ *
42
+ * @param vnode - The VNode produced by a component's `render(state, ctx)`.
43
+ * @param host - The island's host element to render into.
44
+ * @example
45
+ * ```ts
46
+ * const { commitVNode } = await import("./render");
47
+ * commitVNode(h(BoardView, { snapshot }), host);
48
+ * ```
49
+ */
50
+ function commitVNode(vnode, host) {
51
+ (0, preact.render)(vnode, host);
52
+ }
31
53
  //#endregion
54
+ exports.commitVNode = commitVNode;
32
55
  exports.renderVNode = renderVNode;
@@ -28,5 +28,27 @@ function renderVNode(vnode, region) {
28
28
  region.replaceChildren();
29
29
  render(vnode, region);
30
30
  }
31
+ /**
32
+ * Commit a component's VNode into its host as a DIFF — the in-interaction render
33
+ * path. Unlike {@link renderVNode} (which RESETS the region: unmount → clear → mount,
34
+ * correct for a navigation swap of static SSR markup), this is a plain Preact
35
+ * `render(vnode, host)` that diffs against Preact's retained vdom, preserving focus,
36
+ * scroll, and uncontrolled input state across re-renders triggered by `ctx.set`.
37
+ *
38
+ * Reached ONLY through the lazy `await import("./render")` gate (the component render
39
+ * scheduler in `components.ts`), so an app whose islands never return a VNode never
40
+ * pulls Preact's `render` into its main bundle.
41
+ *
42
+ * @param vnode - The VNode produced by a component's `render(state, ctx)`.
43
+ * @param host - The island's host element to render into.
44
+ * @example
45
+ * ```ts
46
+ * const { commitVNode } = await import("./render");
47
+ * commitVNode(h(BoardView, { snapshot }), host);
48
+ * ```
49
+ */
50
+ function commitVNode(vnode, host) {
51
+ render(vnode, host);
52
+ }
31
53
  //#endregion
32
- export { renderVNode };
54
+ export { commitVNode, renderVNode };
@@ -0,0 +1,383 @@
1
+ //#region src/plugins/spa/types.d.ts
2
+ /**
3
+ * What a component's `render` may return:
4
+ * - a Preact `VNode` — committed into the host through the lazy Preact gate (`commitVNode`);
5
+ * - a `Node` — replaces the host's children;
6
+ * - a `string` — set as the host's `innerHTML`;
7
+ * - `void`/`undefined` — the render mutated the DOM itself (DOM-only islands → no Preact loaded).
8
+ */
9
+ type RenderResult = import("preact").VNode | Node | string | void;
10
+ /**
11
+ * Factory that builds a component's typed per-instance state (mirrors a plugin's
12
+ * `createState`). Called ONCE at mount; the returned object is stored on the
13
+ * {@link ComponentInstance} and exposed read-only as `ctx.state`.
14
+ *
15
+ * @param ctx - The component context for this instance (state is not yet set).
16
+ * @returns The initial per-instance state.
17
+ * @example
18
+ * state: (ctx): BoardState => ({ boardId: ctx.params.id ?? "", cards: [] })
19
+ */
20
+ type ComponentStateFactory<S extends object> = (ctx: ComponentContext<S>) => S;
21
+ /**
22
+ * Pure render of `(state, ctx)` → {@link RenderResult}. Called after mount-state-init
23
+ * and again (microtask-batched) after every `ctx.set`. Must be free of side effects
24
+ * beyond producing its result.
25
+ *
26
+ * @param state - The current per-instance state (read-only).
27
+ * @param ctx - The component context for this instance.
28
+ * @returns The render result to commit into the host.
29
+ * @example
30
+ * render: (state) => h(BoardView, { snapshot: state.snapshot })
31
+ */
32
+ type ComponentRender<S extends object> = (state: Readonly<S>, ctx: ComponentContext<S>) => RenderResult;
33
+ /**
34
+ * A delegated DOM event handler. `target` is the element matched by the key's selector
35
+ * (already resolved via `closest` — no `instanceof`/`closest` ceremony in the body).
36
+ *
37
+ * Typed `void` for ergonomics (the void-return rule accepts async handlers returning
38
+ * `Promise<void>` too); the kernel ignores any returned value.
39
+ *
40
+ * @param ctx - The component context (carries the live per-instance `state`).
41
+ * @param event - The raw DOM event.
42
+ * @param target - The element matched by the selector (the host when no selector).
43
+ * @returns void (a returned promise is ignored by the kernel).
44
+ * @example
45
+ * (ctx, event, button) => { event.preventDefault(); ctx.set({ open: true }); }
46
+ */
47
+ type ComponentEventHandler<S extends object> = (ctx: ComponentContext<S>, event: Event, target: Element) => void;
48
+ /**
49
+ * Declarative delegated event map. Each key is `"<type> <selector>"` (the selector is
50
+ * optional → a host-level listener). ONE real listener per event TYPE is attached to
51
+ * the host; dispatch walks `event.target.closest(selector)` within the host. All
52
+ * listeners are auto-removed on destroy.
53
+ *
54
+ * @example
55
+ * events: {
56
+ * "click [data-action='delete']": (ctx, _e, btn) => ctx.set(removeCard(ctx.state, btn)),
57
+ * "submit [data-add]": (ctx, e) => { e.preventDefault(); add(ctx); }
58
+ * }
59
+ */
60
+ type ComponentEvents<S extends object> = Record<string, ComponentEventHandler<S>>;
61
+ /**
62
+ * Context handed to every component lifecycle hook, render, and event handler — the
63
+ * bound element + page data, plus the matched route's `params`/`meta`/`locale` and a
64
+ * link builder, so an island can read its route context (e.g. a `card` route's
65
+ * `ctx.meta.focus` + `ctx.params.id`) directly, without the page bridging it through
66
+ * `data-*` attributes.
67
+ *
68
+ * Generic over the per-instance state `S` (default `undefined` so every existing
69
+ * hooks-only island still type-checks). The additive members (`state`/`set`/`flush`/
70
+ * `cleanup`/`component`) are ALWAYS-PRESENT functions — never optional keys — so they
71
+ * never trip `exactOptionalPropertyTypes`.
72
+ */
73
+ interface ComponentContext<S = undefined> {
74
+ /** The element the component instance is bound to. */
75
+ el: Element;
76
+ /** Page data extracted from the `script#__DATA__` payload. */
77
+ data: PageData;
78
+ /** Resolved path params of the route matched for the current URL (empty if unmatched). */
79
+ readonly params: Record<string, string | undefined>;
80
+ /** The matched route's `.meta()` bag (empty if unmatched). */
81
+ readonly meta: Record<string, unknown>;
82
+ /** Active locale for the current route (empty string if unknown). */
83
+ readonly locale: string;
84
+ /** Build a link to a named route by pattern substitution (same output as `app.router.toUrl`). */
85
+ readonly url: (name: string, params?: Record<string, string>) => string;
86
+ /** The live per-instance state (the object returned by `spec.state`). `undefined` for legacy hooks-only islands. */
87
+ readonly state: S;
88
+ /**
89
+ * Merge a patch into the per-instance state, then schedule ONE batched render.
90
+ * Accepts a partial object or an updater `(prev) => partial`. A no-op for legacy
91
+ * islands with no `state`/`render`.
92
+ *
93
+ * @param patch - A partial state object, or an updater returning one.
94
+ * @returns void
95
+ * @example
96
+ * ctx.set({ open: true });
97
+ * ctx.set(prev => ({ count: prev.count + 1 }));
98
+ */
99
+ set(patch: Partial<S> | ((prev: Readonly<S>) => Partial<S>)): void;
100
+ /**
101
+ * Force a synchronous render now (drains any pending scheduled render). Rarely
102
+ * needed in app code — `ctx.set` already schedules one; mainly a test seam.
103
+ *
104
+ * @returns void
105
+ * @example
106
+ * ctx.flush();
107
+ */
108
+ flush(): void;
109
+ /**
110
+ * Register a disposer run on `onDestroy` (subscriptions, timers, manual/global
111
+ * listeners the declarative `events` map cannot cover). Disposers run LIFO.
112
+ *
113
+ * @param dispose - The teardown function.
114
+ * @returns void
115
+ * @example
116
+ * ctx.cleanup(onPatch(p => applyPatch(ctx, p)));
117
+ */
118
+ cleanup(dispose: () => void): void;
119
+ /**
120
+ * Resolve another island's registered `api` by name. Returns `undefined` when no
121
+ * provider is registered (optional-dependency semantics, mirroring `ctx.has`).
122
+ *
123
+ * @param name - The provider island's component name.
124
+ * @returns The provider's api, or `undefined`.
125
+ * @example
126
+ * ctx.component<LightboxApi>("lightbox")?.open(slides, index);
127
+ */
128
+ component<T = unknown>(name: string): T | undefined;
129
+ }
130
+ /** Lifecycle hooks a component may implement. Generic over the per-instance state `S`. */
131
+ interface ComponentHooks<S = undefined> {
132
+ /**
133
+ * Called once when the instance is created (before DOM attach).
134
+ *
135
+ * @param ctx - The component context for this instance.
136
+ * @returns void
137
+ * @example
138
+ * onCreate({ el }) { el.dataset.ready = "1"; }
139
+ */
140
+ onCreate?(ctx: ComponentContext<S>): void;
141
+ /**
142
+ * Called after the instance is attached to its element.
143
+ *
144
+ * @param ctx - The component context for this instance.
145
+ * @returns void
146
+ * @example
147
+ * onMount({ el }) { el.textContent = "0"; }
148
+ * @example
149
+ * async onMount(ctx) { ctx.set({ items: await load() }); } // async is allowed; the harness awaits it via settle()
150
+ */
151
+ onMount?(ctx: ComponentContext<S>): void;
152
+ /**
153
+ * Called when a navigation begins while this instance is mounted.
154
+ *
155
+ * @param ctx - The component context for this instance.
156
+ * @returns void
157
+ * @example
158
+ * onNavStart({ el }) { el.dataset.loading = ""; }
159
+ */
160
+ onNavStart?(ctx: ComponentContext<S>): void;
161
+ /**
162
+ * Called when a navigation completes while this instance is mounted.
163
+ *
164
+ * @param ctx - The component context for this instance.
165
+ * @returns void
166
+ * @example
167
+ * onNavEnd({ el }) { delete el.dataset.loading; }
168
+ */
169
+ onNavEnd?(ctx: ComponentContext<S>): void;
170
+ /**
171
+ * Called before the instance is detached from its element.
172
+ *
173
+ * @param ctx - The component context for this instance.
174
+ * @returns void
175
+ * @example
176
+ * onUnMount({ el }) { el.replaceChildren(); }
177
+ */
178
+ onUnMount?(ctx: ComponentContext<S>): void;
179
+ /**
180
+ * Called once when the instance is destroyed (after detach).
181
+ *
182
+ * @param ctx - The component context for this instance.
183
+ * @returns void
184
+ * @example
185
+ * onDestroy({ el }) { delete el.dataset.ready; }
186
+ */
187
+ onDestroy?(ctx: ComponentContext<S>): void;
188
+ }
189
+ /**
190
+ * The spec extras carried on a {@link ComponentDef}, type-erased to `object` state
191
+ * (authors keep full `S` inference at the `createComponent` call site; the registry
192
+ * stores the runtime-only erased form). Absent for legacy `(name, hooks)` defs.
193
+ */
194
+ interface ComponentSpecExtras {
195
+ /** Per-instance state factory. */
196
+ state?: ComponentStateFactory<object>;
197
+ /** Render called on mount + after every `ctx.set`. */
198
+ render?: ComponentRender<object>;
199
+ /** Declarative delegated events. */
200
+ events?: ComponentEvents<object>;
201
+ /** Public api factory registered under the component name. */
202
+ api?: (ctx: ComponentContext<object>) => unknown;
203
+ }
204
+ /** A registered component definition (an opaque token; author inference lives on `createComponent`). */
205
+ interface ComponentDef {
206
+ /** Unique component name (matched against `data-component`). */
207
+ name: string;
208
+ /** Lifecycle hooks (the subset shared with the legacy form). */
209
+ hooks: ComponentHooks<object>;
210
+ /** Plugin-mirror extras (state/render/events/api). Absent for legacy `(name, hooks)` defs. */
211
+ spec?: ComponentSpecExtras;
212
+ }
213
+ /** The matched-route slice carried on a live instance (params/meta/locale + link builder). */
214
+ type ComponentRouteSlice = Pick<ComponentContext, "params" | "meta" | "locale" | "url">;
215
+ /** Page data payload parsed from the inline `script#__DATA__` element. */
216
+ type PageData = Record<string, unknown>;
217
+ //#endregion
218
+ //#region src/plugins/spa/components.d.ts
219
+ /** The matched-route slice merged onto the component context (params/meta/locale + link builder). */
220
+ type RouteSlice = ComponentRouteSlice;
221
+ //#endregion
222
+ //#region src/testing.d.ts
223
+ /** One captured spa emit (the kernel's `spa:component-mount` / `-unmount`). */
224
+ interface CapturedEmit {
225
+ /** The event name. */
226
+ readonly event: string;
227
+ /** The event payload. */
228
+ readonly payload: unknown;
229
+ }
230
+ /** A mounted island a unit/integration test drives without booting `createApp`. */
231
+ interface IslandHandle<S extends object = object, A = unknown> {
232
+ /** The host element the island bound to (already in `document.body`). */
233
+ readonly el: HTMLElement;
234
+ /** Live per-instance state (typed). `undefined` for legacy hooks-only islands. */
235
+ readonly state: S | undefined;
236
+ /** The island's registered api (typed), if it declared `api`. */
237
+ readonly api: A | undefined;
238
+ /**
239
+ * Dispatch a delegated event by spec: `fire("click [data-action='delete']")` clicks
240
+ * the first matching element inside the host (or the host itself when no selector).
241
+ *
242
+ * @param spec - `"<type>"` or `"<type> <selector>"` (same grammar as the `events` map).
243
+ * @param init - Optional event init (bubbles/cancelable are defaulted true).
244
+ * @example
245
+ * handle.fire("submit [data-create]");
246
+ */
247
+ fire(spec: string, init?: EventInit): void;
248
+ /**
249
+ * Dispatch a RAW, pre-built event at a selector — full control for events the
250
+ * synthetic `fire` cannot build (DragEvent/dataTransfer/clientY).
251
+ *
252
+ * @param selector - The element to dispatch on (the host when no match).
253
+ * @param event - The pre-constructed event.
254
+ * @example
255
+ * handle.dispatch('[data-cards]', Object.assign(new Event("drop", { bubbles: true }), { dataTransfer }));
256
+ */
257
+ dispatch(selector: string, event: Event): void;
258
+ /**
259
+ * Synchronously drain any pending render now (mutate → flush → assert, no `await`).
260
+ * For VNode-returning islands call {@link IslandHandle.settle} first so the lazy
261
+ * Preact render chunk is loaded.
262
+ *
263
+ * @example
264
+ * handle.flush();
265
+ */
266
+ flush(): void;
267
+ /**
268
+ * Await `onMount`'s returned promise + the render-chunk load + a microtask, then
269
+ * flush — the deterministic settle for async mounts and VNode renders.
270
+ *
271
+ * @returns A promise resolving once the island is fully mounted and rendered.
272
+ * @example
273
+ * await handle.settle();
274
+ */
275
+ settle(): Promise<void>;
276
+ /**
277
+ * Fire `onNavStart` on the instance (persistent + page-specific receive it).
278
+ *
279
+ * @example
280
+ * handle.navStart();
281
+ */
282
+ navStart(): void;
283
+ /**
284
+ * Fire `onNavEnd` on a persistent instance, with an optional destination route slice.
285
+ *
286
+ * @param route - Partial route slice to merge onto the current one.
287
+ * @example
288
+ * handle.navEnd({ params: { id: "b2" } });
289
+ */
290
+ navEnd(route?: Partial<RouteSlice>): void;
291
+ /**
292
+ * Run `onUnMount` + `onDestroy` (asserts auto-teardown of `events` + `ctx.cleanup`).
293
+ *
294
+ * @example
295
+ * handle.unmount();
296
+ */
297
+ unmount(): void;
298
+ /** Captured `spa:component-mount` / `-unmount` emits, in order. */
299
+ readonly emitted: ReadonlyArray<CapturedEmit>;
300
+ }
301
+ /** Options for {@link mountIsland}. All optional; sensible headless defaults. */
302
+ interface MountIslandOptions {
303
+ /** Inner HTML placed INSIDE the host before mount (the SSR markup the island enhances). */
304
+ html?: string;
305
+ /** Mount into THIS element instead of creating one (sandbox: a real page host). */
306
+ el?: HTMLElement;
307
+ /** Route params (→ `ctx.params`). */
308
+ params?: Record<string, string>;
309
+ /** Route meta (→ `ctx.meta`). */
310
+ meta?: Record<string, unknown>;
311
+ /** Page data (→ `ctx.data`; serialized into `#__DATA__` so `extractPageData` sees it). */
312
+ data?: PageData;
313
+ /** Route locale (→ `ctx.locale`). */
314
+ locale?: string;
315
+ /** Link builder (→ `ctx.url`); defaults to `/<name>`. */
316
+ url?: (name: string, params?: Record<string, string>) => string;
317
+ /** Mount OUTSIDE the swap area so the instance is persistent (gets `onNavEnd`). */
318
+ persistent?: boolean;
319
+ /** Stubbed sibling-island apis resolved by `ctx.component(name)`. */
320
+ components?: Record<string, unknown>;
321
+ }
322
+ /**
323
+ * Mount ONE island headlessly through the REAL spa kernel internals under a DOM. The
324
+ * unit + light-integration tier: no `createApp`, no router, no network.
325
+ *
326
+ * @param definition - The component definition under test (from `createComponent`).
327
+ * @param options - Host HTML/element, route slice, page data, persistence, stub apis.
328
+ * @returns A handle exposing the instance's `state`/`api` + event/nav/flush drivers.
329
+ * @example
330
+ * const h = mountIsland(tabNav, { html: "<a></a><a></a><a></a>", persistent: true });
331
+ * h.navEnd({ locale: "en" });
332
+ * expect(h.el.querySelector("[aria-current]")).toBeTruthy();
333
+ */
334
+ declare function mountIsland<S extends object = object, A = unknown>(definition: ComponentDef, options?: MountIslandOptions): IslandHandle<S, A>;
335
+ /** The result of {@link renderIsland} — a host plus query/flush/teardown helpers. */
336
+ interface RenderIslandResult {
337
+ /** The host element the view rendered into. */
338
+ readonly host: HTMLElement;
339
+ /**
340
+ * The host's current `innerHTML`.
341
+ *
342
+ * @returns The serialized markup.
343
+ * @example
344
+ * expect(result.html()).toContain("Alpha");
345
+ */
346
+ html(): string;
347
+ /**
348
+ * Query the host for the first element matching a selector.
349
+ *
350
+ * @param selector - A CSS selector.
351
+ * @returns The first match, or `null`.
352
+ * @example
353
+ * result.find("[data-board]");
354
+ */
355
+ find<E extends Element = Element>(selector: string): E | null;
356
+ /**
357
+ * Unmount the Preact tree and remove the host from the document.
358
+ *
359
+ * @example
360
+ * result.unmount();
361
+ */
362
+ unmount(): void;
363
+ }
364
+ /**
365
+ * The cheapest unit tier: render a controller/view island's pure `render(state, ctx)`
366
+ * against fixture state, with no kernel and no `mountIsland`. Uses `preact/test-utils`
367
+ * `act` (which ships WITH Preact — no new dependency) so effects flush deterministically.
368
+ *
369
+ * @param render - The island's `render` function (e.g. `boardList.spec.render`).
370
+ * @param input - Fixture inputs.
371
+ * @param input.state - The fixture per-instance state to render.
372
+ * @param input.ctx - Optional partial context overrides.
373
+ * @returns A {@link RenderIslandResult} for asserting the rendered DOM.
374
+ * @example
375
+ * const r = renderIsland(render, { state: { boards: [{ id: "1", title: "Alpha" }] } });
376
+ * expect(r.find("[data-board]")).toBeTruthy();
377
+ */
378
+ declare function renderIsland<S extends object>(render: ComponentRender<S>, input: {
379
+ state: S;
380
+ ctx?: Partial<ComponentContext<S>>;
381
+ }): RenderIslandResult;
382
+ //#endregion
383
+ export { CapturedEmit, IslandHandle, MountIslandOptions, RenderIslandResult, mountIsland, renderIsland };