@vesk/runtime 0.2.26 → 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 +69 -4
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +529 -113
- 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 -2
- 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,12 +92,26 @@ 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;
|
|
78
115
|
}
|
|
79
116
|
// After a claim, a claimed element may still contain direct SSR element
|
|
80
117
|
// children: component-boundary wrappers (`display:contents` spans) and static
|
|
@@ -86,31 +123,175 @@ function stripDirectTextNodes(el) {
|
|
|
86
123
|
function captureSsrElementChildren(el) {
|
|
87
124
|
el.__vsk_ssrEls = Array.prototype.slice.call(el.children);
|
|
88
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;
|
|
147
|
+
}
|
|
89
148
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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.
|
|
96
155
|
*/
|
|
97
|
-
|
|
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) {
|
|
98
198
|
let unclaimed = 0;
|
|
99
199
|
let sample = '';
|
|
200
|
+
const orphans = [];
|
|
201
|
+
const orphanNames = [];
|
|
100
202
|
const walker = document.createTreeWalker(container, _SHOW_COMMENT, {
|
|
101
|
-
acceptNode: (node) => (node.textContent
|
|
203
|
+
acceptNode: (node) => (isVskMarkerText(node.textContent) ? _FILTER_ACCEPT : _FILTER_SKIP),
|
|
102
204
|
});
|
|
103
205
|
while (walker.nextNode()) {
|
|
104
|
-
const
|
|
206
|
+
const c = walker.currentNode;
|
|
207
|
+
const el = c.nextElementSibling;
|
|
105
208
|
if (el && underClaimedAncestor(el, container))
|
|
106
209
|
continue;
|
|
107
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
|
+
}
|
|
108
226
|
if (!sample && container.outerHTML)
|
|
109
227
|
sample = String(container.outerHTML).slice(0, 800);
|
|
110
228
|
}
|
|
229
|
+
const twinRes = scanTwins(container);
|
|
230
|
+
const issues = [];
|
|
111
231
|
if (unclaimed > 0) {
|
|
112
|
-
|
|
113
|
-
|
|
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}`);
|
|
114
295
|
return false;
|
|
115
296
|
}
|
|
116
297
|
return true;
|
|
@@ -122,18 +303,77 @@ function underClaimedAncestor(el, container) {
|
|
|
122
303
|
}
|
|
123
304
|
return false;
|
|
124
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
|
+
}
|
|
125
318
|
const _SHOW_COMMENT = 128;
|
|
126
319
|
export const _FILTER_ACCEPT = 1;
|
|
127
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
|
+
}
|
|
128
341
|
export function collectVskMarkers(container) {
|
|
129
342
|
const markers = [];
|
|
130
343
|
const walker = document.createTreeWalker(container, _SHOW_COMMENT, {
|
|
131
|
-
acceptNode: (node) => (node.textContent
|
|
344
|
+
acceptNode: (node) => (isVskMarkerText(node.textContent) ? _FILTER_ACCEPT : _FILTER_SKIP),
|
|
132
345
|
});
|
|
133
346
|
while (walker.nextNode())
|
|
134
347
|
markers.push(walker.currentNode);
|
|
135
348
|
return markers;
|
|
136
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
|
+
}
|
|
137
377
|
function adoptElement(marker, tag) {
|
|
138
378
|
const el = marker.nextElementSibling;
|
|
139
379
|
if (!el) {
|
|
@@ -147,6 +387,7 @@ function adoptElement(marker, tag) {
|
|
|
147
387
|
// defect: the layout's missing conditional span claim stole the h1 marker).
|
|
148
388
|
if (tag && el.tagName.toLowerCase() !== tag)
|
|
149
389
|
return null;
|
|
390
|
+
checkMarkerIdentity(marker, el);
|
|
150
391
|
marker.remove();
|
|
151
392
|
stripDirectTextNodes(el);
|
|
152
393
|
captureSsrElementChildren(el);
|
|
@@ -163,6 +404,7 @@ function adoptElementRaw(marker, tag) {
|
|
|
163
404
|
}
|
|
164
405
|
if (tag && el.tagName.toLowerCase() !== tag)
|
|
165
406
|
return null;
|
|
407
|
+
checkMarkerIdentity(marker, el);
|
|
166
408
|
marker.remove();
|
|
167
409
|
captureSsrElementChildren(el);
|
|
168
410
|
stampClaimed(el);
|
|
@@ -173,15 +415,15 @@ class WalkerEngine {
|
|
|
173
415
|
markers;
|
|
174
416
|
idx = 0;
|
|
175
417
|
// Elements already adopted by an earlier claim in this walk. Marker-only SSR
|
|
176
|
-
// can leave TWO
|
|
177
|
-
// the callee's own root marker — e.g. Link self-prefixes `<!--vsk-->`
|
|
178
|
-
// the shared walker claims its call-site marker), and a claim must never
|
|
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
|
|
179
421
|
// adopt the same node twice: double-adoption strips its SSR text a second
|
|
180
422
|
// time and shells the cursor so every later claim misses its element.
|
|
181
423
|
adopted = new WeakSet();
|
|
182
424
|
constructor(root, markers) {
|
|
183
425
|
this.root = root;
|
|
184
|
-
this.markers = markers.map((c) => ({ comment: c, state: 'unclaimed' }));
|
|
426
|
+
this.markers = markers.map((c) => ({ comment: c, state: 'unclaimed', identity: markerIdentity(c) }));
|
|
185
427
|
}
|
|
186
428
|
done() {
|
|
187
429
|
return this.idx >= this.markers.length;
|
|
@@ -203,7 +445,7 @@ class WalkerEngine {
|
|
|
203
445
|
recordAdopted(el) {
|
|
204
446
|
this.adopted.add(el);
|
|
205
447
|
}
|
|
206
|
-
// Marker-only SSR can stack several
|
|
448
|
+
// Marker-only SSR can stack several keyed marks before ONE element
|
|
207
449
|
// (call-site marker + the callee's own root marker). The FIRST claim adopts
|
|
208
450
|
// the element; the alias markers that immediately follow it are dead — they
|
|
209
451
|
// point at an already-claimed node and must be physically retired so the
|
|
@@ -242,6 +484,8 @@ class WalkerEngine {
|
|
|
242
484
|
// consuming it here would strip its SSR text, stamp a claim on
|
|
243
485
|
// a node this render does not own, and drift every later claim
|
|
244
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);
|
|
245
489
|
break;
|
|
246
490
|
}
|
|
247
491
|
this.idx++;
|
|
@@ -263,21 +507,31 @@ class WalkerEngine {
|
|
|
263
507
|
}
|
|
264
508
|
// Every claim parked itself — either because the render asked for more
|
|
265
509
|
// elements than SSR provided, or because the SSR element tag did not
|
|
266
|
-
// match the claim. Best effort: build a fresh element and
|
|
510
|
+
// match the claim. Best effort: build a fresh element and report.
|
|
267
511
|
//
|
|
268
|
-
// The
|
|
512
|
+
// The report is scoped to walks that still hold unconsumed markers
|
|
269
513
|
// (`this.idx < this.markers.length`): that is the signature of a real
|
|
270
514
|
// SSR/client structural divergence — a marker is ahead of the cursor but
|
|
271
515
|
// the claim could not adopt it (wrong tag). Once the walker is exhausted
|
|
272
516
|
// every further `nextElement` is a post-hydration re-render (reactive
|
|
273
517
|
// loop/if blocks re-running after the interval or an event) legitimately
|
|
274
|
-
// building fresh nodes —
|
|
275
|
-
// console every tick. SPA/client-only renders keep empty
|
|
276
|
-
// temp roots and never warn.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
+
}
|
|
280
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);
|
|
281
535
|
return document.createElement(tag || 'div');
|
|
282
536
|
}
|
|
283
537
|
claimOnly(tag) {
|
|
@@ -290,8 +544,10 @@ class WalkerEngine {
|
|
|
290
544
|
const el = tm.comment.nextElementSibling;
|
|
291
545
|
if (this.isAlreadyAdopted(tm, el))
|
|
292
546
|
continue;
|
|
293
|
-
if (tag && el && el.tagName.toLowerCase() !== tag)
|
|
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);
|
|
294
549
|
break;
|
|
550
|
+
}
|
|
295
551
|
this.idx++;
|
|
296
552
|
const adopted = adoptElementRaw(tm.comment, tag);
|
|
297
553
|
if (adopted === null) {
|
|
@@ -303,6 +559,10 @@ class WalkerEngine {
|
|
|
303
559
|
tm.state = 'claimed';
|
|
304
560
|
return adopted;
|
|
305
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);
|
|
306
566
|
return document.createElement(tag || 'div');
|
|
307
567
|
}
|
|
308
568
|
retireDetached() {
|
|
@@ -326,29 +586,63 @@ class WalkerEngine {
|
|
|
326
586
|
}
|
|
327
587
|
}
|
|
328
588
|
subWalker(rootEl) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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));
|
|
344
615
|
}
|
|
345
|
-
|
|
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
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
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) {
|
|
346
639
|
const strKey = String(key);
|
|
347
640
|
// Strict positional claim: the item claims the cursor's marker. Interior
|
|
348
641
|
// markers of the previously-claimed item are consumed by that item's own
|
|
349
642
|
// render, so by the time we get here the cursor points at this item's
|
|
350
643
|
// SSR root. Only EXACT ordering SSR == client is claimed; divergence
|
|
351
|
-
// 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).
|
|
352
646
|
for (let i = this.idx; i < this.markers.length; i++) {
|
|
353
647
|
const tm = this.markers[i];
|
|
354
648
|
if (tm.state === 'claimed')
|
|
@@ -357,20 +651,60 @@ class WalkerEngine {
|
|
|
357
651
|
if (this.isAlreadyAdopted(tm, el))
|
|
358
652
|
continue;
|
|
359
653
|
if (el && el.getAttribute('data-vsk-key') === strKey) {
|
|
360
|
-
|
|
361
|
-
this.recordAdopted(el);
|
|
654
|
+
const claim = this.adoptKeyedElement(tm, el);
|
|
362
655
|
this.retireAliases(el);
|
|
363
|
-
|
|
364
|
-
stripDirectTextNodes(el);
|
|
365
|
-
captureSsrElementChildren(el);
|
|
366
|
-
stampClaimed(el);
|
|
367
|
-
return { el };
|
|
656
|
+
return claim;
|
|
368
657
|
}
|
|
369
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
|
+
}
|
|
370
671
|
return null;
|
|
371
672
|
}
|
|
372
673
|
return null;
|
|
373
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
|
+
}
|
|
374
708
|
peekKey(key) {
|
|
375
709
|
const strKey = String(key);
|
|
376
710
|
// Non-consuming scan across EVERY remaining unclaimed marker: used to
|
|
@@ -438,45 +772,103 @@ export function hydrate(container, componentFn, props) {
|
|
|
438
772
|
assertFullyHydrated(container);
|
|
439
773
|
return result;
|
|
440
774
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
export function hydrateViewport(container, componentFn, props, rootMargin = 500) {
|
|
448
|
-
if (document.readyState !== 'complete') {
|
|
449
|
-
return new Promise((resolve) => {
|
|
450
|
-
const onLoad = () => {
|
|
451
|
-
window.removeEventListener('load', onLoad);
|
|
452
|
-
resolve(hydrateViewport(container, componentFn, props, rootMargin));
|
|
453
|
-
};
|
|
454
|
-
window.addEventListener('load', onLoad);
|
|
455
|
-
});
|
|
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;
|
|
456
781
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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;
|
|
469
805
|
}
|
|
470
|
-
|
|
471
|
-
|
|
806
|
+
catch {
|
|
807
|
+
return false;
|
|
472
808
|
}
|
|
473
809
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
+
}
|
|
479
867
|
const observer = new IntersectionObserver((entries) => {
|
|
868
|
+
if (!batchAlive(container, epoch, isCurrent)) {
|
|
869
|
+
observer.disconnect();
|
|
870
|
+
return finish();
|
|
871
|
+
}
|
|
480
872
|
const toHydrate = [];
|
|
481
873
|
for (const entry of entries) {
|
|
482
874
|
if (entry.isIntersecting) {
|
|
@@ -491,52 +883,70 @@ export function hydrateViewport(container, componentFn, props, rootMargin = 500)
|
|
|
491
883
|
}
|
|
492
884
|
}
|
|
493
885
|
if (toHydrate.length > 0) {
|
|
886
|
+
if (!batchAlive(container, epoch, isCurrent)) {
|
|
887
|
+
observer.disconnect();
|
|
888
|
+
return finish();
|
|
889
|
+
}
|
|
494
890
|
const w = createHydrateWalker(container, toHydrate);
|
|
495
891
|
runInHydrateBlock(() => componentFn(props || {}, new Map(), w));
|
|
496
892
|
}
|
|
497
893
|
if (held.size === 0) {
|
|
498
894
|
observer.disconnect();
|
|
499
895
|
assertFullyHydrated(container);
|
|
500
|
-
|
|
896
|
+
return finish();
|
|
501
897
|
}
|
|
502
898
|
}, { rootMargin: `${rootMargin}px` });
|
|
899
|
+
cancelFn = () => {
|
|
900
|
+
try {
|
|
901
|
+
observer.disconnect();
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
// ignore disconnect failures
|
|
905
|
+
}
|
|
906
|
+
finish();
|
|
907
|
+
};
|
|
503
908
|
for (const marker of deferredMarkers) {
|
|
504
909
|
const el = marker.nextElementSibling;
|
|
505
910
|
if (el)
|
|
506
911
|
observer.observe(el);
|
|
507
912
|
}
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
return Promise.resolve();
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
return Object.assign(promise, { cancel: () => cancelFn() });
|
|
512
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
|
+
*/
|
|
513
928
|
export function hydrateIdle(container, componentFn, props, options = {}) {
|
|
514
|
-
const
|
|
515
|
-
const
|
|
929
|
+
const epoch = navEpoch();
|
|
930
|
+
const isCurrent = options.isCurrent;
|
|
516
931
|
const timeout = options.timeout || 3000;
|
|
517
|
-
let idx = 0;
|
|
518
932
|
const rIC = window.requestIdleCallback || ((cb) => setTimeout(cb, 50));
|
|
519
933
|
const cIC = window.cancelIdleCallback || clearTimeout;
|
|
520
934
|
let rafId = null;
|
|
521
935
|
let cancelled = false;
|
|
522
|
-
|
|
523
|
-
|
|
936
|
+
let ran = false;
|
|
937
|
+
function runOnce() {
|
|
938
|
+
if (cancelled || ran)
|
|
524
939
|
return;
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
rafId = rIC(processChunk, { timeout });
|
|
534
|
-
}
|
|
535
|
-
else if (idx >= allMarkers.length) {
|
|
536
|
-
assertFullyHydrated(container);
|
|
537
|
-
}
|
|
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);
|
|
538
948
|
}
|
|
539
|
-
rafId = rIC(
|
|
949
|
+
rafId = rIC(runOnce, { timeout });
|
|
540
950
|
return {
|
|
541
951
|
cancel() {
|
|
542
952
|
cancelled = true;
|
|
@@ -549,11 +959,13 @@ export function hydrateIdle(container, componentFn, props, options = {}) {
|
|
|
549
959
|
}
|
|
550
960
|
export function needsHydration(container) {
|
|
551
961
|
const walker = document.createTreeWalker(container, _SHOW_COMMENT, {
|
|
552
|
-
acceptNode: (node) => (node.textContent
|
|
962
|
+
acceptNode: (node) => (isVskMarkerText(node.textContent) ? _FILTER_ACCEPT : _FILTER_SKIP),
|
|
553
963
|
});
|
|
554
964
|
return walker.nextNode() !== null;
|
|
555
965
|
}
|
|
556
966
|
export function hydrateOnInteraction(container, componentFn, props, options = {}) {
|
|
967
|
+
const epoch = navEpoch();
|
|
968
|
+
const isCurrent = options.isCurrent;
|
|
557
969
|
const events = options.events || ['click', 'touchstart', 'focus', 'mouseenter'];
|
|
558
970
|
let hydrated = false;
|
|
559
971
|
function trigger(_eventType) {
|
|
@@ -563,6 +975,10 @@ export function hydrateOnInteraction(container, componentFn, props, options = {}
|
|
|
563
975
|
for (const ev of events) {
|
|
564
976
|
container.removeEventListener(ev, handler);
|
|
565
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;
|
|
566
982
|
const markers = collectVskMarkers(container);
|
|
567
983
|
if (markers.length > 0) {
|
|
568
984
|
const walker = createHydrateWalker(container, markers);
|
|
@@ -584,7 +1000,7 @@ export function hydrationCount(container) {
|
|
|
584
1000
|
let count = 0;
|
|
585
1001
|
const walker = document.createTreeWalker(container, _SHOW_COMMENT, {
|
|
586
1002
|
acceptNode: (node) => {
|
|
587
|
-
if (node.textContent
|
|
1003
|
+
if (isVskMarkerText(node.textContent)) {
|
|
588
1004
|
count++;
|
|
589
1005
|
return _FILTER_ACCEPT;
|
|
590
1006
|
}
|