@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 +1 -1
- package/runtime/dom.js +73 -4
- package/runtime/hydrate.js +150 -3
- package/runtime/router.js +69 -9
- package/server/render.js +14 -7
- package/server/ssg-runtime.js +53 -2
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
|
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;
|
package/runtime/hydrate.js
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
288
|
-
// target).
|
|
289
|
-
//
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
21
|
-
* the resolved SEO metadata (for the `<head>`),
|
|
22
|
-
* path matched a real route, 404 when it fell back to the registered 404 page — the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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`. */
|
package/server/ssg-runtime.js
CHANGED
|
@@ -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 === "&" ? "&" : c === "<" ? "<" : ">"));
|
|
@@ -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
|
-
|
|
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 };
|