@opentf/web 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentf/web",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "The native-first OTF Web runtime — signal-based reactivity and zero-VDOM DOM operations, paired with the IR-based compiler.",
5
5
  "type": "module",
6
6
  "exports": {
package/runtime/dom.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // no diffing — just direct, fine-grained DOM writes wired to signals.
4
4
 
5
5
  import { effect, signal } from "../core/signals.js";
6
+ import { claimRegionEnd, claimRegionStart } from "./hydrate.js";
6
7
 
7
8
  // Attribute names that must be assigned as JS properties (not setAttribute) for
8
9
  // correct behavior. Mirrors the set the old runtime relied on (core/constants).
@@ -253,9 +254,40 @@ function toNodes(value) {
253
254
  * Returns the effect disposer.
254
255
  */
255
256
  export function bindChild(anchor, fn) {
256
- let current = [];
257
+ return childEffect(anchor, fn, [], false);
258
+ }
259
+
260
+ /**
261
+ * Hydrate a server-rendered conditional / dynamic-node region (docs/HYDRATION.md §3.1,
262
+ * 2.1b). The cursor `cur` is at the `<!--[-->` marker; between it and `<!--]-->` sits the
263
+ * one branch the server rendered (or nothing). `adoptFn(cur)` evaluates the same branch
264
+ * expression with *adopt* calls — only the taken branch's builder runs, claiming its nodes
265
+ * off the shared cursor — so the adopted nodes become the region's initial content with no
266
+ * rebuild. `buildFn()` is the CSR version (build calls) the effect swaps to when a later
267
+ * reactive change selects a different branch. The closing `<!--]-->` is the swap anchor.
268
+ * Returns the effect disposer.
269
+ */
270
+ export function hydrateChild(cur, adoptFn, buildFn) {
271
+ claimRegionStart(cur);
272
+ const adopted = toNodes(adoptFn(cur));
273
+ const anchor = claimRegionEnd(cur);
274
+ // Seed with the adopted nodes and skip the first effect run's DOM write: the branch is
275
+ // already in place. The first run still evaluates `buildFn` to subscribe to its reactive
276
+ // deps (the built nodes are discarded); from the second run on it swaps normally.
277
+ return childEffect(anchor, buildFn, adopted, true);
278
+ }
279
+
280
+ /** The child-region effect shared by {@link bindChild} (empty seed, no skip) and
281
+ * {@link hydrateChild} (seeded with adopted nodes, first DOM write skipped). On each run
282
+ * the previous nodes are replaced with the new ones, inserted before `anchor`. */
283
+ function childEffect(anchor, fn, current, skipFirst) {
284
+ let first = skipFirst;
257
285
  return effect(() => {
258
286
  const next = toNodes(fn());
287
+ if (first) {
288
+ first = false;
289
+ return; // hydration: keep the adopted DOM on first paint (subscribe only)
290
+ }
259
291
  const host = anchor.parentNode;
260
292
  for (const n of current) {
261
293
  if (n.parentNode === host) host.removeChild(n);
@@ -279,9 +311,45 @@ export function bindChild(anchor, fn) {
279
311
  export function bindList(parent, sourceFn, renderItem, keyFn) {
280
312
  const anchor = document.createComment("");
281
313
  parent.appendChild(anchor);
282
- let cache = new Map(); // key -> { sig, node }
283
- let prevKeys = []; // keys in current DOM order, for minimal-move reconciliation
314
+ return reconcileList(anchor, sourceFn, renderItem, keyFn, new Map(), []);
315
+ }
284
316
 
317
+ /**
318
+ * Hydrate a server-rendered list region (docs/HYDRATION.md §3.1/2.1). The cursor `cur` is
319
+ * positioned at the `<!--[-->` marker; the region holds one root node per item, then
320
+ * `<!--]-->`. Adopts each item's server node via `adoptItem(cur, itemSignal, index)`
321
+ * (claiming off the shared cursor, which advances to the next item), seeds the reconcile
322
+ * cache with the adopted `{sig, node}` pairs, then wires the *same* keyed-reconcile effect
323
+ * `bindList` uses — so a later data change builds/moves/removes with no first-paint flash.
324
+ * The closing `<!--]-->` becomes the reconcile anchor. Returns the effect disposer.
325
+ */
326
+ export function hydrateList(cur, sourceFn, adoptItem, renderItem, keyFn) {
327
+ claimRegionStart(cur);
328
+ const data = sourceFn();
329
+ const items = Array.isArray(data) ? data : [];
330
+ const cache = new Map();
331
+ const prevKeys = [];
332
+ for (let index = 0; index < items.length; index++) {
333
+ const item = items[index];
334
+ const key = keyFn ? keyFn(item, index) : index;
335
+ const sig = signal(item);
336
+ const node = adoptItem(cur, sig, index); // claims one item subtree off `cur`
337
+ cache.set(key, { sig, node });
338
+ prevKeys.push(key);
339
+ }
340
+ const anchor = claimRegionEnd(cur);
341
+ // The seeded effect's first run re-reads the same data: every key hits the cache, so it
342
+ // subscribes for future updates without mutating the already-correct adopted DOM.
343
+ return reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys);
344
+ }
345
+
346
+ /**
347
+ * The keyed-reconcile effect shared by {@link bindList} (empty seed) and
348
+ * {@link hydrateList} (seeded from adopted nodes). `anchor` is the trailing comment new
349
+ * items are inserted before; `cache` (key → `{sig, node}`) and `prevKeys` (keys in DOM
350
+ * order) carry reconciliation state across runs. Returns the effect disposer.
351
+ */
352
+ function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
285
353
  return effect(() => {
286
354
  const data = sourceFn();
287
355
  const items = Array.isArray(data) ? data : [];
@@ -327,7 +395,8 @@ export function bindList(parent, sourceFn, renderItem, keyFn) {
327
395
  // cascade into. Walk back to front so each insertion's reference node is
328
396
  // already in its final position.
329
397
  const keep = longestIncreasingRun(prevIndex);
330
- const host = anchor.parentNode || parent;
398
+ const host = anchor.parentNode;
399
+ if (!host) return; // anchor detached (list torn down) — nothing to place
331
400
  for (let i = nodes.length - 1; i >= 0; i--) {
332
401
  if (keep.has(i)) continue;
333
402
  const ref = i + 1 < nodes.length ? nodes[i + 1] : anchor;
@@ -19,6 +19,21 @@
19
19
  export const HOLE_START = "$";
20
20
  export const HOLE_END = "/";
21
21
 
22
+ /** Comment markers bounding a variable structural region — a list or a conditional —
23
+ * in server HTML (docs/HYDRATION.md §3.1): `<!--[-->` opens, `<!--]-->` closes. A list
24
+ * holds one item root node per item; a conditional holds the currently rendered branch
25
+ * (or nothing). The closing marker becomes the reconcile/swap anchor on hydration. */
26
+ export const REGION_START = "[";
27
+ export const REGION_END = "]";
28
+
29
+ /** Markers bounding a *component's* light-DOM `{children}` slot (docs/HYDRATION.md §3.1,
30
+ * 2.1d) — distinct bytes from the list/conditional region markers so a component's own
31
+ * internal regions never collide with its slot. The slot's content is the *parent's* JSX
32
+ * (server-rendered here), so the parent finds this slot by its `<!--c[-->` marker within the
33
+ * host and adopts the children (its reactivity), while the component steps over the slot. */
34
+ export const SLOT_START = "c[";
35
+ export const SLOT_END = "c]";
36
+
22
37
  const ELEMENT = 1;
23
38
  const TEXT = 3;
24
39
  const COMMENT = 8;
@@ -37,6 +52,12 @@ export class HydrationMismatch extends Error {
37
52
  // True during the initial hydration pass. A component's connectedCallback reads it
38
53
  // to decide whether to *adopt* its server children or *build* a fresh subtree — the
39
54
  // custom-element-as-island coordination (docs/HYDRATION.md §3.4).
55
+ //
56
+ // Why a flag and not the per-instance `this.firstChild` test: a server-rendered host
57
+ // and a client-`createElement`'d host that was handed call-site children both have a
58
+ // `firstChild`, so the structural test alone can't tell "adopt the server DOM" from
59
+ // "build and slot these children" (it mis-adopts on plain SPA navigation). The flag is
60
+ // the unambiguous signal — it is only ever set during the one-shot first-paint pass.
40
61
  let _hydrating = false;
41
62
 
42
63
  /** Is the client mid-hydration right now? */
@@ -44,7 +65,9 @@ export function isHydrating() {
44
65
  return _hydrating;
45
66
  }
46
67
 
47
- /** Run `fn` with the hydration flag set, restoring the prior value (nesting-safe). */
68
+ /** Run `fn` with the hydration flag set, restoring the prior value (nesting-safe).
69
+ * Synchronous only — for the async first-paint pass use {@link beginHydration} /
70
+ * {@link endHydration}, which span the route module's `import()`. */
48
71
  export function runHydration(fn) {
49
72
  const prev = _hydrating;
50
73
  _hydrating = true;
@@ -55,6 +78,65 @@ export function runHydration(fn) {
55
78
  }
56
79
  }
57
80
 
81
+ // Route modules are code-split (`() => import(...)`), so a route's custom elements are
82
+ // defined — and its server-rendered hosts upgrade synchronously — *during* the router's
83
+ // `await import()`, before the page `hydrate` factory runs. The flag therefore has to be
84
+ // set across that whole async region, which a synchronous `runHydration` can't do; the
85
+ // router brackets first paint with these instead. First-paint hydration is a single,
86
+ // sequential boot step (no concurrent navigation), so a plain module-global set is safe.
87
+
88
+ /** Begin the first-paint hydration pass; pair with {@link endHydration}. Set *before*
89
+ * the route module is imported so every server host upgrading at `customElements.define`
90
+ * observes it and adopts (docs/HYDRATION.md §3.4). */
91
+ export function beginHydration() {
92
+ _hydrating = true;
93
+ }
94
+
95
+ /** End the first-paint hydration pass — subsequent client navigations build fresh. */
96
+ export function endHydration() {
97
+ _hydrating = false;
98
+ }
99
+
100
+ // ── the serialized props payload (compiler-driven data hydration) ─────────────
101
+ // SSG/SSR embeds each island's rich props in a `<script type="application/json"
102
+ // id="__otfw_h">`, keyed by the host's `data-h` id (see server/ssg-runtime.js). A
103
+ // hydrating component reads its props from here — real JS values (objects, arrays,
104
+ // numbers), not lossy string attributes — so it resumes with correct data, with no flash
105
+ // and no dependence on a parent walk re-applying props. Read once and cached.
106
+ let _payload; // undefined = not yet read; null = none present; else the parsed array
107
+
108
+ function hydrationPayload() {
109
+ if (_payload === undefined) {
110
+ const el = typeof document !== "undefined" ? document.getElementById("__otfw_h") : null;
111
+ try {
112
+ _payload = el ? JSON.parse(el.textContent || "[]") : null;
113
+ } catch {
114
+ _payload = null;
115
+ }
116
+ }
117
+ return _payload;
118
+ }
119
+
120
+ /**
121
+ * The rich hydration props recorded for host `el` (keyed by its `data-h` id), or `null`
122
+ * when there is no payload for it — a client-`createElement`'d element on SPA navigation,
123
+ * or a plain CSR build. A hydrate-target component constructor reads this to initialize
124
+ * its prop signals, falling back to attributes/defaults when it returns `null`.
125
+ */
126
+ export function hydrationProps(el) {
127
+ const data = hydrationPayload();
128
+ if (!data || !el || typeof el.getAttribute !== "function") return null;
129
+ const id = el.getAttribute("data-h");
130
+ if (id == null) return null;
131
+ const entry = data[+id];
132
+ return entry == null ? null : entry;
133
+ }
134
+
135
+ /** Reset the cached payload (tests only — a fresh document between cases). */
136
+ export function __resetHydrationPayload() {
137
+ _payload = undefined;
138
+ }
139
+
58
140
  // ── the cursor ────────────────────────────────────────────────────────────────
59
141
 
60
142
  /** A walker over `parent`'s children, positioned at the next node to claim. */
@@ -63,10 +145,16 @@ export function cursor(parent) {
63
145
  }
64
146
 
65
147
  /** Advance past the next node without claiming it (a static text node between
66
- * dynamic siblings), returning it. */
148
+ * dynamic siblings), returning it. The template only emits a skip for a static
149
+ * `ViewNode::Text`, which the server renders as a real text node; assert that so a
150
+ * cursor misalignment surfaces *here* (with a clear message) instead of downstream at
151
+ * an unrelated claim. Throws {@link HydrationMismatch} on a wrong/absent node. */
67
152
  export function skipNode(cur) {
68
153
  const n = cur.node;
69
- cur.node = n ? n.nextSibling : null;
154
+ if (!n || n.nodeType !== TEXT) {
155
+ throw new HydrationMismatch(`expected static text, found ${describe(n)}`);
156
+ }
157
+ cur.node = n.nextSibling;
70
158
  return n;
71
159
  }
72
160
 
@@ -111,6 +199,65 @@ export function claimText(cur) {
111
199
  return textNode;
112
200
  }
113
201
 
202
+ /** Claim the `<!--[-->` marker that opens a server-rendered region (list or conditional),
203
+ * advancing the cursor past it. Throws {@link HydrationMismatch} on a wrong/absent node. */
204
+ export function claimRegionStart(cur) {
205
+ const n = cur.node;
206
+ if (!n || n.nodeType !== COMMENT || n.data !== REGION_START) {
207
+ throw new HydrationMismatch(`expected a region start marker, found ${describe(n)}`);
208
+ }
209
+ cur.node = n.nextSibling;
210
+ return n;
211
+ }
212
+
213
+ /** Claim the `<!--]-->` marker that closes a region and return it — it becomes the
214
+ * reconcile/swap anchor (`hydrateList`/`hydrateChild` insert later nodes before it).
215
+ * Advances the cursor past it. Throws {@link HydrationMismatch} on a wrong/absent node. */
216
+ export function claimRegionEnd(cur) {
217
+ const n = cur.node;
218
+ if (!n || n.nodeType !== COMMENT || n.data !== REGION_END) {
219
+ throw new HydrationMismatch(`expected a region end marker, found ${describe(n)}`);
220
+ }
221
+ cur.node = n.nextSibling;
222
+ return n;
223
+ }
224
+
225
+ /** Step a component's own adopt walk past its `{children}` slot — claim the
226
+ * `<!--c[-->`…`<!--c]-->` markers and skip the slotted nodes (the parent owns their
227
+ * reactivity, wired separately by {@link hydrateSlot}), leaving them in place. Advances the
228
+ * cursor past the closing marker. Throws {@link HydrationMismatch} on a missing start marker. */
229
+ export function skipSlot(cur) {
230
+ const start = cur.node;
231
+ if (!start || start.nodeType !== COMMENT || start.data !== SLOT_START) {
232
+ throw new HydrationMismatch(`expected a children-slot marker, found ${describe(start)}`);
233
+ }
234
+ let n = start.nextSibling;
235
+ while (n && !(n.nodeType === COMMENT && n.data === SLOT_END)) n = n.nextSibling;
236
+ cur.node = n ? n.nextSibling : null; // step past the `<!--c]-->` end marker
237
+ }
238
+
239
+ /**
240
+ * Adopt a component's slotted children — the *parent's* JSX, server-rendered inside the
241
+ * component host at its `{children}` slot. The parent owns their reactivity but the
242
+ * component decides where they sit, so the parent locates the slot by its `<!--c[-->` marker
243
+ * within `host` and adopts from the first slotted node via `adoptFn(cursor)`. Independent of
244
+ * when the component upgrades: the markers are static server DOM and adoption only *wires*
245
+ * (never moves) nodes, so it commutes with the component's own `skipSlot`. A missing marker
246
+ * (a rebuilt component, or no children) is a no-op.
247
+ */
248
+ export function hydrateSlot(host, adoptFn) {
249
+ const start = findComment(host, SLOT_START);
250
+ if (start) adoptFn({ node: start.nextSibling });
251
+ }
252
+
253
+ /** The first descendant comment of `root` whose data is `data` (tree order). */
254
+ function findComment(root, data) {
255
+ const walker = document.createTreeWalker(root, 128 /* NodeFilter.SHOW_COMMENT */);
256
+ let n;
257
+ while ((n = walker.nextNode())) if (n.data === data) return n;
258
+ return null;
259
+ }
260
+
114
261
  /** A short human description of a node for mismatch messages. */
115
262
  function describe(node) {
116
263
  if (!node) return "nothing";
package/runtime/router.js CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { clearError, reportError } from "../core/errors.js";
17
17
  import { signal } from "../core/signals.js";
18
+ import { beginHydration, cursor, endHydration } from "./hydrate.js";
18
19
  import { runCleanup, runMount } from "./mount.js";
19
20
 
20
21
  const isBrowser = typeof window !== "undefined";
@@ -200,6 +201,55 @@ export async function buildRouteNode(match, query = {}) {
200
201
  return { node, nodes };
201
202
  }
202
203
 
204
+ /**
205
+ * Adopt a matched route's server-rendered DOM (docs/HYDRATION.md §3.4, 2.1c) — the hydrate
206
+ * analogue of {@link buildRouteNode}. One cursor threads the whole layout chain: the
207
+ * outermost layout adopts at the container, and each layout hands that cursor to the next at
208
+ * its `{children}` slot (`props.children` is a thunk that claims the nested route's subtree
209
+ * and advances the cursor), down to the page. Returns `{ nodes }` (page → … → root) for
210
+ * lifecycle, or `null` if the page or any layout isn't adoptable (no `hydrateAt` export) — the
211
+ * caller then falls back to a clean CSR build. A thrown `HydrationMismatch` propagates up,
212
+ * disposing each layer's partial wiring on the way (each `hydrateAt` has its own guard).
213
+ */
214
+ export async function hydrateRouteNode(match, query, rootEl) {
215
+ const props = { params: match.params, query };
216
+ const pageMod = await resolveModule(match.entry);
217
+ if (!pageMod || typeof pageMod.hydrateAt !== "function") return null;
218
+ const chain = layoutChain(match.route);
219
+ const layoutMods = [];
220
+ for (const entry of chain) {
221
+ const m = await resolveModule(entry);
222
+ if (!m || typeof m.hydrateAt !== "function") return null; // whole chain must be adoptable
223
+ layoutMods.push(m);
224
+ }
225
+
226
+ // Compose the adopt thunks innermost-first: the page, then each layout wrapping it. Each
227
+ // thunk pushes its claimed root node, so `nodes` ends up page → … → outermost (the inner
228
+ // thunk runs — and pushes — during the outer layout's walk, before the outer pushes).
229
+ const nodes = [];
230
+ let thunk = (c) => {
231
+ const n = pageMod.hydrateAt(c, props);
232
+ nodes.push(n);
233
+ return n;
234
+ };
235
+ for (let i = chain.length - 1; i >= 0; i--) {
236
+ const layout = layoutMods[i];
237
+ const inner = thunk;
238
+ thunk = (c) => {
239
+ const n = layout.hydrateAt(c, { ...props, children: inner });
240
+ nodes.push(n);
241
+ return n;
242
+ };
243
+ }
244
+ thunk(cursor(rootEl)); // outermost adopts at the container; the chain threads inward
245
+ return { nodes };
246
+ }
247
+
248
+ /** Resolve a route entry (lazy loader or module namespace) to its module. */
249
+ async function resolveModule(entry) {
250
+ return typeof entry === "function" ? await entry() : entry;
251
+ }
252
+
203
253
  /**
204
254
  * Set the reactive route state directly (no history/render). Used by server render
205
255
  * so a page reading `router.pathname`/`params`/`query` resolves to the route being
@@ -284,24 +334,34 @@ export async function navigate(path, replace = false, isPop = false, hydrate = f
284
334
  }
285
335
 
286
336
  // First paint over server-rendered DOM: *adopt* it (hydrate) instead of rebuilding,
287
- // when the route module exposes a `hydrate` adopt factory (compiled with the hydrate
288
- // target). Only leaf routes (no layout chain) hydrate so far — `{children}`-slot
289
- // adoption is a later phase so anything else falls through to a clean CSR build. A
337
+ // when the route module exposes a `hydrateAt` adopt factory (compiled with the hydrate
338
+ // target). `hydrateRouteNode` threads one cursor through the whole layout chain (2.1c);
339
+ // a route whose page or any layout isn't adoptable returns null clean CSR build. A
290
340
  // hydration mismatch is reported (never silent) and also falls through to a rebuild.
341
+ //
342
+ // The hydration flag must be live *before* the route modules are imported: route chunks
343
+ // are code-split, so `customElements.define` — and the synchronous upgrade of every
344
+ // server-rendered `<web-*>` host — happens during those imports, before the factories
345
+ // run. Those upgrading components read `isHydrating()` to adopt their server DOM rather
346
+ // than build (docs/HYDRATION.md §3.4). `endHydration()` in `finally` makes every
347
+ // subsequent client navigation build fresh; the CSR fallback below then runs with the
348
+ // flag cleared, so a rebuilt host builds instead of trying to re-adopt.
291
349
  if (hydrate && match) {
350
+ beginHydration();
292
351
  try {
293
- const mod = typeof match.entry === "function" ? await match.entry() : match.entry;
294
- if (mod && typeof mod.hydrate === "function" && layoutChain(match.route).length === 0) {
295
- const query = Object.fromEntries(url.searchParams);
296
- const node = mod.hydrate(rootEl, { params: match.params, query });
297
- currentNodes = [node];
298
- runMount(node);
352
+ const query = Object.fromEntries(url.searchParams);
353
+ const adopted = await hydrateRouteNode(match, query, rootEl);
354
+ if (adopted) {
355
+ currentNodes = adopted.nodes;
356
+ for (const n of adopted.nodes) runMount(n); // onMount for page + every layout
299
357
  clearError({ phase: "route" });
300
358
  return;
301
359
  }
302
360
  } catch (e) {
303
361
  reportError(e, { phase: "hydrate", path: url.pathname });
304
362
  // fall through to a clean CSR build below
363
+ } finally {
364
+ endHydration();
305
365
  }
306
366
  }
307
367
 
package/server/render.js CHANGED
@@ -15,14 +15,16 @@ import {
15
15
  setRouteState,
16
16
  } from "../runtime/router.js";
17
17
  import { resolveMetadata } from "./head.js";
18
+ import { beginHydrationCollect, endHydrationCollect } from "./ssg-runtime.js";
18
19
 
19
20
  /**
20
- * Render `pathname` to `{ html, metadata, status }`: the markup for inside `#app`,
21
- * the resolved SEO metadata (for the `<head>`), and an HTTP `status` (200 when the
22
- * path matched a real route, 404 when it fell back to the registered 404 page — the
23
- * SSR server uses it; SSG ignores it). `params` (from `getStaticPaths`) override the
24
- * matched route's params for dynamic routes. Returns `null` if there's no match and
25
- * no 404 page.
21
+ * Render `pathname` to `{ html, metadata, status, hydration }`: the markup for inside
22
+ * `#app`, the resolved SEO metadata (for the `<head>`), an HTTP `status` (200 when the
23
+ * path matched a real route, 404 when it fell back to the registered 404 page — the SSR
24
+ * server uses it; SSG ignores it), and `hydration` the JSON island-props payload the
25
+ * toolchain embeds so the client resumes from rich data (`""` when nothing needs it).
26
+ * `params` (from `getStaticPaths`) override the matched route's params for dynamic
27
+ * routes. Returns `null` if there's no match and no 404 page.
26
28
  */
27
29
  export async function renderRoute(pathname, params = null, search = "") {
28
30
  const real = matchRoute(pathname);
@@ -36,6 +38,10 @@ export async function renderRoute(pathname, params = null, search = "") {
36
38
 
37
39
  const query = Object.fromEntries(new URLSearchParams(search));
38
40
  const props = { params: match.params, query };
41
+
42
+ // Collect each island's rich props while the tree renders (ssgComponent records them
43
+ // and stamps `data-h` ids), then serialize the payload for the shell.
44
+ beginHydrationCollect();
39
45
  let html = (await resolveFactory(match.entry))(props);
40
46
 
41
47
  // Wrap with layouts, most-specific inward to root outermost.
@@ -44,6 +50,7 @@ export async function renderRoute(pathname, params = null, search = "") {
44
50
  const layout = await resolveFactory(chain[i]);
45
51
  html = layout({ ...props, children: html });
46
52
  }
53
+ const hydration = endHydrationCollect();
47
54
 
48
55
  const metadata = await resolveMetadata({
49
56
  route: match.route,
@@ -51,7 +58,7 @@ export async function renderRoute(pathname, params = null, search = "") {
51
58
  params: match.params,
52
59
  query,
53
60
  });
54
- return { html, metadata, status: real ? 200 : 404 };
61
+ return { html, metadata, status: real ? 200 : 404, hydration };
55
62
  }
56
63
 
57
64
  /** Back-compat / convenience: render just the `#app` markup for `pathname`. */
@@ -20,6 +20,53 @@ export function defineSSG(tag, render) {
20
20
  registry[tag] = render;
21
21
  }
22
22
 
23
+ // ── hydration props collector (compiler-driven data hydration) ────────────────
24
+ // The lossy path is passing rich props through string attributes. Instead, each
25
+ // server-rendered island gets a `data-h` id and its JSON-safe props are recorded into a
26
+ // single payload the shell embeds as `<script type="application/json">`. At upgrade the
27
+ // client component initializes its prop *signals* from those real JS values (objects,
28
+ // arrays, numbers) — not from `getAttribute` — so islands resume with correct data, with
29
+ // no flash and no dependence on a parent walk re-applying props. `renderRoute` brackets a
30
+ // render with `beginHydrationCollect()` / `endHydrationCollect()`.
31
+ let _collect = null;
32
+
33
+ /** Start collecting per-island hydration props for one render. */
34
+ export function beginHydrationCollect() {
35
+ _collect = [];
36
+ }
37
+
38
+ /**
39
+ * Finish collecting and return the payload as a JSON string ("" when nothing needs
40
+ * hydration). `<` is escaped so a prop value can never break out of the surrounding
41
+ * `<script>` (a `</script>` / `<!--` injection).
42
+ */
43
+ export function endHydrationCollect() {
44
+ const data = _collect;
45
+ _collect = null;
46
+ if (!data || data.length === 0) return "";
47
+ return JSON.stringify(data).replace(/</g, "\\u003c");
48
+ }
49
+
50
+ /**
51
+ * Record an island's JSON-safe props, returning its hydration id — or `null` when not
52
+ * collecting, or when nothing serializes. A JSON round-trip drops functions (event
53
+ * callbacks are client-only — applied by the parent walk, invisible so no flash),
54
+ * `undefined`, and anything cyclic/DOM/signal-shaped, so only plain data crosses.
55
+ */
56
+ function collectHydrationProps(props) {
57
+ if (!_collect || props == null || typeof props !== "object") return null;
58
+ let safe;
59
+ try {
60
+ safe = JSON.parse(JSON.stringify(props));
61
+ } catch {
62
+ return null; // cyclic / non-serializable → client falls back to attributes/defaults
63
+ }
64
+ if (!safe || typeof safe !== "object" || Object.keys(safe).length === 0) return null;
65
+ const id = _collect.length;
66
+ _collect.push(safe);
67
+ return id;
68
+ }
69
+
23
70
  /** Escape text content for HTML. */
24
71
  export function escapeHtml(s) {
25
72
  return String(s).replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
@@ -104,9 +151,13 @@ export function ssgComponent(tag, props, children) {
104
151
  inner = ""; // fail soft (client renders/handles it)
105
152
  }
106
153
  // Stamp the stable styling hook on the host (mirrors CSR's `classList.add`) so
107
- // tag-hashed components are still styleable by a readable class name.
154
+ // tag-hashed components are still styleable by a readable class name, plus the
155
+ // hydration id (`data-h`) keying this island's rich props in the payload (if any).
108
156
  const cls = render && render.hostClass;
109
- return cls ? `<${tag} class="${cls}">${inner}</${tag}>` : `<${tag}>${inner}</${tag}>`;
157
+ const id = collectHydrationProps(props);
158
+ let attrs = cls ? ` class="${cls}"` : "";
159
+ if (id != null) attrs += ` data-h="${id}"`;
160
+ return `<${tag}${attrs}>${inner}</${tag}>`;
110
161
  }
111
162
 
112
163
  export { VOID };