@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/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
- 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;
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
- * Dev-mode check that every `<!--vsk-->` marker in `container` has been
81
- * claimed by the most recent full hydration pass. Markers that remain belong
82
- * to SSR regions the client never rendered (or rendered differently) — orphaned
83
- * phantom content that will never be cleaned up. Returns `false` and warns when
84
- * any such marker survives. Runs automatically at the end of `hydrate`,
85
- * `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.
86
155
  */
87
- 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) {
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 === 'vsk' ? _FILTER_ACCEPT : _FILTER_SKIP),
203
+ acceptNode: (node) => (isVskMarkerText(node.textContent) ? _FILTER_ACCEPT : _FILTER_SKIP),
92
204
  });
93
205
  while (walker.nextNode()) {
94
- const el = walker.currentNode.nextElementSibling;
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
- devWarn(`${unclaimed} hydration marker${unclaimed === 1 ? '' : 's'} never claimed; ` +
103
- `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}`);
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 === 'vsk' ? _FILTER_ACCEPT : _FILTER_SKIP),
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 warn in dev.
510
+ // match the claim. Best effort: build a fresh element and report.
191
511
  //
192
- // The warning is scoped to walks that still hold unconsumed markers
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 — silent, so a typing `for` loop does not spam the
199
- // console every tick. SPA/client-only renders keep empty marker lists on
200
- // temp roots and never warn.
201
- if (__hydrateDevMode && this.root && this.idx < this.markers.length) {
202
- devWarn(`hydration claim missed <${tag || 'element'}> (${this.markers.length - this.idx} markers unconsumed); the client rendered more or different content than SSR.` +
203
- (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
+ }
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
- const subMarkers = this.markers.slice(this.idx).filter((m) => {
209
- if (m.state === 'claimed')
210
- return false;
211
- if (rootEl === m.comment)
212
- return true;
213
- if (!rootEl || !m.comment)
214
- return false;
215
- if (typeof rootEl.contains === 'function')
216
- return rootEl.contains(m.comment);
217
- return false;
218
- });
219
- this.idx += subMarkers.length;
220
- for (const m of subMarkers)
221
- m.state = 'claimed';
222
- // A component's SSR content always carries interior `<!--vsk-->` markers
223
- // that the child's own hydrator claims. Exception: plain JS components
224
- // (lucide icons etc.) render inside the compiler's boundary wrapper
225
- // (`<!--vsk--><span style="display:contents">`) with NO interior markers.
226
- // An empty marker list would send the child to `nextElement`'s fresh-node
227
- // fallback, orphaning the SSR root inside the claimed wrapper (duplicate
228
- // icons, non-reactive if/else blocks after hydration). Fall back to
229
- // positional claiming of the wrapper's element children so those
230
- // components adopt their SSR root in place, exactly like `.vsk` roots do.
231
- if (subMarkers.length === 0 && rootEl && rootEl.tagName === 'SPAN' && rootEl.style && rootEl.style.display === 'contents') {
232
- return createHydrateChildWalker(rootEl);
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
- claimByKey(key) {
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
- tm.state = 'claimed';
250
- tm.comment.remove();
251
- stripDirectTextNodes(el);
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
- // Viewport viewport hydration: markers below the fold are held until the
312
- // observer reports them intersecting. The hold is tracked explicitly (no
313
- // `vsk-hold` text mutation, no `_observed` monkey-patch) so the marker state
314
- // machine stays unambiguous and `assertFullyHydrated` can count held markers.
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
- const allMarkers = collectVskMarkers(container);
327
- const viewportMarkers = [];
328
- const deferredMarkers = [];
329
- for (const marker of allMarkers) {
330
- const el = marker.nextElementSibling;
331
- if (!el) {
332
- deferredMarkers.push(marker);
333
- continue;
334
- }
335
- const rect = el.getBoundingClientRect();
336
- if (rect.bottom < -rootMargin || rect.top > window.innerHeight + rootMargin) {
337
- 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;
338
805
  }
339
- else {
340
- viewportMarkers.push(marker);
806
+ catch {
807
+ return false;
341
808
  }
342
809
  }
343
- const held = new Set(deferredMarkers);
344
- const viewportWalker = createHydrateWalker(container, viewportMarkers);
345
- runInHydrateBlock(() => componentFn(props || {}, new Map(), viewportWalker));
346
- if (deferredMarkers.length > 0) {
347
- 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
+ }
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
- resolve();
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
- assertFullyHydrated(container);
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 allMarkers = collectVskMarkers(container);
384
- const chunkSize = options.chunkSize || 10;
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
- function processChunk(deadline) {
392
- if (cancelled)
936
+ let ran = false;
937
+ function runOnce() {
938
+ if (cancelled || ran)
393
939
  return;
394
- const end = Math.min(idx + chunkSize, allMarkers.length);
395
- const chunk = allMarkers.slice(idx, end);
396
- idx = end;
397
- if (chunk.length > 0) {
398
- const walker = createHydrateWalker(container, chunk);
399
- runInHydrateBlock(() => componentFn(props || {}, new Map(), walker));
400
- }
401
- if (idx < allMarkers.length && (!deadline || deadline.timeRemaining() > 0 || deadline.didTimeout)) {
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(processChunk, { timeout });
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 === 'vsk' ? _FILTER_ACCEPT : _FILTER_SKIP),
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 === 'vsk') {
1003
+ if (isVskMarkerText(node.textContent)) {
457
1004
  count++;
458
1005
  return _FILTER_ACCEPT;
459
1006
  }