@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/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
- if (el.childNodes[i].nodeType === 3)
76
- el.childNodes[i].remove();
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
- * Dev-mode check that every `<!--vsk-->` marker in `container` has been
91
- * claimed by the most recent full hydration pass. Markers that remain belong
92
- * to SSR regions the client never rendered (or rendered differently) — orphaned
93
- * phantom content that will never be cleaned up. Returns `false` and warns when
94
- * any such marker survives. Runs automatically at the end of `hydrate`,
95
- * `hydrateInitial`, and every hydration strategy's completion.
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
- export function assertFullyHydrated(container) {
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 === 'vsk' ? _FILTER_ACCEPT : _FILTER_SKIP),
203
+ acceptNode: (node) => (isVskMarkerText(node.textContent) ? _FILTER_ACCEPT : _FILTER_SKIP),
102
204
  });
103
205
  while (walker.nextNode()) {
104
- const el = walker.currentNode.nextElementSibling;
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
- devWarn(`${unclaimed} hydration marker${unclaimed === 1 ? '' : 's'} never claimed; ` +
113
- `SSR and client markup diverge. Container fragment:\n${sample}`);
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 === 'vsk' ? _FILTER_ACCEPT : _FILTER_SKIP),
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 `<!--vsk-->` marks on the same element (the call site and
177
- // the callee's own root marker — e.g. Link self-prefixes `<!--vsk-->` and
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 `<!--vsk-->` marks before ONE element
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 warn in dev.
510
+ // match the claim. Best effort: build a fresh element and report.
267
511
  //
268
- // The warning is scoped to walks that still hold unconsumed markers
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 — silent, so a typing `for` loop does not spam the
275
- // console every tick. SPA/client-only renders keep empty marker lists on
276
- // temp roots and never warn.
277
- if (__hydrateDevMode && this.root && this.idx < this.markers.length) {
278
- devWarn(`hydration claim missed <${tag || 'element'}> (${this.markers.length - this.idx} markers unconsumed); the client rendered more or different content than SSR.` +
279
- (this.root.outerHTML ? ` Fragment:\n${String(this.root.outerHTML).slice(0, 800)}` : ''));
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
- const subMarkers = this.markers.slice(this.idx).filter((m) => {
330
- if (m.state === 'claimed')
331
- return false;
332
- if (rootEl === m.comment)
333
- return true;
334
- if (!rootEl || !m.comment)
335
- return false;
336
- if (typeof rootEl.contains === 'function')
337
- return rootEl.contains(m.comment);
338
- return false;
339
- });
340
- this.idx += subMarkers.length;
341
- for (const m of subMarkers)
342
- m.state = 'claimed';
343
- return new WalkerEngine(rootEl, subMarkers.map((m) => m.comment));
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
- claimByKey(key) {
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
- tm.state = 'claimed';
361
- this.recordAdopted(el);
654
+ const claim = this.adoptKeyedElement(tm, el);
362
655
  this.retireAliases(el);
363
- tm.comment.remove();
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
- // Viewport viewport hydration: markers below the fold are held until the
443
- // observer reports them intersecting. The hold is tracked explicitly (no
444
- // `vsk-hold` text mutation, no `_observed` monkey-patch) so the marker state
445
- // machine stays unambiguous and `assertFullyHydrated` can count held markers.
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
- const allMarkers = collectVskMarkers(container);
458
- const viewportMarkers = [];
459
- const deferredMarkers = [];
460
- for (const marker of allMarkers) {
461
- const el = marker.nextElementSibling;
462
- if (!el) {
463
- deferredMarkers.push(marker);
464
- continue;
465
- }
466
- const rect = el.getBoundingClientRect();
467
- if (rect.bottom < -rootMargin || rect.top > window.innerHeight + rootMargin) {
468
- deferredMarkers.push(marker);
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
- else {
471
- viewportMarkers.push(marker);
806
+ catch {
807
+ return false;
472
808
  }
473
809
  }
474
- const held = new Set(deferredMarkers);
475
- const viewportWalker = createHydrateWalker(container, viewportMarkers);
476
- runInHydrateBlock(() => componentFn(props || {}, new Map(), viewportWalker));
477
- if (deferredMarkers.length > 0) {
478
- return new Promise((resolve) => {
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
- resolve();
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
- assertFullyHydrated(container);
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 allMarkers = collectVskMarkers(container);
515
- const chunkSize = options.chunkSize || 10;
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
- function processChunk(deadline) {
523
- if (cancelled)
936
+ let ran = false;
937
+ function runOnce() {
938
+ if (cancelled || ran)
524
939
  return;
525
- const end = Math.min(idx + chunkSize, allMarkers.length);
526
- const chunk = allMarkers.slice(idx, end);
527
- idx = end;
528
- if (chunk.length > 0) {
529
- const walker = createHydrateWalker(container, chunk);
530
- runInHydrateBlock(() => componentFn(props || {}, new Map(), walker));
531
- }
532
- if (idx < allMarkers.length && (!deadline || deadline.timeRemaining() > 0 || deadline.didTimeout)) {
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(processChunk, { timeout });
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 === 'vsk' ? _FILTER_ACCEPT : _FILTER_SKIP),
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 === 'vsk') {
1003
+ if (isVskMarkerText(node.textContent)) {
588
1004
  count++;
589
1005
  return _FILTER_ACCEPT;
590
1006
  }