@vesk/runtime 0.2.25 → 0.2.27
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/dist/hmr-client.d.ts +17 -0
- package/dist/hmr-client.d.ts.map +1 -1
- package/dist/hmr-client.js +54 -0
- package/dist/hydrate.d.ts +83 -4
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +664 -117
- package/dist/index-client.d.ts +4 -1
- package/dist/index-client.d.ts.map +1 -1
- package/dist/index-client.js +2 -1
- package/dist/reconcile.d.ts +7 -6
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +30 -24
- package/dist/router-components.d.ts.map +1 -1
- package/dist/router-components.js +4 -7
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +9 -1
- package/dist/style.d.ts +4 -0
- package/dist/style.d.ts.map +1 -0
- package/dist/style.js +50 -0
- package/package.json +2 -2
package/dist/hydrate.js
CHANGED
|
@@ -51,11 +51,34 @@ const CLAIMED_ATTR = 'data-vsk-claimed';
|
|
|
51
51
|
export function setHydrateDevMode(enabled) {
|
|
52
52
|
__hydrateDevMode = enabled;
|
|
53
53
|
}
|
|
54
|
+
let __hydrateStrict = false;
|
|
55
|
+
let __mismatchHandler = null;
|
|
56
|
+
export function setHydrateStrict(enabled) {
|
|
57
|
+
__hydrateStrict = enabled;
|
|
58
|
+
}
|
|
59
|
+
export function isHydrateStrict() {
|
|
60
|
+
return __hydrateStrict;
|
|
61
|
+
}
|
|
62
|
+
export function onHydrationMismatch(handler) {
|
|
63
|
+
__mismatchHandler = handler;
|
|
64
|
+
}
|
|
54
65
|
function devWarn(message) {
|
|
55
66
|
if (__hydrateDevMode && typeof console !== 'undefined') {
|
|
56
67
|
console.warn('[vesk-hydrate] ' + message);
|
|
57
68
|
}
|
|
58
69
|
}
|
|
70
|
+
function reportMiss(kind, detail, loud) {
|
|
71
|
+
if (__mismatchHandler) {
|
|
72
|
+
try {
|
|
73
|
+
__mismatchHandler({ kind, detail });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Telemetry must never break rendering.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (loud)
|
|
80
|
+
devWarn(`${kind}: ${detail}`);
|
|
81
|
+
}
|
|
59
82
|
function stampClaimed(el) {
|
|
60
83
|
if (__hydrateDevMode) {
|
|
61
84
|
try {
|
|
@@ -69,38 +92,206 @@ function stampClaimed(el) {
|
|
|
69
92
|
}
|
|
70
93
|
// Strip the direct text children of a claimed element. Static text inside a
|
|
71
94
|
// claimed subtree is re-created client-side as fresh text nodes, so the
|
|
72
|
-
// SSR-serialized text nodes are dropped to avoid duplicates.
|
|
95
|
+
// SSR-serialized text nodes are dropped to avoid duplicates. Before dropping,
|
|
96
|
+
// stash the concatenated text as `__vsk_ssrText`: hydrate-mode codegen
|
|
97
|
+
// initializes a sole dynamic text child from this snapshot, so a missed effect
|
|
98
|
+
// degrades to stale (visible) text instead of an empty element (Hydrate-Todo
|
|
99
|
+
// A5). Always set ('' when none) so consumers never branch on presence.
|
|
73
100
|
function stripDirectTextNodes(el) {
|
|
101
|
+
let text = '';
|
|
74
102
|
for (let i = el.childNodes.length - 1; i >= 0; i--) {
|
|
75
|
-
|
|
76
|
-
|
|
103
|
+
const c = el.childNodes[i];
|
|
104
|
+
if (c.nodeType === 3) {
|
|
105
|
+
try {
|
|
106
|
+
text = String(c.textContent ?? '') + text;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// ignore unreadable nodes, still remove below
|
|
110
|
+
}
|
|
111
|
+
c.remove();
|
|
112
|
+
}
|
|
77
113
|
}
|
|
114
|
+
el.__vsk_ssrText = text;
|
|
115
|
+
}
|
|
116
|
+
// After a claim, a claimed element may still contain direct SSR element
|
|
117
|
+
// children: component-boundary wrappers (`display:contents` spans) and static
|
|
118
|
+
// markup that hydration skips. Fresh text/dynamic nodes re-created client-side
|
|
119
|
+
// must be inserted BEFORE these residues at their SSR positions — appending at
|
|
120
|
+
// the end would reorder mixed text + component children (e.g. a label that
|
|
121
|
+
// reads `<span>compiler-first framework · <VersionBadge /></span>`). Store the
|
|
122
|
+
// residue list (in DOM order) on the element so codegen can address it.
|
|
123
|
+
function captureSsrElementChildren(el) {
|
|
124
|
+
el.__vsk_ssrEls = Array.prototype.slice.call(el.children);
|
|
125
|
+
}
|
|
126
|
+
function normText(el) {
|
|
127
|
+
try {
|
|
128
|
+
return (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function collectElements(rootEl) {
|
|
135
|
+
const out = [];
|
|
136
|
+
(function walk(n) {
|
|
137
|
+
const kids = n.childNodes;
|
|
138
|
+
for (let i = 0; i < kids.length; i++) {
|
|
139
|
+
const c = kids[i];
|
|
140
|
+
if (c.nodeType === 1) {
|
|
141
|
+
out.push(c);
|
|
142
|
+
walk(c);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
})(rootEl);
|
|
146
|
+
return out;
|
|
78
147
|
}
|
|
79
148
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
149
|
+
* Twin scan (heuristic): an adopted element sitting directly beside an
|
|
150
|
+
* unadopted same-tag element with identical non-empty text is the signature of
|
|
151
|
+
* a claim miss that twinned — the SSR node survived next to its fresh copy.
|
|
152
|
+
* Either order (SSR-then-fresh or fresh-then-SSR) counts. Purely static
|
|
153
|
+
* identical siblings (both unclaimed) are NOT twins; neither are two adopted
|
|
154
|
+
* nodes.
|
|
86
155
|
*/
|
|
87
|
-
|
|
156
|
+
function scanTwins(container) {
|
|
157
|
+
let count = 0;
|
|
158
|
+
const samples = [];
|
|
159
|
+
for (const el of collectElements(container)) {
|
|
160
|
+
const prev = el.previousElementSibling;
|
|
161
|
+
if (!prev || prev.tagName !== el.tagName)
|
|
162
|
+
continue;
|
|
163
|
+
const t = normText(el);
|
|
164
|
+
if (!t || t !== normText(prev))
|
|
165
|
+
continue;
|
|
166
|
+
let a = false;
|
|
167
|
+
let b = false;
|
|
168
|
+
try {
|
|
169
|
+
a = el.hasAttribute(CLAIMED_ATTR);
|
|
170
|
+
b = prev.hasAttribute(CLAIMED_ATTR);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (a !== b) {
|
|
176
|
+
count++;
|
|
177
|
+
if (samples.length < 3) {
|
|
178
|
+
try {
|
|
179
|
+
samples.push(`<${el.tagName.toLowerCase()}> "${t.slice(0, 60)}"`);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// ignore sample failures
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { count, samples };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Structured audit of the most recent full hydration pass over `container`.
|
|
191
|
+
* Counts unclaimed `<!--vsk-->` markers (SSR regions the client never
|
|
192
|
+
* rendered) and twin elements (SSR node surviving beside its fresh copy).
|
|
193
|
+
* In strict mode, removes genuine orphans — marker plus its SSR element — now
|
|
194
|
+
* that every owner had its chance to claim. Claim-time backoff is untouched:
|
|
195
|
+
* mid-walk removal would steal elements from their real owners.
|
|
196
|
+
*/
|
|
197
|
+
export function auditHydration(container) {
|
|
88
198
|
let unclaimed = 0;
|
|
89
199
|
let sample = '';
|
|
200
|
+
const orphans = [];
|
|
201
|
+
const orphanNames = [];
|
|
90
202
|
const walker = document.createTreeWalker(container, _SHOW_COMMENT, {
|
|
91
|
-
acceptNode: (node) => (node.textContent
|
|
203
|
+
acceptNode: (node) => (isVskMarkerText(node.textContent) ? _FILTER_ACCEPT : _FILTER_SKIP),
|
|
92
204
|
});
|
|
93
205
|
while (walker.nextNode()) {
|
|
94
|
-
const
|
|
206
|
+
const c = walker.currentNode;
|
|
207
|
+
const el = c.nextElementSibling;
|
|
95
208
|
if (el && underClaimedAncestor(el, container))
|
|
96
209
|
continue;
|
|
97
210
|
unclaimed++;
|
|
211
|
+
orphans.push(c);
|
|
212
|
+
// B1: name the orphan — typed component identity from the marker, or
|
|
213
|
+
// the live element tag for bare markers.
|
|
214
|
+
if (orphanNames.length < 5) {
|
|
215
|
+
const parsed = parseVskMarker(c.textContent);
|
|
216
|
+
const id = parsed && parsed.identity ? `<!--vsk:${parsed.identity}-->` : null;
|
|
217
|
+
let tag = '';
|
|
218
|
+
try {
|
|
219
|
+
tag = el && el.tagName ? `<${el.tagName.toLowerCase()}>` : '(no element)';
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
tag = '(unreadable)';
|
|
223
|
+
}
|
|
224
|
+
orphanNames.push(id ? `${id} before ${tag}` : `bare <!--vsk--> before ${tag} (untyped)`);
|
|
225
|
+
}
|
|
98
226
|
if (!sample && container.outerHTML)
|
|
99
227
|
sample = String(container.outerHTML).slice(0, 800);
|
|
100
228
|
}
|
|
229
|
+
const twinRes = scanTwins(container);
|
|
230
|
+
const issues = [];
|
|
101
231
|
if (unclaimed > 0) {
|
|
102
|
-
|
|
103
|
-
|
|
232
|
+
issues.push({
|
|
233
|
+
kind: 'leftover-marker',
|
|
234
|
+
detail: `${unclaimed} hydration marker${unclaimed === 1 ? '' : 's'} never claimed; ` +
|
|
235
|
+
`SSR and client markup diverge. Orphans: ${orphanNames.join(', ')}.`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
for (const s of twinRes.samples) {
|
|
239
|
+
issues.push({ kind: 'twin', detail: `possible hydration twin: ${s}` });
|
|
240
|
+
}
|
|
241
|
+
if (twinRes.count > twinRes.samples.length) {
|
|
242
|
+
issues.push({
|
|
243
|
+
kind: 'twin',
|
|
244
|
+
detail: `${twinRes.count - twinRes.samples.length} further possible twin(s) (samples capped)`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (__hydrateStrict) {
|
|
248
|
+
for (const c of orphans) {
|
|
249
|
+
try {
|
|
250
|
+
const el = c.nextElementSibling;
|
|
251
|
+
if (el && underClaimedAncestor(el, container))
|
|
252
|
+
continue;
|
|
253
|
+
if (el)
|
|
254
|
+
el.remove();
|
|
255
|
+
c.remove();
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Best-effort repair; the report above already recorded it.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (__mismatchHandler) {
|
|
263
|
+
for (const issue of issues) {
|
|
264
|
+
try {
|
|
265
|
+
__mismatchHandler(issue);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Telemetry must never break rendering.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
ok: unclaimed === 0 && twinRes.count === 0,
|
|
274
|
+
unclaimed,
|
|
275
|
+
twins: twinRes.count,
|
|
276
|
+
issues,
|
|
277
|
+
sample,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Dev-mode check that every `<!--vsk-->` marker in `container` has been
|
|
282
|
+
* claimed by the most recent full hydration pass. Markers that remain belong
|
|
283
|
+
* to SSR regions the client never rendered (or rendered differently) — orphaned
|
|
284
|
+
* phantom content that will never be cleaned up. Returns `false` and warns when
|
|
285
|
+
* any such marker survives. Runs automatically at the end of `hydrate`,
|
|
286
|
+
* `hydrateInitial`, and every hydration strategy's completion.
|
|
287
|
+
*/
|
|
288
|
+
export function assertFullyHydrated(container) {
|
|
289
|
+
const report = auditHydration(container);
|
|
290
|
+
if (!report.ok) {
|
|
291
|
+
devWarn(`${report.unclaimed} hydration marker${report.unclaimed === 1 ? '' : 's'} never claimed; ` +
|
|
292
|
+
`SSR and client markup diverge.` +
|
|
293
|
+
(report.twins > 0 ? ` ${report.twins} possible twin(s) detected.` : '') +
|
|
294
|
+
` Container fragment:\n${report.sample}`);
|
|
104
295
|
return false;
|
|
105
296
|
}
|
|
106
297
|
return true;
|
|
@@ -112,18 +303,77 @@ function underClaimedAncestor(el, container) {
|
|
|
112
303
|
}
|
|
113
304
|
return false;
|
|
114
305
|
}
|
|
306
|
+
function markerIdentity(comment) {
|
|
307
|
+
try {
|
|
308
|
+
const parsed = parseVskMarker(comment.textContent);
|
|
309
|
+
return parsed ? parsed.identity : null;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function describeMarker(tm) {
|
|
316
|
+
return tm.identity ? `<!--vsk:${tm.identity}-->` : '<!--vsk-->';
|
|
317
|
+
}
|
|
115
318
|
const _SHOW_COMMENT = 128;
|
|
116
319
|
export const _FILTER_ACCEPT = 1;
|
|
117
320
|
export const _FILTER_SKIP = 2;
|
|
321
|
+
// Keyed marker identity (Hydrate-Todo B1 + keyed markers). Server codegen
|
|
322
|
+
// types every boundary: components (`<!--vsk:c:Name-->`), static subtrees
|
|
323
|
+
// (`<!--vsk:t:tag-->`). No regex here either (runtime text-processing rule):
|
|
324
|
+
// exact match or the `vsk:` prefix. Bare `<!--vsk-->` still adopts
|
|
325
|
+
// positionally but is reported as `untyped-marker` — no exceptions.
|
|
326
|
+
export function isVskMarkerText(text) {
|
|
327
|
+
if (text === 'vsk')
|
|
328
|
+
return true;
|
|
329
|
+
if (typeof text !== 'string' || text.length < 4)
|
|
330
|
+
return false;
|
|
331
|
+
return text.charAt(0) === 'v' && text.charAt(1) === 's' && text.charAt(2) === 'k' && text.charAt(3) === ':';
|
|
332
|
+
}
|
|
333
|
+
export function parseVskMarker(text) {
|
|
334
|
+
if (text === 'vsk')
|
|
335
|
+
return { identity: null };
|
|
336
|
+
if (!isVskMarkerText(text))
|
|
337
|
+
return null;
|
|
338
|
+
const rest = text.slice(4);
|
|
339
|
+
return { identity: rest === '' ? null : rest };
|
|
340
|
+
}
|
|
118
341
|
export function collectVskMarkers(container) {
|
|
119
342
|
const markers = [];
|
|
120
343
|
const walker = document.createTreeWalker(container, _SHOW_COMMENT, {
|
|
121
|
-
acceptNode: (node) => (node.textContent
|
|
344
|
+
acceptNode: (node) => (isVskMarkerText(node.textContent) ? _FILTER_ACCEPT : _FILTER_SKIP),
|
|
122
345
|
});
|
|
123
346
|
while (walker.nextNode())
|
|
124
347
|
markers.push(walker.currentNode);
|
|
125
348
|
return markers;
|
|
126
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Keyed-marker verification at adopt time. A `t:tag` marker must precede that
|
|
352
|
+
* tag — anything else is SSR/HTML-parser skew (e.g. a `<p>` auto-close moving
|
|
353
|
+
* the marker), reported deterministically instead of drifting. A bare marker
|
|
354
|
+
* still adopts positionally but is reported: every emission site types its
|
|
355
|
+
* markers, so bare means a missed site or stale output. No exceptions.
|
|
356
|
+
*/
|
|
357
|
+
function checkMarkerIdentity(marker, el) {
|
|
358
|
+
let identity = null;
|
|
359
|
+
try {
|
|
360
|
+
const parsed = parseVskMarker(marker.textContent);
|
|
361
|
+
identity = parsed ? parsed.identity : null;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
identity = null;
|
|
365
|
+
}
|
|
366
|
+
if (identity === null) {
|
|
367
|
+
reportMiss('untyped-marker', `adopted a bare <!--vsk--> marker before <${el.tagName.toLowerCase()}>; all emission sites must type their markers.`, __hydrateDevMode);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (identity.charAt(0) === 't' && identity.charAt(1) === ':') {
|
|
371
|
+
const expected = identity.slice(2).toLowerCase();
|
|
372
|
+
if (expected !== '' && expected !== el.tagName.toLowerCase()) {
|
|
373
|
+
reportMiss('marker-skew', `marker <!--vsk:${identity}--> precedes <${el.tagName.toLowerCase()}> — SSR/HTML-parser skew; adopting the live element.`, __hydrateDevMode);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
127
377
|
function adoptElement(marker, tag) {
|
|
128
378
|
const el = marker.nextElementSibling;
|
|
129
379
|
if (!el) {
|
|
@@ -137,8 +387,26 @@ function adoptElement(marker, tag) {
|
|
|
137
387
|
// defect: the layout's missing conditional span claim stole the h1 marker).
|
|
138
388
|
if (tag && el.tagName.toLowerCase() !== tag)
|
|
139
389
|
return null;
|
|
390
|
+
checkMarkerIdentity(marker, el);
|
|
140
391
|
marker.remove();
|
|
141
392
|
stripDirectTextNodes(el);
|
|
393
|
+
captureSsrElementChildren(el);
|
|
394
|
+
stampClaimed(el);
|
|
395
|
+
return el;
|
|
396
|
+
}
|
|
397
|
+
// Like adoptElement but preserves direct text children. Used by static-component
|
|
398
|
+
// stubs whose SSR content is kept as-is (no client-side re-rendering).
|
|
399
|
+
function adoptElementRaw(marker, tag) {
|
|
400
|
+
const el = marker.nextElementSibling;
|
|
401
|
+
if (!el) {
|
|
402
|
+
marker.remove();
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
if (tag && el.tagName.toLowerCase() !== tag)
|
|
406
|
+
return null;
|
|
407
|
+
checkMarkerIdentity(marker, el);
|
|
408
|
+
marker.remove();
|
|
409
|
+
captureSsrElementChildren(el);
|
|
142
410
|
stampClaimed(el);
|
|
143
411
|
return el;
|
|
144
412
|
}
|
|
@@ -146,13 +414,59 @@ class WalkerEngine {
|
|
|
146
414
|
root;
|
|
147
415
|
markers;
|
|
148
416
|
idx = 0;
|
|
417
|
+
// Elements already adopted by an earlier claim in this walk. Marker-only SSR
|
|
418
|
+
// can leave TWO keyed marks on the same element (the call-site marker and
|
|
419
|
+
// the callee's own root marker — e.g. Link self-prefixes `<!--vsk:c:Link-->`
|
|
420
|
+
// and the shared walker claims its call-site marker), and a claim must never
|
|
421
|
+
// adopt the same node twice: double-adoption strips its SSR text a second
|
|
422
|
+
// time and shells the cursor so every later claim misses its element.
|
|
423
|
+
adopted = new WeakSet();
|
|
149
424
|
constructor(root, markers) {
|
|
150
425
|
this.root = root;
|
|
151
|
-
this.markers = markers.map((c) => ({ comment: c, state: 'unclaimed' }));
|
|
426
|
+
this.markers = markers.map((c) => ({ comment: c, state: 'unclaimed', identity: markerIdentity(c) }));
|
|
152
427
|
}
|
|
153
428
|
done() {
|
|
154
429
|
return this.idx >= this.markers.length;
|
|
155
430
|
}
|
|
431
|
+
// A marker whose `nextElementSibling` was already adopted is a dead alias of
|
|
432
|
+
// a live claim point (double-marker collision). Retire it and move on so the
|
|
433
|
+
// cursor never claims a node twice.
|
|
434
|
+
isAlreadyAdopted(tm, el) {
|
|
435
|
+
if (el !== null && this.adopted.has(el)) {
|
|
436
|
+
tm.state = 'claimed';
|
|
437
|
+
this.idx++;
|
|
438
|
+
// Physically retire the alias too: like `adoptElement`, a spent
|
|
439
|
+
// marker never stays in the live DOM.
|
|
440
|
+
tm.comment.remove();
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
recordAdopted(el) {
|
|
446
|
+
this.adopted.add(el);
|
|
447
|
+
}
|
|
448
|
+
// Marker-only SSR can stack several keyed marks before ONE element
|
|
449
|
+
// (call-site marker + the callee's own root marker). The FIRST claim adopts
|
|
450
|
+
// the element; the alias markers that immediately follow it are dead — they
|
|
451
|
+
// point at an already-claimed node and must be physically retired so the
|
|
452
|
+
// canary never counts them and a trailing alias never lingers in the DOM.
|
|
453
|
+
retireAliases(el) {
|
|
454
|
+
let i = this.idx;
|
|
455
|
+
while (i < this.markers.length) {
|
|
456
|
+
const tm = this.markers[i];
|
|
457
|
+
if (tm.state === 'claimed') {
|
|
458
|
+
i++;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (tm.comment.nextElementSibling === el) {
|
|
462
|
+
tm.state = 'claimed';
|
|
463
|
+
tm.comment.remove();
|
|
464
|
+
i++;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
156
470
|
nextElement(tag) {
|
|
157
471
|
while (this.idx < this.markers.length) {
|
|
158
472
|
const tm = this.markers[this.idx];
|
|
@@ -161,6 +475,8 @@ class WalkerEngine {
|
|
|
161
475
|
continue;
|
|
162
476
|
}
|
|
163
477
|
const el = tm.comment.nextElementSibling;
|
|
478
|
+
if (this.isAlreadyAdopted(tm, el))
|
|
479
|
+
continue;
|
|
164
480
|
if (tag && el && el.tagName.toLowerCase() !== tag) {
|
|
165
481
|
// SSR rendered a different tag than this claim wants. Leave the
|
|
166
482
|
// marker AND the element untouched and back off without moving
|
|
@@ -168,6 +484,8 @@ class WalkerEngine {
|
|
|
168
484
|
// consuming it here would strip its SSR text, stamp a claim on
|
|
169
485
|
// a node this render does not own, and drift every later claim
|
|
170
486
|
// into fresh-node fallback (the empty `/store/widget` h1).
|
|
487
|
+
// The skipped marker's B1 identity names the divergence point.
|
|
488
|
+
reportMiss('tag-mismatch', `claim wanted <${tag}> but the cursor holds ${describeMarker(tm)} before <${el.tagName.toLowerCase()}>; backing off for its real owner.`, false);
|
|
171
489
|
break;
|
|
172
490
|
}
|
|
173
491
|
this.idx++;
|
|
@@ -182,81 +500,211 @@ class WalkerEngine {
|
|
|
182
500
|
tm.state = 'claimed';
|
|
183
501
|
break;
|
|
184
502
|
}
|
|
503
|
+
this.recordAdopted(adopted);
|
|
504
|
+
this.retireAliases(adopted);
|
|
185
505
|
tm.state = 'claimed';
|
|
186
506
|
return adopted;
|
|
187
507
|
}
|
|
188
508
|
// Every claim parked itself — either because the render asked for more
|
|
189
509
|
// elements than SSR provided, or because the SSR element tag did not
|
|
190
|
-
// match the claim. Best effort: build a fresh element and
|
|
510
|
+
// match the claim. Best effort: build a fresh element and report.
|
|
191
511
|
//
|
|
192
|
-
// The
|
|
512
|
+
// The report is scoped to walks that still hold unconsumed markers
|
|
193
513
|
// (`this.idx < this.markers.length`): that is the signature of a real
|
|
194
514
|
// SSR/client structural divergence — a marker is ahead of the cursor but
|
|
195
515
|
// the claim could not adopt it (wrong tag). Once the walker is exhausted
|
|
196
516
|
// every further `nextElement` is a post-hydration re-render (reactive
|
|
197
517
|
// loop/if blocks re-running after the interval or an event) legitimately
|
|
198
|
-
// building fresh nodes —
|
|
199
|
-
// console every tick. SPA/client-only renders keep empty
|
|
200
|
-
// temp roots and never warn.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
518
|
+
// building fresh nodes — telemetry-only, so a typing `for` loop does
|
|
519
|
+
// not spam the console every tick. SPA/client-only renders keep empty
|
|
520
|
+
// marker lists on temp roots and never warn.
|
|
521
|
+
const hasUnconsumed = this.root !== null && this.idx < this.markers.length;
|
|
522
|
+
// Name the skipped cursor marker (B1): the divergence point is no
|
|
523
|
+
// longer anonymous.
|
|
524
|
+
let skipped = '';
|
|
525
|
+
if (hasUnconsumed) {
|
|
526
|
+
for (let s = this.idx; s < this.markers.length; s++) {
|
|
527
|
+
if (this.markers[s].state === 'claimed')
|
|
528
|
+
continue;
|
|
529
|
+
skipped = ` skipped ${describeMarker(this.markers[s])}`;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
204
532
|
}
|
|
533
|
+
reportMiss(hasUnconsumed ? 'tag-mismatch' : 'exhausted', `hydration claim missed <${tag || 'element'}> (${this.markers.length - this.idx} markers unconsumed${skipped}); the client rendered more or different content than SSR.` +
|
|
534
|
+
(this.root && this.root.outerHTML ? ` Fragment:\n${String(this.root.outerHTML).slice(0, 800)}` : ''), hasUnconsumed && __hydrateDevMode);
|
|
205
535
|
return document.createElement(tag || 'div');
|
|
206
536
|
}
|
|
537
|
+
claimOnly(tag) {
|
|
538
|
+
while (this.idx < this.markers.length) {
|
|
539
|
+
const tm = this.markers[this.idx];
|
|
540
|
+
if (tm.state === 'claimed') {
|
|
541
|
+
this.idx++;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const el = tm.comment.nextElementSibling;
|
|
545
|
+
if (this.isAlreadyAdopted(tm, el))
|
|
546
|
+
continue;
|
|
547
|
+
if (tag && el && el.tagName.toLowerCase() !== tag) {
|
|
548
|
+
reportMiss('tag-mismatch', `static claim wanted <${tag}> but the cursor holds ${describeMarker(tm)} before <${el.tagName.toLowerCase()}>; backing off for its real owner.`, false);
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
this.idx++;
|
|
552
|
+
const adopted = adoptElementRaw(tm.comment, tag);
|
|
553
|
+
if (adopted === null) {
|
|
554
|
+
tm.state = 'claimed';
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
this.recordAdopted(adopted);
|
|
558
|
+
this.retireAliases(adopted);
|
|
559
|
+
tm.state = 'claimed';
|
|
560
|
+
return adopted;
|
|
561
|
+
}
|
|
562
|
+
// Static-stub fallback twins exactly like nextElement: the SSR node
|
|
563
|
+
// stays while a fresh node is built. Same reporting contract.
|
|
564
|
+
const hasUnconsumed = this.root !== null && this.idx < this.markers.length;
|
|
565
|
+
reportMiss(hasUnconsumed ? 'tag-mismatch' : 'exhausted', `hydration static claim missed <${tag || 'element'}> (${this.markers.length - this.idx} markers unconsumed); the client rendered more or different content than SSR.`, hasUnconsumed && __hydrateDevMode);
|
|
566
|
+
return document.createElement(tag || 'div');
|
|
567
|
+
}
|
|
568
|
+
retireDetached() {
|
|
569
|
+
while (this.idx < this.markers.length) {
|
|
570
|
+
const tm = this.markers[this.idx];
|
|
571
|
+
if (tm.state === 'claimed') {
|
|
572
|
+
this.idx++;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const c = tm.comment;
|
|
576
|
+
// A marker whose comment is no longer attached cannot belong to any
|
|
577
|
+
// element this walker can claim, so its SSR content was removed by
|
|
578
|
+
// the render (e.g. Link's replaceChildren). Skip it and keep the
|
|
579
|
+
// cursor document-ordered for the next live marker.
|
|
580
|
+
if (!c || !c.parentNode) {
|
|
581
|
+
tm.state = 'claimed';
|
|
582
|
+
this.idx++;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
207
588
|
subWalker(rootEl) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (
|
|
232
|
-
|
|
589
|
+
// Ownership TRANSFER (Hydrate-Todo B3): the child's markers are
|
|
590
|
+
// spliced out of this walk — exactly one engine owns each marker, so
|
|
591
|
+
// no cursor arithmetic can drift and no shared refs can double-claim.
|
|
592
|
+
// The old `idx += count` assumed document order == marker order.
|
|
593
|
+
const owned = [];
|
|
594
|
+
const kept = [];
|
|
595
|
+
for (const m of this.markers) {
|
|
596
|
+
let mine = false;
|
|
597
|
+
if (m.state !== 'claimed') {
|
|
598
|
+
if (rootEl === m.comment)
|
|
599
|
+
mine = true;
|
|
600
|
+
else if (rootEl && m.comment && typeof rootEl.contains === 'function')
|
|
601
|
+
mine = rootEl.contains(m.comment);
|
|
602
|
+
}
|
|
603
|
+
if (mine) {
|
|
604
|
+
m.state = 'claimed';
|
|
605
|
+
owned.push(m);
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
kept.push(m);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
this.markers = kept;
|
|
612
|
+
if (this.idx > this.markers.length)
|
|
613
|
+
this.idx = this.markers.length;
|
|
614
|
+
return new WalkerEngine(rootEl, owned.map((m) => m.comment));
|
|
615
|
+
}
|
|
616
|
+
// Retire every unclaimed marker in the whole walk that points at `el`.
|
|
617
|
+
// Used after a cross-position adopt (relocate): the adopted node moves to
|
|
618
|
+
// the cursor, so alias markers stacked at its old station would otherwise
|
|
619
|
+
// dangle — pointing at its successor and misaligning later claims.
|
|
620
|
+
retireAliasesOf(el) {
|
|
621
|
+
for (const tm of this.markers) {
|
|
622
|
+
if (tm.state !== 'claimed' && tm.comment.nextElementSibling === el) {
|
|
623
|
+
tm.state = 'claimed';
|
|
624
|
+
tm.comment.remove();
|
|
625
|
+
}
|
|
233
626
|
}
|
|
234
|
-
return new WalkerEngine(rootEl, subMarkers.map((m) => m.comment));
|
|
235
627
|
}
|
|
236
|
-
|
|
628
|
+
adoptKeyedElement(tm, el) {
|
|
629
|
+
tm.state = 'claimed';
|
|
630
|
+
this.recordAdopted(el);
|
|
631
|
+
checkMarkerIdentity(tm.comment, el);
|
|
632
|
+
tm.comment.remove();
|
|
633
|
+
stripDirectTextNodes(el);
|
|
634
|
+
captureSsrElementChildren(el);
|
|
635
|
+
stampClaimed(el);
|
|
636
|
+
return { el };
|
|
637
|
+
}
|
|
638
|
+
claimByKey(key, options) {
|
|
237
639
|
const strKey = String(key);
|
|
238
640
|
// Strict positional claim: the item claims the cursor's marker. Interior
|
|
239
641
|
// markers of the previously-claimed item are consumed by that item's own
|
|
240
642
|
// render, so by the time we get here the cursor points at this item's
|
|
241
643
|
// SSR root. Only EXACT ordering SSR == client is claimed; divergence
|
|
242
|
-
// renders the item fresh at the region tail (canary reports the ghost)
|
|
644
|
+
// renders the item fresh at the region tail (canary reports the ghost)
|
|
645
|
+
// unless `relocate` upgrades the miss to adopt+move (Hydrate-Todo A4).
|
|
243
646
|
for (let i = this.idx; i < this.markers.length; i++) {
|
|
244
647
|
const tm = this.markers[i];
|
|
245
648
|
if (tm.state === 'claimed')
|
|
246
649
|
continue;
|
|
247
650
|
const el = tm.comment.nextElementSibling;
|
|
651
|
+
if (this.isAlreadyAdopted(tm, el))
|
|
652
|
+
continue;
|
|
248
653
|
if (el && el.getAttribute('data-vsk-key') === strKey) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
stampClaimed(el);
|
|
253
|
-
return { el };
|
|
654
|
+
const claim = this.adoptKeyedElement(tm, el);
|
|
655
|
+
this.retireAliases(el);
|
|
656
|
+
return claim;
|
|
254
657
|
}
|
|
255
658
|
// First unclaimed marker is not this item — don't scan past it.
|
|
659
|
+
if (options && options.relocate) {
|
|
660
|
+
const moved = this.relocateByKey(strKey, tm);
|
|
661
|
+
if (moved)
|
|
662
|
+
return moved;
|
|
663
|
+
}
|
|
664
|
+
// If the key exists later in the region this is a genuine SSR↔client
|
|
665
|
+
// reorder; without relocate the item renders fresh at the tail
|
|
666
|
+
// while its SSR twin rots. Report it so reorder divergence is
|
|
667
|
+
// visible instead of silent.
|
|
668
|
+
if (this.peekKey(strKey) !== null) {
|
|
669
|
+
reportMiss('key-reorder', `keyed item "${strKey}" is not at the cursor; SSR order and client order diverge — item renders fresh, SSR twin orphaned.`, __hydrateDevMode);
|
|
670
|
+
}
|
|
256
671
|
return null;
|
|
257
672
|
}
|
|
258
673
|
return null;
|
|
259
674
|
}
|
|
675
|
+
// A4 adopt+move: find `key` anywhere later in the walk, adopt it in place,
|
|
676
|
+
// then move it to the cursor (`anchor`, the mismatching marker) so region
|
|
677
|
+
// order converges to client order with node identity preserved. The cursor
|
|
678
|
+
// itself does not advance — skipped markers still belong to their owners.
|
|
679
|
+
// Returns null when the key has no live SSR node (genuinely new item).
|
|
680
|
+
relocateByKey(strKey, anchor) {
|
|
681
|
+
for (let j = this.idx; j < this.markers.length; j++) {
|
|
682
|
+
const tm = this.markers[j];
|
|
683
|
+
if (tm.state === 'claimed')
|
|
684
|
+
continue;
|
|
685
|
+
const el = tm.comment.nextElementSibling;
|
|
686
|
+
if (!el || this.adopted.has(el))
|
|
687
|
+
continue;
|
|
688
|
+
if (el.getAttribute('data-vsk-key') !== strKey)
|
|
689
|
+
continue;
|
|
690
|
+
const parent = el.parentNode;
|
|
691
|
+
const anchorParent = anchor.comment.parentNode;
|
|
692
|
+
if (!parent || parent !== anchorParent)
|
|
693
|
+
continue;
|
|
694
|
+
const claim = this.adoptKeyedElement(tm, el);
|
|
695
|
+
this.retireAliasesOf(el);
|
|
696
|
+
try {
|
|
697
|
+
parent.insertBefore(el, anchor.comment);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
// A failed move must not strand an adopted node: the claim
|
|
701
|
+
// stands (identity preserved) and order falls back to markers.
|
|
702
|
+
}
|
|
703
|
+
reportMiss('key-reorder', `keyed item "${strKey}" adopted out of position and moved into place; SSR order and client order diverged.`, false);
|
|
704
|
+
return claim;
|
|
705
|
+
}
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
260
708
|
peekKey(key) {
|
|
261
709
|
const strKey = String(key);
|
|
262
710
|
// Non-consuming scan across EVERY remaining unclaimed marker: used to
|
|
@@ -267,6 +715,8 @@ class WalkerEngine {
|
|
|
267
715
|
if (tm.state === 'claimed')
|
|
268
716
|
continue;
|
|
269
717
|
const el = tm.comment.nextElementSibling;
|
|
718
|
+
if (el && this.adopted.has(el))
|
|
719
|
+
continue;
|
|
270
720
|
if (el && el.getAttribute('data-vsk-key') === strKey)
|
|
271
721
|
return el;
|
|
272
722
|
}
|
|
@@ -290,6 +740,18 @@ export function createHydrateChildWalker(parentEl) {
|
|
|
290
740
|
const child = children[childIdx++];
|
|
291
741
|
if (!tag || child.tagName.toLowerCase() === tag) {
|
|
292
742
|
stripDirectTextNodes(child);
|
|
743
|
+
captureSsrElementChildren(child);
|
|
744
|
+
stampClaimed(child);
|
|
745
|
+
return child;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return document.createElement(tag || 'div');
|
|
749
|
+
},
|
|
750
|
+
claimOnly(tag) {
|
|
751
|
+
while (childIdx < children.length) {
|
|
752
|
+
const child = children[childIdx++];
|
|
753
|
+
if (!tag || child.tagName.toLowerCase() === tag) {
|
|
754
|
+
captureSsrElementChildren(child);
|
|
293
755
|
stampClaimed(child);
|
|
294
756
|
return child;
|
|
295
757
|
}
|
|
@@ -299,6 +761,9 @@ export function createHydrateChildWalker(parentEl) {
|
|
|
299
761
|
subWalker(rootEl) {
|
|
300
762
|
return createHydrateChildWalker(rootEl);
|
|
301
763
|
},
|
|
764
|
+
retireDetached() {
|
|
765
|
+
// Child-walkers hold no marker list; nothing to sweep.
|
|
766
|
+
},
|
|
302
767
|
};
|
|
303
768
|
}
|
|
304
769
|
export function hydrate(container, componentFn, props) {
|
|
@@ -307,45 +772,103 @@ export function hydrate(container, componentFn, props) {
|
|
|
307
772
|
assertFullyHydrated(container);
|
|
308
773
|
return result;
|
|
309
774
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
export function hydrateViewport(container, componentFn, props, rootMargin = 500) {
|
|
317
|
-
if (document.readyState !== 'complete') {
|
|
318
|
-
return new Promise((resolve) => {
|
|
319
|
-
const onLoad = () => {
|
|
320
|
-
window.removeEventListener('load', onLoad);
|
|
321
|
-
resolve(hydrateViewport(container, componentFn, props, rootMargin));
|
|
322
|
-
};
|
|
323
|
-
window.addEventListener('load', onLoad);
|
|
324
|
-
});
|
|
775
|
+
export function bumpNavEpoch() {
|
|
776
|
+
try {
|
|
777
|
+
const g = globalThis;
|
|
778
|
+
const next = (g.__vesk_nav_epoch || 0) + 1;
|
|
779
|
+
g.__vesk_nav_epoch = next;
|
|
780
|
+
return next;
|
|
325
781
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
782
|
+
catch {
|
|
783
|
+
return 0;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function navEpoch() {
|
|
787
|
+
try {
|
|
788
|
+
return globalThis.__vesk_nav_epoch || 0;
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
return 0;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function batchAlive(container, epoch, isCurrent) {
|
|
795
|
+
try {
|
|
796
|
+
if (!container.isConnected)
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
if (isCurrent) {
|
|
803
|
+
try {
|
|
804
|
+
return isCurrent() !== false;
|
|
338
805
|
}
|
|
339
|
-
|
|
340
|
-
|
|
806
|
+
catch {
|
|
807
|
+
return false;
|
|
341
808
|
}
|
|
342
809
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
810
|
+
return navEpoch() === epoch;
|
|
811
|
+
}
|
|
812
|
+
export function hydrateViewport(container, componentFn, props, rootMargin = 500, options = {}) {
|
|
813
|
+
const epoch = navEpoch();
|
|
814
|
+
const isCurrent = options.isCurrent;
|
|
815
|
+
let cancelled = false;
|
|
816
|
+
let cancelFn = () => { };
|
|
817
|
+
const promise = new Promise((resolve) => {
|
|
818
|
+
const finish = () => {
|
|
819
|
+
cancelled = true;
|
|
820
|
+
cancelFn = () => { };
|
|
821
|
+
resolve();
|
|
822
|
+
};
|
|
823
|
+
cancelFn = finish;
|
|
824
|
+
if (!batchAlive(container, epoch, isCurrent))
|
|
825
|
+
return finish();
|
|
826
|
+
start();
|
|
827
|
+
function start() {
|
|
828
|
+
if (cancelled)
|
|
829
|
+
return finish();
|
|
830
|
+
if (document.readyState !== 'complete') {
|
|
831
|
+
const onLoad = () => {
|
|
832
|
+
window.removeEventListener('load', onLoad);
|
|
833
|
+
if (cancelled || !batchAlive(container, epoch, isCurrent))
|
|
834
|
+
return finish();
|
|
835
|
+
begin();
|
|
836
|
+
};
|
|
837
|
+
window.addEventListener('load', onLoad);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
begin();
|
|
841
|
+
}
|
|
842
|
+
function begin() {
|
|
843
|
+
const allMarkers = collectVskMarkers(container);
|
|
844
|
+
const viewportMarkers = [];
|
|
845
|
+
const deferredMarkers = [];
|
|
846
|
+
for (const marker of allMarkers) {
|
|
847
|
+
const el = marker.nextElementSibling;
|
|
848
|
+
if (!el) {
|
|
849
|
+
deferredMarkers.push(marker);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
const rect = el.getBoundingClientRect();
|
|
853
|
+
if (rect.bottom < -rootMargin || rect.top > window.innerHeight + rootMargin) {
|
|
854
|
+
deferredMarkers.push(marker);
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
viewportMarkers.push(marker);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const held = new Set(deferredMarkers);
|
|
861
|
+
const viewportWalker = createHydrateWalker(container, viewportMarkers);
|
|
862
|
+
runInHydrateBlock(() => componentFn(props || {}, new Map(), viewportWalker));
|
|
863
|
+
if (deferredMarkers.length === 0) {
|
|
864
|
+
assertFullyHydrated(container);
|
|
865
|
+
return finish();
|
|
866
|
+
}
|
|
348
867
|
const observer = new IntersectionObserver((entries) => {
|
|
868
|
+
if (!batchAlive(container, epoch, isCurrent)) {
|
|
869
|
+
observer.disconnect();
|
|
870
|
+
return finish();
|
|
871
|
+
}
|
|
349
872
|
const toHydrate = [];
|
|
350
873
|
for (const entry of entries) {
|
|
351
874
|
if (entry.isIntersecting) {
|
|
@@ -360,52 +883,70 @@ export function hydrateViewport(container, componentFn, props, rootMargin = 500)
|
|
|
360
883
|
}
|
|
361
884
|
}
|
|
362
885
|
if (toHydrate.length > 0) {
|
|
886
|
+
if (!batchAlive(container, epoch, isCurrent)) {
|
|
887
|
+
observer.disconnect();
|
|
888
|
+
return finish();
|
|
889
|
+
}
|
|
363
890
|
const w = createHydrateWalker(container, toHydrate);
|
|
364
891
|
runInHydrateBlock(() => componentFn(props || {}, new Map(), w));
|
|
365
892
|
}
|
|
366
893
|
if (held.size === 0) {
|
|
367
894
|
observer.disconnect();
|
|
368
895
|
assertFullyHydrated(container);
|
|
369
|
-
|
|
896
|
+
return finish();
|
|
370
897
|
}
|
|
371
898
|
}, { rootMargin: `${rootMargin}px` });
|
|
899
|
+
cancelFn = () => {
|
|
900
|
+
try {
|
|
901
|
+
observer.disconnect();
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
// ignore disconnect failures
|
|
905
|
+
}
|
|
906
|
+
finish();
|
|
907
|
+
};
|
|
372
908
|
for (const marker of deferredMarkers) {
|
|
373
909
|
const el = marker.nextElementSibling;
|
|
374
910
|
if (el)
|
|
375
911
|
observer.observe(el);
|
|
376
912
|
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
return Promise.resolve();
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
return Object.assign(promise, { cancel: () => cancelFn() });
|
|
381
916
|
}
|
|
917
|
+
/**
|
|
918
|
+
* Idle hydration (Hydrate-Todo B2): waits for the first idle window, then
|
|
919
|
+
* hydrates the whole container in ONE full run and stops.
|
|
920
|
+
*
|
|
921
|
+
* A previous design re-invoked the component once per marker chunk. That is
|
|
922
|
+
* irreparably unsafe without codegen yield points: every run claims its own
|
|
923
|
+
* chunk but misses everything else, so each run twins out-of-chunk SSR content
|
|
924
|
+
* with fresh nodes AND double-registers effects. Single-shot keeps the RULE
|
|
925
|
+
* (no duplication); true intra-render time-slicing needs compiler support and
|
|
926
|
+
* `chunkSize` is reserved for it (accepted, currently no effect).
|
|
927
|
+
*/
|
|
382
928
|
export function hydrateIdle(container, componentFn, props, options = {}) {
|
|
383
|
-
const
|
|
384
|
-
const
|
|
929
|
+
const epoch = navEpoch();
|
|
930
|
+
const isCurrent = options.isCurrent;
|
|
385
931
|
const timeout = options.timeout || 3000;
|
|
386
|
-
let idx = 0;
|
|
387
932
|
const rIC = window.requestIdleCallback || ((cb) => setTimeout(cb, 50));
|
|
388
933
|
const cIC = window.cancelIdleCallback || clearTimeout;
|
|
389
934
|
let rafId = null;
|
|
390
935
|
let cancelled = false;
|
|
391
|
-
|
|
392
|
-
|
|
936
|
+
let ran = false;
|
|
937
|
+
function runOnce() {
|
|
938
|
+
if (cancelled || ran)
|
|
393
939
|
return;
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
rafId = rIC(processChunk, { timeout });
|
|
403
|
-
}
|
|
404
|
-
else if (idx >= allMarkers.length) {
|
|
405
|
-
assertFullyHydrated(container);
|
|
406
|
-
}
|
|
940
|
+
ran = true;
|
|
941
|
+
// Liveness at fire time: an idle callback landing after navigation
|
|
942
|
+
// must not hydrate replaced DOM.
|
|
943
|
+
if (!batchAlive(container, epoch, isCurrent))
|
|
944
|
+
return;
|
|
945
|
+
const walker = createHydrateWalker(container);
|
|
946
|
+
runInHydrateBlock(() => componentFn(props || {}, new Map(), walker));
|
|
947
|
+
assertFullyHydrated(container);
|
|
407
948
|
}
|
|
408
|
-
rafId = rIC(
|
|
949
|
+
rafId = rIC(runOnce, { timeout });
|
|
409
950
|
return {
|
|
410
951
|
cancel() {
|
|
411
952
|
cancelled = true;
|
|
@@ -418,11 +959,13 @@ export function hydrateIdle(container, componentFn, props, options = {}) {
|
|
|
418
959
|
}
|
|
419
960
|
export function needsHydration(container) {
|
|
420
961
|
const walker = document.createTreeWalker(container, _SHOW_COMMENT, {
|
|
421
|
-
acceptNode: (node) => (node.textContent
|
|
962
|
+
acceptNode: (node) => (isVskMarkerText(node.textContent) ? _FILTER_ACCEPT : _FILTER_SKIP),
|
|
422
963
|
});
|
|
423
964
|
return walker.nextNode() !== null;
|
|
424
965
|
}
|
|
425
966
|
export function hydrateOnInteraction(container, componentFn, props, options = {}) {
|
|
967
|
+
const epoch = navEpoch();
|
|
968
|
+
const isCurrent = options.isCurrent;
|
|
426
969
|
const events = options.events || ['click', 'touchstart', 'focus', 'mouseenter'];
|
|
427
970
|
let hydrated = false;
|
|
428
971
|
function trigger(_eventType) {
|
|
@@ -432,6 +975,10 @@ export function hydrateOnInteraction(container, componentFn, props, options = {}
|
|
|
432
975
|
for (const ev of events) {
|
|
433
976
|
container.removeEventListener(ev, handler);
|
|
434
977
|
}
|
|
978
|
+
// An interaction that arrives after navigation must not hydrate the
|
|
979
|
+
// previous page's markers into replaced content.
|
|
980
|
+
if (!batchAlive(container, epoch, isCurrent))
|
|
981
|
+
return;
|
|
435
982
|
const markers = collectVskMarkers(container);
|
|
436
983
|
if (markers.length > 0) {
|
|
437
984
|
const walker = createHydrateWalker(container, markers);
|
|
@@ -453,7 +1000,7 @@ export function hydrationCount(container) {
|
|
|
453
1000
|
let count = 0;
|
|
454
1001
|
const walker = document.createTreeWalker(container, _SHOW_COMMENT, {
|
|
455
1002
|
acceptNode: (node) => {
|
|
456
|
-
if (node.textContent
|
|
1003
|
+
if (isVskMarkerText(node.textContent)) {
|
|
457
1004
|
count++;
|
|
458
1005
|
return _FILTER_ACCEPT;
|
|
459
1006
|
}
|