@vesk/compiler 0.2.93 → 0.2.95

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.
@@ -2,7 +2,7 @@ import { walk } from 'zimmerframe';
2
2
  import { print } from 'esrap';
3
3
  import ts from 'esrap/languages/ts';
4
4
  import tsx from 'esrap/languages/tsx';
5
- import { StaticNode, TextNode, DynamicBinding, OpaqueDynamicRegion, MapRegion, WhileLoop, SwitchBlock, TryCatch, ForLoop, TrackDecl, RuntimeStatement, BreakSignal, ComponentRef, ComponentCall, ServerBlock, ClientBlock, HeadBlock, SlotNode, PropSlot, PropSlotRender, } from '@vesk/compiler/src/ir';
5
+ import { StaticNode, TextNode, DynamicBinding, OpaqueDynamicRegion, MapRegion, WhileLoop, SwitchBlock, TryCatch, ForLoop, TrackDecl, RuntimeStatement, BreakSignal, ComponentRef, ComponentCall, regionMarkerId, ServerBlock, ClientBlock, HeadBlock, SlotNode, PropSlot, PropSlotRender, } from '@vesk/compiler/src/ir';
6
6
  import { parse } from '@vesk/compiler/src/parser';
7
7
  import { generateIR } from '@vesk/compiler/src/ir-generator';
8
8
  import { transformTopLevelForActions } from '@vesk/compiler/src/actions';
@@ -535,6 +535,10 @@ class Ctx {
535
535
  markerless = true;
536
536
  inTryBody = false;
537
537
  claimStatic = false;
538
+ // Markerless only: the next real claim carries a compiler-generated static
539
+ // class descriptor so a same-tag but structurally different SSR slot is
540
+ // substituted instead of adopted (e.g. an empty-state <div> vs a list <div>).
541
+ descriptorClaim = false;
538
542
  // While set, the next StaticNode claimed in hydrate mode uses `__root` (the
539
543
  // item element already claimed by key) instead of walking for a new element.
540
544
  rootClaim = false;
@@ -685,6 +689,7 @@ function emitNode(ctx, node, tracked, effectsVar, parentVar, compPrefix = '__com
685
689
  ctx.push(` const __slot = createLayoutSlot(${ctx.walker}, ${parentVar});`);
686
690
  ctx.push(` const __child = props.children(__slot.walker);`);
687
691
  ctx.push(` if (__child && typeof __child.then === 'function') __pendingChild = __slot.track(__child);`);
692
+ ctx.push(` else if (__child && __child.nodeType === 11) ${parentVar}.appendChild(__child);`);
688
693
  ctx.push(` } else {`);
689
694
  ctx.push(` ${parentVar}.appendChild(props.children);`);
690
695
  ctx.push(` }`);
@@ -738,6 +743,24 @@ const PROPERTY_ATTRS = {
738
743
  option: new Set(['selected']),
739
744
  progress: new Set(['value']),
740
745
  };
746
+ /**
747
+ * Build the markerless structural descriptor for a claimed element. Only a
748
+ * source-authored static class is included: dynamic classes are runtime values
749
+ * and must never be validated (SSR/client first paint may legitimately differ).
750
+ * The descriptor is a JS-only argument to the walker; it never reaches SSR HTML.
751
+ */
752
+ function staticClassDescriptor(node) {
753
+ const hasDynamicClass = node.children.some((child) => child instanceof DynamicBinding && child.kind === 'attribute' && child.target === 'class');
754
+ if (hasDynamicClass)
755
+ return null;
756
+ const attr = node.attributes.find((a) => a.name === 'class');
757
+ if (!attr)
758
+ return null;
759
+ const value = attr.value.trim();
760
+ if (!value)
761
+ return null;
762
+ return JSON.stringify({ class: value });
763
+ }
741
764
  function emitStatic(ctx, node, tracked, effectsVar, skipK = 0, regionBudget) {
742
765
  const el = ctx.n();
743
766
  if (ctx.hydrate) {
@@ -761,13 +784,21 @@ function emitStatic(ctx, node, tracked, effectsVar, skipK = 0, regionBudget) {
761
784
  // are claimed on their own sub-walker and carry their own residue math.
762
785
  const skipExpr = regionBudget && ctx.markerless ? markerlessSkipExpr(regionBudget, skipK) : skipK;
763
786
  const skipArg = ctx.markerless ? `, ${skipExpr}` : '';
787
+ // A component root or region branch root may be the slot where the client's
788
+ // initial branch differs from SSR. Validate its static class so a same-tag
789
+ // wrong branch is substituted, not adopted. One-shot: only the first real
790
+ // claim in that root/branch carries the descriptor.
791
+ const wantsDescriptor = ctx.markerless && ctx.descriptorClaim;
792
+ ctx.descriptorClaim = false;
793
+ const descriptor = wantsDescriptor && !rootClaim ? staticClassDescriptor(node) : null;
794
+ const descriptorArg = descriptor ? `, ${descriptor}` : '';
764
795
  // `rootClaim` marks the keyed renderItem root: claimed items supply the
765
796
  // SSR element as `__root`, fresh items must CREATE one. The walker is never
766
797
  // valid here as a fallback — its cursor doesn't advance past claimByKey, so
767
798
  // `nextElement` would steal a neighboring SSR node to render the new item.
768
799
  const claimedExpr = rootClaim
769
800
  ? `__root || document.createElement(${JSON.stringify(node.tag)})`
770
- : `${ctx.walker}.nextElement(${JSON.stringify(node.tag)}${skipArg})`;
801
+ : `${ctx.walker}.nextElement(${JSON.stringify(node.tag)}${skipArg}${descriptorArg})`;
771
802
  ctx.push(`const ${el} = ${claimedExpr};`);
772
803
  if (regionBudget && ctx.markerless)
773
804
  ctx.push(`${regionBudget} = 0;`);
@@ -1269,6 +1300,65 @@ function emitFragment(ctx, nodes, tracked, effectsVar) {
1269
1300
  ctx.push(`return $f; })();`);
1270
1301
  return frag;
1271
1302
  }
1303
+ /**
1304
+ * True when any node in the subtree is a layout `{props.children}` slot.
1305
+ *
1306
+ * A slot must be hydrated on the walker scoped to the element that will
1307
+ * actually contain it. Building the children fragment eagerly on the caller's
1308
+ * walker is only correct when the callee appends that fragment to its own
1309
+ * mount point. A component that forwards children through a scoped container
1310
+ * (e.g. a shell that renders `{props.children}` inside `<main>`) invokes the
1311
+ * child hydrator with a sub-walker; passing an eagerly-built fragment skips
1312
+ * that scope and lets the page claim the shell's own SSR root.
1313
+ */
1314
+ function containsSlot(nodes) {
1315
+ for (const node of nodes) {
1316
+ if (node instanceof SlotNode)
1317
+ return true;
1318
+ if (node instanceof StaticNode) {
1319
+ if (containsSlot(node.children))
1320
+ return true;
1321
+ }
1322
+ else if (node instanceof ComponentCall) {
1323
+ if (containsSlot(node.children))
1324
+ return true;
1325
+ }
1326
+ else if (node instanceof OpaqueDynamicRegion) {
1327
+ if (containsSlot(node.consequentNodes) || containsSlot(node.alternateNodes))
1328
+ return true;
1329
+ }
1330
+ else if (node instanceof MapRegion) {
1331
+ if (containsSlot(node.bodyTemplate) || containsSlot(node.alternateNodes))
1332
+ return true;
1333
+ }
1334
+ else if (node instanceof WhileLoop) {
1335
+ if (containsSlot(node.bodyTemplate))
1336
+ return true;
1337
+ }
1338
+ else if (node instanceof ForLoop) {
1339
+ if (containsSlot(node.bodyTemplate))
1340
+ return true;
1341
+ }
1342
+ else if (node instanceof SwitchBlock) {
1343
+ for (const c of node.cases)
1344
+ if (containsSlot(c.body))
1345
+ return true;
1346
+ }
1347
+ else if (node instanceof TryCatch) {
1348
+ if (containsSlot(node.bodyTemplate) || containsSlot(node.catchBody))
1349
+ return true;
1350
+ }
1351
+ else if (node instanceof ServerBlock || node instanceof ClientBlock || node instanceof HeadBlock) {
1352
+ if (containsSlot(node.children))
1353
+ return true;
1354
+ }
1355
+ else if (node instanceof PropSlot) {
1356
+ if (containsSlot(node.body))
1357
+ return true;
1358
+ }
1359
+ }
1360
+ return false;
1361
+ }
1272
1362
  function emitComponentCall(ctx, node, tracked, effectsVar, parentVar, compPrefix = '__components', skipK = 0, regionBudget) {
1273
1363
  const propsEntries = node.props.map((p) => {
1274
1364
  if (typeof p.value === 'string')
@@ -1328,6 +1418,11 @@ function emitComponentCall(ctx, node, tracked, effectsVar, parentVar, compPrefix
1328
1418
  ? `reactiveProps({ ${propsEntries.join(', ')} })`
1329
1419
  : `{ ${propsEntries.join(', ')} }`);
1330
1420
  if (ctx.hydrate) {
1421
+ // A component call is itself the root claim site. A compiled child carries
1422
+ // its own root descriptor; a plain target has no IR root to describe. In
1423
+ // either case the caller's pending branch descriptor must not leak to a
1424
+ // following sibling.
1425
+ ctx.descriptorClaim = false;
1331
1426
  const access = calleeExpr
1332
1427
  ?? (ctx.importedNames.has(node.componentName)
1333
1428
  ? node.componentName
@@ -1406,20 +1501,56 @@ function emitComponentCall(ctx, node, tracked, effectsVar, parentVar, compPrefix
1406
1501
  // fresh and let the runtime component adopt the existing root.
1407
1502
  if (wipeMount || ctx.selfClaimNames.has(node.componentName))
1408
1503
  ctx.hydrate = false;
1409
- ctx.push(`const ${frag} = (() => { const $f = document.createDocumentFragment();`);
1410
- const savedEffects = ctx.effects;
1411
- ctx.effects = [];
1412
- for (const child of regChildren) {
1413
- const childVar = emitNode(ctx, child, tracked, effectsVar, '$f');
1414
- if (childVar)
1415
- ctx.push(`$f.appendChild(${childVar});`);
1504
+ // A slot nested under a compiled component must be built by that
1505
+ // component's scoped walker, not the caller's. Pass children as a lazy
1506
+ // hydrator so the callee can invoke it with the walker for the element
1507
+ // that actually contains the slot (e.g. `<main>` in a shell). Eagerly
1508
+ // building the fragment here hydrates the page against the shell's own
1509
+ // SSR root, which detaches the shell on hydration.
1510
+ const lazySlotChildren = ctx.hydrate && !plainTarget && !ctx.selfClaimNames.has(node.componentName) && containsSlot(regChildren);
1511
+ if (lazySlotChildren) {
1512
+ const fnVar = ctx.n();
1513
+ const savedLines = ctx.lines;
1514
+ const savedEffects = ctx.effects;
1515
+ const savedWalker = ctx.walker;
1516
+ ctx.lines = [];
1517
+ ctx.effects = [];
1518
+ ctx.walker = '__childWalker';
1519
+ ctx.push(`const $f = document.createDocumentFragment();`);
1520
+ for (const child of regChildren) {
1521
+ const childVar = emitNode(ctx, child, tracked, effectsVar, '$f');
1522
+ if (childVar)
1523
+ ctx.push(`$f.appendChild(${childVar});`);
1524
+ }
1525
+ for (const eff of ctx.effects)
1526
+ ctx.push(effectsVar ? `${effectsVar}.push(${effectBlockToHandlerExpr(eff)});` : eff);
1527
+ ctx.push(`return $f;`);
1528
+ const body = ctx.lines.join('\n');
1529
+ ctx.lines = savedLines;
1530
+ ctx.effects = savedEffects;
1531
+ ctx.walker = savedWalker;
1532
+ const asyncKw = ctx.isAsyncScope ? 'async ' : '';
1533
+ ctx.push(`const ${fnVar} = ${asyncKw}(__childWalker) => {`);
1534
+ ctx.push(indent(body));
1535
+ ctx.push(`};`);
1536
+ propsEntries.push(`children: ${fnVar}`);
1537
+ }
1538
+ else {
1539
+ ctx.push(`const ${frag} = (() => { const $f = document.createDocumentFragment();`);
1540
+ const savedEffects = ctx.effects;
1541
+ ctx.effects = [];
1542
+ for (const child of regChildren) {
1543
+ const childVar = emitNode(ctx, child, tracked, effectsVar, '$f');
1544
+ if (childVar)
1545
+ ctx.push(`$f.appendChild(${childVar});`);
1546
+ }
1547
+ for (const eff of ctx.effects)
1548
+ ctx.push(effectsVar ? `${effectsVar}.push(${effectBlockToHandlerExpr(eff)});` : eff);
1549
+ ctx.effects = savedEffects;
1550
+ ctx.push(`return $f; })();`);
1551
+ propsEntries.push(`children: ${frag}`);
1416
1552
  }
1417
- for (const eff of ctx.effects)
1418
- ctx.push(effectsVar ? `${effectsVar}.push(${effectBlockToHandlerExpr(eff)});` : eff);
1419
- ctx.effects = savedEffects;
1420
1553
  ctx.hydrate = savedHydrate;
1421
- ctx.push(`return $f; })();`);
1422
- propsEntries.push(`children: ${frag}`);
1423
1554
  }
1424
1555
  // Deposit the residue for the child's first top-level claim (it runs on
1425
1556
  // this same walker with its own zero `topPendingSkip`). A region budget is
@@ -1667,7 +1798,7 @@ function emitTryCatch(ctx, node, tracked, effectsVar, parentVar, skipK = 0, regi
1667
1798
  ${effArr}.length = 0;
1668
1799
  __cleanup(${anchor}, ${endAnchor});
1669
1800
  try { ${asyncKw}${tryRenderComp}(); } catch(${catchParam}) { ${hasCatch ? `${asyncKw}${catchRenderComp}(${catchParam});` : 'throw ${catchParam};'} }
1670
- });
1801
+ }, true);
1671
1802
  }`);
1672
1803
  return null;
1673
1804
  }
@@ -1675,13 +1806,14 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1675
1806
  const hyd = ctx.hydrate;
1676
1807
  const parent = parentVar || '$root';
1677
1808
  const budget = hyd ? regionBudgetVar(ctx, skipK, regionBudget) : null;
1809
+ const rid = regionMarkerId(node);
1678
1810
  const isOpaque = (n) => !!n && typeof n.condition !== 'undefined';
1679
1811
  if (hyd && node.alternateNodes.length === 1 && isOpaque(node.alternateNodes[0])) {
1680
1812
  const chain = [];
1681
1813
  let cur = node;
1682
1814
  let finalElse = null;
1683
1815
  while (cur) {
1684
- chain.push({ cond: transformTracked(cur.condition, tracked), nodes: cur.consequentNodes });
1816
+ chain.push({ cond: transformTracked(cur.condition, tracked), nodes: cur.consequentNodes, id: regionMarkerId(cur) });
1685
1817
  if (cur.alternateNodes.length === 1 && isOpaque(cur.alternateNodes[0])) {
1686
1818
  cur = cur.alternateNodes[0];
1687
1819
  }
@@ -1706,6 +1838,9 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1706
1838
  ctx.push(`const ${rn} = ${fnOpen}`);
1707
1839
  const cl = ctx.n();
1708
1840
  ctx.push(indent(`const ${cl} = [];`));
1841
+ const savedDescriptor = ctx.descriptorClaim;
1842
+ if (hyd && ctx.markerless)
1843
+ ctx.descriptorClaim = true;
1709
1844
  for (const n of chain[i].nodes) {
1710
1845
  const savedClaim = ctx.claimStatic;
1711
1846
  if (hyd)
@@ -1715,6 +1850,7 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1715
1850
  if (v)
1716
1851
  ctx.push(indent(`${cl}.push(${v});`));
1717
1852
  }
1853
+ ctx.descriptorClaim = savedDescriptor;
1718
1854
  ctx.push(indent(`__place(${anchor}, ${endAnchor}, ${cl}, ${parent});`));
1719
1855
  ctx.push(`};`);
1720
1856
  }
@@ -1724,6 +1860,9 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1724
1860
  ctx.push(`const ${finalRender} = ${fnOpen}`);
1725
1861
  const cl2 = ctx.n();
1726
1862
  ctx.push(indent(`const ${cl2} = [];`));
1863
+ const savedDescriptor = ctx.descriptorClaim;
1864
+ if (hyd && ctx.markerless)
1865
+ ctx.descriptorClaim = true;
1727
1866
  for (const n of finalElse) {
1728
1867
  const savedClaim = ctx.claimStatic;
1729
1868
  if (hyd)
@@ -1733,15 +1872,10 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1733
1872
  if (v)
1734
1873
  ctx.push(indent(`${cl2}.push(${v});`));
1735
1874
  }
1875
+ ctx.descriptorClaim = savedDescriptor;
1736
1876
  ctx.push(indent(`__place(${anchor}, ${endAnchor}, ${cl2}, ${parent});`));
1737
1877
  ctx.push(`};`);
1738
1878
  }
1739
- let init = `if (${chain[0].cond}) { ${asyncKw}${renderNames[0]}(); }`;
1740
- for (let i = 1; i < chain.length; i++)
1741
- init += ` else if (${chain[i].cond}) { ${asyncKw}${renderNames[i]}(); }`;
1742
- if (finalRender)
1743
- init += ` else { ${asyncKw}${finalRender}(); }`;
1744
- ctx.push(init);
1745
1879
  // Resolve which branch index the chain is in, for any chain depth. The last
1746
1880
  // fallback is `chain.length` when a final `else` exists, otherwise -1 (no
1747
1881
  // branch renders). Build the nested ternary from the innermost condition
@@ -1751,13 +1885,37 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1751
1885
  branchInitFallback = `(${chain[i].cond} ? ${i} : ${branchInitFallback})`;
1752
1886
  }
1753
1887
  const branchInit = `(${branchInitFallback})`;
1888
+ // Snapshot the branch chosen by the INITIAL render before claiming it. The
1889
+ // effect flush runs after the component body, so a tracked cell can change
1890
+ // in between (bootstrap hydration, a child island's body, an inline
1891
+ // statement). Initializing `__iv` at flush time would silently adopt the
1892
+ // new branch while the DOM still held the old one; the first effect run
1893
+ // must compare against the branch that actually rendered.
1894
+ const branchSnapshot = ctx.n();
1895
+ ctx.push(`const ${branchSnapshot} = ${branchInit};`);
1896
+ if (!ctx.markerless) {
1897
+ for (let i = 0; i < chain.length; i++) {
1898
+ ctx.push(`if (__hydrate) __reconcileRegion(${parent}, '${chain[i].id}', ${branchSnapshot} === ${i} ? 0 : 1, ${ctx.walker});`);
1899
+ }
1900
+ }
1901
+ else {
1902
+ const chainBranches = chain.map((c) => c.nodes);
1903
+ if (finalElse)
1904
+ chainBranches.push(finalElse);
1905
+ emitStructuralRegionDiscard(ctx, chainBranches, branchSnapshot, budget);
1906
+ }
1907
+ let init = `if (${branchSnapshot} === 0) { ${asyncKw}${renderNames[0]}(); }`;
1908
+ for (let i = 1; i < chain.length; i++)
1909
+ init += ` else if (${branchSnapshot} === ${i}) { ${asyncKw}${renderNames[i]}(); }`;
1910
+ if (finalRender)
1911
+ init += ` else { ${asyncKw}${finalRender}(); }`;
1912
+ ctx.push(init);
1754
1913
  let effInner = `if (__new === 0) { ${asyncKw}${renderNames[0]}(); }`;
1755
1914
  for (let i = 1; i < chain.length; i++)
1756
1915
  effInner += ` else if (__new === ${i}) { ${asyncKw}${renderNames[i]}(); }`;
1757
1916
  if (finalRender)
1758
1917
  effInner += ` else { ${asyncKw}${finalRender}(); }`;
1759
1918
  const effBody = `effect(${ctx.isAsyncScope ? 'async () => {' : '() => {'}
1760
- if (__first) { __first = false; __iv = ${branchInit}; return; }
1761
1919
  const __new = ${branchInit};
1762
1920
  if (__new !== __iv) {
1763
1921
  for (const e of ${effectsVar}) destroy_block(e);
@@ -1766,18 +1924,16 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1766
1924
  ${effInner}
1767
1925
  __iv = __new;
1768
1926
  }
1769
- });`;
1927
+ }, true);`;
1770
1928
  if (effTarget) {
1771
1929
  ctx.push(`${effTarget}.push((() => {
1772
- let __iv = ${branchInit};
1773
- let __first = true;
1930
+ let __iv = ${branchSnapshot};
1774
1931
  return ${effBody}
1775
1932
  })());`);
1776
1933
  }
1777
1934
  else {
1778
1935
  ctx.effects.push(`{
1779
- let __iv = ${branchInit};
1780
- let __first = true;
1936
+ let __iv = ${branchSnapshot};
1781
1937
  ${effBody}
1782
1938
  }`);
1783
1939
  }
@@ -1805,6 +1961,9 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1805
1961
  ctx.push(indent(`const ${conFrag} = document.createDocumentFragment();`));
1806
1962
  if (hyd)
1807
1963
  ctx.push(indent(`const __cl = [];`));
1964
+ const savedConDescriptor = ctx.descriptorClaim;
1965
+ if (hyd && ctx.markerless)
1966
+ ctx.descriptorClaim = true;
1808
1967
  for (const n of node.consequentNodes) {
1809
1968
  const savedClaim = ctx.claimStatic;
1810
1969
  if (hyd)
@@ -1818,6 +1977,7 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1818
1977
  ctx.push(indent(`${conFrag}.appendChild(${v});`));
1819
1978
  }
1820
1979
  }
1980
+ ctx.descriptorClaim = savedConDescriptor;
1821
1981
  if (hyd)
1822
1982
  ctx.push(indent(`__place(${anchor}, ${endAnchor}, __cl, ${parent});`));
1823
1983
  else
@@ -1834,6 +1994,9 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1834
1994
  ctx.push(indent(`const ${altFrag} = document.createDocumentFragment();`));
1835
1995
  if (hyd)
1836
1996
  ctx.push(indent(`const __cl = [];`));
1997
+ const savedAltDescriptor = ctx.descriptorClaim;
1998
+ if (hyd && ctx.markerless)
1999
+ ctx.descriptorClaim = true;
1837
2000
  for (const n of node.alternateNodes) {
1838
2001
  const savedAltClaim = ctx.claimStatic;
1839
2002
  if (hyd)
@@ -1847,6 +2010,7 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1847
2010
  ctx.push(indent(`${altFrag}.appendChild(${v});`));
1848
2011
  }
1849
2012
  }
2013
+ ctx.descriptorClaim = savedAltDescriptor;
1850
2014
  if (hyd)
1851
2015
  ctx.push(indent(`__place(${anchor}, ${endAnchor}, __cl, ${parent});`));
1852
2016
  else
@@ -1855,10 +2019,19 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1855
2019
  }
1856
2020
  if (!hyd)
1857
2021
  ctx.push(`${parent}.appendChild(${endAnchor});`);
1858
- ctx.push(`if (${condExpr}) { ${asyncKw}${conRenderName}(); }` + (hasElse ? ` else { ${asyncKw}${altRenderName}(); }` : ''));
2022
+ // Snapshot the condition used by the INITIAL render. Effect flush runs after
2023
+ // the body, so tracked data can arrive in between; comparing the first effect
2024
+ // run against a value read at flush time would adopt the new branch without
2025
+ // replacing the DOM that the old branch already claimed.
2026
+ const condSnapshot = ctx.n();
2027
+ ctx.push(`const ${condSnapshot} = ${condExpr};`);
2028
+ if (hyd && !ctx.markerless)
2029
+ ctx.push(`if (__hydrate) __reconcileRegion(${parent}, '${rid}', ${condSnapshot} ? 0 : 1, ${ctx.walker});`);
2030
+ if (hyd && ctx.markerless)
2031
+ emitStructuralRegionDiscard(ctx, [node.consequentNodes, node.alternateNodes], hasElse ? `(${condSnapshot} ? 0 : 1)` : `(${condSnapshot} ? 0 : -1)`, budget);
2032
+ ctx.push(`if (${condSnapshot}) { ${asyncKw}${conRenderName}(); }` + (hasElse ? ` else { ${asyncKw}${altRenderName}(); }` : ''));
1859
2033
  const renderBranch = `if (__nv) { ${asyncKw}${conRenderName}(); }` + (hasElse ? ` else { ${asyncKw}${altRenderName}(); }` : '');
1860
2034
  const effectBody = `effect(${ctx.isAsyncScope ? 'async () => {' : '() => {'}
1861
- if (__first) { __first = false; __iv = ${condExpr}; return; }
1862
2035
  const __nv = ${condExpr};
1863
2036
  if (__nv !== __iv) {
1864
2037
  for (const e of ${effectsVar}) destroy_block(e);
@@ -1867,7 +2040,7 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1867
2040
  ${renderBranch}
1868
2041
  __iv = __nv;
1869
2042
  }
1870
- });`;
2043
+ }, true);`;
1871
2044
  // A static condition renders once; an effect referencing it would only ever
1872
2045
  // reference statement/loop-local bindings that are out of scope at the flush
1873
2046
  // site (e.g. `const pages = …; if (pages.length > 0)` inside a for-of body).
@@ -1875,15 +2048,13 @@ function emitOpaque(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionB
1875
2048
  return null;
1876
2049
  if (effTarget) {
1877
2050
  ctx.push(`${effTarget}.push((() => {
1878
- let __iv = ${condExpr};
1879
- let __first = true;
2051
+ let __iv = ${condSnapshot};
1880
2052
  return ${effectBody}
1881
2053
  })());`);
1882
2054
  }
1883
2055
  else {
1884
2056
  ctx.effects.push(`{
1885
- let __iv = ${condExpr};
1886
- let __first = true;
2057
+ let __iv = ${condSnapshot};
1887
2058
  ${effectBody}
1888
2059
  }`);
1889
2060
  }
@@ -1973,7 +2144,7 @@ function emitWhileLoop(ctx, node, tracked, parentVar, skipK = 0, regionBudget) {
1973
2144
  __busy = false;
1974
2145
  __iv = (${condExpr});
1975
2146
  }
1976
- });
2147
+ }, true);
1977
2148
  }`);
1978
2149
  return null;
1979
2150
  }
@@ -2087,7 +2258,7 @@ function emitForLoop(ctx, node, tracked, parentVar, skipK = 0, regionBudget) {
2087
2258
  __busy = false;
2088
2259
  __iv = (${srcExpr});
2089
2260
  }
2090
- });
2261
+ }, true);
2091
2262
  }`);
2092
2263
  }
2093
2264
  else {
@@ -2156,7 +2327,7 @@ function emitForLoop(ctx, node, tracked, parentVar, skipK = 0, regionBudget) {
2156
2327
  __cleanup(${anchor}, ${endAnchor});
2157
2328
  ${ctx.isAsyncScope ? 'await ' : ''}${renderLoop}();
2158
2329
  __busy = false;
2159
- });
2330
+ }, true);
2160
2331
  }`);
2161
2332
  }
2162
2333
  }
@@ -2233,7 +2404,7 @@ function emitSwitchBlock(ctx, node, tracked, parentVar, skipK = 0, regionBudget)
2233
2404
  __busy = false;
2234
2405
  __iv = (${discExpr});
2235
2406
  }
2236
- });
2407
+ }, true);
2237
2408
  }`);
2238
2409
  return null;
2239
2410
  }
@@ -2380,34 +2551,34 @@ function emitMap(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionBudg
2380
2551
  let emptyRenderName = null;
2381
2552
  if (node.alternateNodes.length > 0) {
2382
2553
  emptyRenderName = ctx.n();
2383
- const emptySig = !hyd
2554
+ // Hydrated maps (keyed AND non-keyed) claim their #empty fallback
2555
+ // positionally with descriptor checks: an SSR slot that rendered the same
2556
+ // fallback is adopted in place (no duplicate), while an SSR item ghost in
2557
+ // that slot is detached by the walker's mismatch recovery and the fallback
2558
+ // renders fresh. Fresh (SPA) renders keep the fragment + direct-insert
2559
+ // shape — there is no SSR DOM to claim.
2560
+ const emptyClaims = ctx.hydrate;
2561
+ const emptySig = !emptyClaims
2384
2562
  ? (ctx.isAsyncScope ? 'async () => {' : '() => {')
2385
2563
  : (ctx.isAsyncScope ? 'async (__cl) => {' : '(__cl) => {');
2386
2564
  ctx.push(`const ${emptyRenderName} = ${emptySig}`);
2387
2565
  ctx.push(indent(`const __p = ${anchor}.parentNode;`));
2388
2566
  const frag = ctx.n();
2389
- if (!hyd)
2567
+ if (!emptyClaims)
2390
2568
  ctx.push(indent(`const ${frag} = document.createDocumentFragment();`));
2391
2569
  const savedClaim = ctx.claimStatic;
2392
- const savedHydrate = ctx.hydrate;
2393
- // A keyed map hydrating with no matching SSR items renders its empty
2394
- // fallback fresh — the SSR item ghosts get reported by the canary instead
2395
- // of being adopted positionally as the fallback (which would be wrong DOM).
2396
- if (hydKeyed)
2397
- ctx.hydrate = false;
2398
2570
  for (const n of node.alternateNodes) {
2399
2571
  ctx.claimStatic = true;
2400
- const v = emitNode(ctx, n, tracked, effectsVar, hyd ? undefined : frag, '__components', 0, budget || undefined);
2572
+ const v = emitNode(ctx, n, tracked, effectsVar, emptyClaims ? undefined : frag, '__components', 0, budget || undefined);
2401
2573
  if (v) {
2402
- if (hyd)
2574
+ if (emptyClaims)
2403
2575
  ctx.push(indent(`if (__cl) __cl.push(${v}); else __p.insertBefore(${v}, ${endAnchor});`));
2404
2576
  else
2405
2577
  ctx.push(indent(`${frag}.appendChild(${v});`));
2406
2578
  }
2407
2579
  }
2408
2580
  ctx.claimStatic = savedClaim;
2409
- ctx.hydrate = savedHydrate;
2410
- if (!hyd)
2581
+ if (!emptyClaims)
2411
2582
  ctx.push(indent(`if (${frag}.childNodes.length > 0) __p.insertBefore(${frag}, ${endAnchor});`));
2412
2583
  ctx.push(`};`);
2413
2584
  }
@@ -2440,8 +2611,19 @@ function emitMap(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionBudg
2440
2611
  }
2441
2612
  if (emptyRenderName) {
2442
2613
  const isEmptyVar = ctx.n();
2614
+ // A hydrated keyed map attaches its region anchors through
2615
+ // `reconcileHydrated` (items branch) or `__place` (empty branch) — the
2616
+ // anchors are created detached and the empty-fallback builder reads
2617
+ // `anchor.parentNode`, so the empty branch must place them too or the
2618
+ // whole hydrator crashes ("insertBefore on null") and every later
2619
+ // reactive update on the page is lost.
2620
+ const collectVar = hydKeyed ? ctx.n() : null;
2443
2621
  ctx.push(`let ${isEmptyVar} = !${hasItems}();`);
2444
- ctx.push(`if (!${isEmptyVar}) { ${initList}(); } else { ${aw}${emptyRenderName}(); }`);
2622
+ if (collectVar)
2623
+ ctx.push(`const ${collectVar} = [];`);
2624
+ ctx.push(`if (!${isEmptyVar}) { ${initList}(); } else { ${aw}${emptyRenderName}(${collectVar || ''}); }`);
2625
+ if (collectVar)
2626
+ ctx.push(`__place(${anchor}, ${endAnchor}, ${collectVar}, ${parent});`);
2445
2627
  if (!reactiveSource)
2446
2628
  return null;
2447
2629
  pushEffect(`let __first = true;`, `effect(${effOpen}
@@ -2456,7 +2638,7 @@ function emitMap(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionBudg
2456
2638
  ${effectsVar}.length = 0;
2457
2639
  __cleanup(${anchor}, ${endAnchor});
2458
2640
  if (__new) { ${initList}(); } else { ${aw}${emptyRenderName}(); }
2459
- })`);
2641
+ }, true)`);
2460
2642
  }
2461
2643
  else {
2462
2644
  ctx.push(`${initList}();`);
@@ -2466,7 +2648,7 @@ function emitMap(ctx, node, tracked, effTarget, parentVar, skipK = 0, regionBudg
2466
2648
  const __nv = ${arrExpr};
2467
2649
  if (__first) { __first = false; __nv; ${breakReset}${reconciler}(${arrExpr}); return; }
2468
2650
  ${breakVar ? `if (${breakVar}) ${breakVar} = false;` : ''}${reconciler}(${arrExpr});
2469
- })`);
2651
+ }, true)`);
2470
2652
  }
2471
2653
  }
2472
2654
  else {
@@ -2523,8 +2705,8 @@ ${renderAllItems(' ', null)}
2523
2705
  __cleanup(${anchor}, ${endAnchor});
2524
2706
  if (__new) {
2525
2707
  ${renderAllItems(' ', null)}
2526
- } else { ${aw}${emptyRenderName}(${collectVar ? collectVar : ''}); }
2527
- })`);
2708
+ } else { ${aw}${emptyRenderName}(); }
2709
+ }, true)`);
2528
2710
  }
2529
2711
  else {
2530
2712
  const collectVar = hyd ? ctx.n() : null;
@@ -2542,7 +2724,7 @@ ${renderAllItems(' ', null)}
2542
2724
  ${effectsVar}.length = 0;
2543
2725
  __cleanup(${anchor}, ${endAnchor});
2544
2726
  ${renderAllItems(' ', null)}
2545
- })`);
2727
+ }, true)`);
2546
2728
  }
2547
2729
  }
2548
2730
  return null;
@@ -2664,6 +2846,11 @@ function generateComponent(comp, importedNames = new Set(), hydrate = false, asy
2664
2846
  }
2665
2847
  }
2666
2848
  }
2849
+ // The first real claim is this component's root: let it validate the static
2850
+ // class shape against the SSR slot. The flag is consumed by the first claim
2851
+ // and never leaks to nested children.
2852
+ if (ctx.hydrate && ctx.markerless)
2853
+ ctx.descriptorClaim = true;
2667
2854
  for (const node of comp.body) {
2668
2855
  // A TrackDecl that could not be hoisted (its init reads an earlier body
2669
2856
  // binding) is emitted here, in source order, so the binding it reads is
@@ -2804,6 +2991,33 @@ function buildComponentMap(irRoot, hydrate = false, alloc, markerless = true) {
2804
2991
  const code = generateComponent(comp, directNames, hydrate, asyncComps, linkNames, selfClaimNames, alloc, markerless);
2805
2992
  mapLines.push(`${registry}[${JSON.stringify(comp.name)}] = ${code};`);
2806
2993
  }
2994
+ if (hydrate && !markerless) {
2995
+ mapLines.push(`function __reconcileRegion(parent, id, branch, walker) {`);
2996
+ mapLines.push(`\tif (!parent) return;`);
2997
+ mapLines.push(`\tlet start = null;`);
2998
+ mapLines.push(`\tlet end = null;`);
2999
+ mapLines.push(`\tfor (let n = parent.firstChild; n; n = n.nextSibling) {`);
3000
+ mapLines.push(`\t\tif (n.nodeType !== 8) continue;`);
3001
+ mapLines.push(`\t\tconst text = n.textContent || '';`);
3002
+ mapLines.push(`\t\tif (text === 'vesk-r:' + id + ':0' || text === 'vesk-r:' + id + ':1') start = n;`);
3003
+ mapLines.push(`\t\telse if (text === 'vesk-r-end:' + id) end = n;`);
3004
+ mapLines.push(`\t\tif (start && end) break;`);
3005
+ mapLines.push(`\t}`);
3006
+ mapLines.push(`\tif (!start || !end) return;`);
3007
+ mapLines.push(`\tconst ssrBranch = (start.textContent || '').slice(-2) === ':1' ? 1 : 0;`);
3008
+ mapLines.push(`\tif (ssrBranch !== branch) {`);
3009
+ mapLines.push(`\t\tlet n = start.nextSibling;`);
3010
+ mapLines.push(`\t\twhile (n && n !== end) {`);
3011
+ mapLines.push(`\t\t\tconst next = n.nextSibling;`);
3012
+ mapLines.push(`\t\t\tn.remove();`);
3013
+ mapLines.push(`\t\t\tn = next;`);
3014
+ mapLines.push(`\t\t}`);
3015
+ mapLines.push(`\t\tif (walker && walker.retireDetached) walker.retireDetached();`);
3016
+ mapLines.push(`\t}`);
3017
+ mapLines.push(`\tstart.remove();`);
3018
+ mapLines.push(`\tend.remove();`);
3019
+ mapLines.push(`}`);
3020
+ }
2807
3021
  mapLines.push(`function __cleanup(start, end) {`);
2808
3022
  mapLines.push(`\tlet n = start.nextSibling;`);
2809
3023
  mapLines.push(`\twhile (n && n !== end) {`);
@@ -2870,6 +3084,50 @@ function ssrResidueEstimate(node) {
2870
3084
  // ServerBlock, ClientBlock, HeadBlock emit no SSR element residue.
2871
3085
  return 0;
2872
3086
  }
3087
+ function firstBranchRoot(nodes) {
3088
+ for (const n of nodes) {
3089
+ if (n instanceof StaticNode)
3090
+ return { tag: n.tag, descriptor: staticClassDescriptor(n) };
3091
+ if (n instanceof ComponentCall || n instanceof ComponentRef)
3092
+ return null;
3093
+ if (n instanceof OpaqueDynamicRegion)
3094
+ return null;
3095
+ if (n instanceof MapRegion || n instanceof ForLoop || n instanceof WhileLoop) {
3096
+ const child = firstBranchRoot(n.bodyTemplate);
3097
+ if (child)
3098
+ return child;
3099
+ }
3100
+ if (n instanceof SwitchBlock) {
3101
+ for (const c of n.cases) {
3102
+ const child = firstBranchRoot(c.body);
3103
+ if (child)
3104
+ return child;
3105
+ }
3106
+ }
3107
+ if (n instanceof TryCatch) {
3108
+ const child = firstBranchRoot(n.bodyTemplate) || firstBranchRoot(n.catchBody);
3109
+ if (child)
3110
+ return child;
3111
+ }
3112
+ }
3113
+ return null;
3114
+ }
3115
+ function structuralRegionMetadata(branches) {
3116
+ const parts = branches.map((nodes) => {
3117
+ const first = firstBranchRoot(nodes);
3118
+ const tag = first ? JSON.stringify(first.tag) : 'undefined';
3119
+ const descriptor = first && first.descriptor ? first.descriptor : 'null';
3120
+ return `{ tag: ${tag}, descriptor: ${descriptor}, count: ${ssrResidueEstimate(nodes)} }`;
3121
+ });
3122
+ return `[${parts.join(', ')}]`;
3123
+ }
3124
+ function emitStructuralRegionDiscard(ctx, branches, selectedExpr, budget) {
3125
+ if (!ctx.hydrate || !ctx.markerless)
3126
+ return;
3127
+ const offset = budget ? `, ${budget}` : '';
3128
+ const consume = budget ? ` ${budget} = 0;` : '';
3129
+ ctx.push(`if (${ctx.walker} && typeof ${ctx.walker}.discardStructuralRegion === 'function' && ${ctx.walker}.discardStructuralRegion(${structuralRegionMetadata(branches)}, ${selectedExpr}${offset})) {${consume}}`);
3130
+ }
2873
3131
  function isStaticIR(body) {
2874
3132
  for (const node of body) {
2875
3133
  if (node instanceof StaticNode) {