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