@vesk/compiler 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.
@@ -2,13 +2,14 @@ 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, ComponentRef, ComponentCall, ServerBlock, ClientBlock, HeadBlock, SlotNode, } from '@vesk/compiler/src/ir';
5
+ import { StaticNode, TextNode, DynamicBinding, OpaqueDynamicRegion, MapRegion, WhileLoop, SwitchBlock, TryCatch, ForLoop, TrackDecl, RuntimeStatement, ComponentRef, ComponentCall, 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';
9
9
  import { extractRuntimeNames, extractTopLevelNames } from '@vesk/compiler/src/server-utils';
10
10
  import { importBindingPairs, localValueImportNames } from '@vesk/compiler/src/module-imports';
11
11
  import { stripTrackGeneric } from '@vesk/compiler/src/scan';
12
+ import { importModuleTarget } from '@vesk/compiler/src/tokens';
12
13
  import { inlineMdImportsFrom } from '@vesk/compiler/src/md-inline';
13
14
  import { stripTsTypes, hasTsSyntax } from '@vesk/compiler/src/strip-ts';
14
15
  function memberExpr(object, property) {
@@ -247,12 +248,36 @@ function indent(code, level = 1) {
247
248
  const pad = '\t'.repeat(level);
248
249
  return code.split('\n').map((l) => (l ? pad + l : '')).join('\n');
249
250
  }
251
+ /**
252
+ * Allocates a disjoint `$n` name block per source file. esbuild's bundler
253
+ * renames colliding top-level symbols when it links a chunk, and on 0.25.x
254
+ * that rename can leave references inside nested closures pointing at the
255
+ * pre-rename name — surfacing as `$nNN is not defined` at hydration. Seeding
256
+ * every file from a hash of its path keeps the names globally unique so the
257
+ * renamer has nothing to rename. Callers without a `sourcePath` keep the
258
+ * historical `$n0…` numbering.
259
+ */
260
+ export function nameAllocFor(sourcePath, start = 0) {
261
+ if (!sourcePath)
262
+ return { c: start };
263
+ let h = 0x811c9dc5;
264
+ for (let i = 0; i < sourcePath.length; i++) {
265
+ h ^= sourcePath.charCodeAt(i);
266
+ h = Math.imul(h, 0x01000193) >>> 0;
267
+ }
268
+ return { c: h * 1_000_000 + start };
269
+ }
250
270
  class Ctx {
251
271
  lines = [];
252
272
  effects = [];
253
- c = 0;
273
+ alloc;
254
274
  importedNames = new Set();
255
275
  linkNames = new Set();
276
+ // Runtime component imports that claim their own SSR root via `nextElement`
277
+ // (Link/NavLink/Md/Form/Field/LoadingIndicator) and return a DocumentFragment
278
+ // when claimed. They need no call-site adoption guard — the guard targets
279
+ // genuinely-plain components that never touch the walker.
280
+ selfClaimNames = new Set();
256
281
  delegatedEvents = new Set();
257
282
  directEvents = new Set();
258
283
  hydrate = false;
@@ -264,12 +289,15 @@ class Ctx {
264
289
  walker = '__hydrate';
265
290
  asyncComps = new Set();
266
291
  isAsyncScope = false;
292
+ constructor(alloc) {
293
+ this.alloc = alloc ?? { c: 0 };
294
+ }
267
295
  push(...args) {
268
296
  for (const a of args)
269
297
  if (a)
270
298
  this.lines.push(a);
271
299
  }
272
- n() { return `$n${this.c++}`; }
300
+ n() { return `$n${this.alloc.c++}`; }
273
301
  getCode() { return this.lines.join('\n'); }
274
302
  flushEffects() {
275
303
  if (this.effects.length === 0)
@@ -286,8 +314,17 @@ class Ctx {
286
314
  lines.push(`if (!document.${guard}) {`);
287
315
  lines.push(`\tdocument.${guard} = true;`);
288
316
  lines.push(`\tdocument.addEventListener(${JSON.stringify(type)}, (e) => {`);
289
- lines.push(`\t\tvar el = e.target.closest('[data-vsk-ev]');`);
290
- lines.push(`\t\tif (el && el.${prop}) el.${prop}(e);`);
317
+ // Walk up from the target to the nearest element that actually has a
318
+ // handler for this event. Using `closest('[data-vsk-ev]')` alone stops at
319
+ // the nearest event-capable element even when its handler is undefined
320
+ // (`<Button/>` forwards an optional onClick that was never passed), which
321
+ // shadows a real handler on an ancestor (e.g. a Popover trigger wrapper).
322
+ lines.push(`\t\tvar el = e.target;`);
323
+ lines.push(`\t\tif (el && el.nodeType !== 1) el = el.parentElement;`);
324
+ lines.push(`\t\twhile (el && el.nodeType === 1) {`);
325
+ lines.push(`\t\t\tif (el.${prop}) { el.${prop}(e); break; }`);
326
+ lines.push(`\t\t\tel = el.parentElement;`);
327
+ lines.push(`\t\t}`);
291
328
  lines.push(`\t});`);
292
329
  lines.push(`}`);
293
330
  }
@@ -319,7 +356,7 @@ function emitNode(ctx, node, tracked, effectsVar, parentVar, compPrefix = '__com
319
356
  if (node instanceof OpaqueDynamicRegion)
320
357
  return emitOpaque(ctx, node, tracked, effectsVar, parentVar);
321
358
  if (node instanceof MapRegion)
322
- return emitMap(ctx, node, tracked, parentVar);
359
+ return emitMap(ctx, node, tracked, effectsVar, parentVar);
323
360
  if (node instanceof ServerBlock)
324
361
  return null;
325
362
  if (node instanceof ClientBlock) {
@@ -347,7 +384,7 @@ function emitNode(ctx, node, tracked, effectsVar, parentVar, compPrefix = '__com
347
384
  ctx.push(`if (props.children !== undefined && props.children !== null) {`);
348
385
  ctx.push(` if (typeof props.children === 'function') {`);
349
386
  ctx.push(` const __child = props.children(${ctx.walker});`);
350
- ctx.push(` if (__child && typeof __child.then === 'function') __pendingChild = __child.then(() => $root);`);
387
+ ctx.push(` if (__child && typeof __child.then === 'function') __pendingChild = __child.then(() => $mount || $root);`);
351
388
  ctx.push(` } else {`);
352
389
  ctx.push(` ${parentVar}.appendChild(props.children);`);
353
390
  ctx.push(` }`);
@@ -366,6 +403,32 @@ function emitNode(ctx, node, tracked, effectsVar, parentVar, compPrefix = '__com
366
403
  return emitForLoop(ctx, node, tracked, parentVar);
367
404
  if (node instanceof SwitchBlock)
368
405
  return emitSwitchBlock(ctx, node, tracked, parentVar);
406
+ if (node instanceof PropSlot)
407
+ return null;
408
+ if (node instanceof PropSlotRender) {
409
+ const slotProp = `props.${node.propName}`;
410
+ if (!parentVar)
411
+ return null;
412
+ if (ctx.hydrate) {
413
+ ctx.push(`if (${slotProp} !== undefined && ${slotProp} !== null) {`);
414
+ ctx.push(` if (typeof ${slotProp} === 'function') {`);
415
+ ctx.push(` const __child = ${slotProp}(${ctx.walker});`);
416
+ ctx.push(` if (__child && typeof __child.then === 'function') __pendingChild = __child.then(() => ${parentVar});`);
417
+ ctx.push(` } else if (${slotProp}.nodeType !== undefined) {`);
418
+ ctx.push(` ${parentVar}.appendChild(${slotProp});`);
419
+ ctx.push(` } else {`);
420
+ ctx.push(` ${parentVar}.appendChild(document.createTextNode(String(${slotProp})));`);
421
+ ctx.push(` }`);
422
+ ctx.push(`}`);
423
+ }
424
+ else {
425
+ ctx.push(`if (${slotProp} !== undefined && ${slotProp} !== null) {`);
426
+ ctx.push(` if (${slotProp}.nodeType !== undefined) ${parentVar}.appendChild(${slotProp});`);
427
+ ctx.push(` else ${parentVar}.appendChild(document.createTextNode(String(${slotProp})));`);
428
+ ctx.push(`}`);
429
+ }
430
+ return null;
431
+ }
369
432
  return null;
370
433
  }
371
434
  const PROPERTY_ATTRS = {
@@ -402,9 +465,15 @@ function emitStatic(ctx, node, tracked, effectsVar) {
402
465
  children.push(child);
403
466
  }
404
467
  }
468
+ const dynAttrNames = new Set();
469
+ for (const d of dynAttrs)
470
+ if (d.target)
471
+ dynAttrNames.add(d.target);
405
472
  for (const attr of node.attributes) {
406
473
  if (attr.name.startsWith('on') && attr.name.length > 2)
407
474
  continue;
475
+ if (dynAttrNames.has(attr.name))
476
+ continue;
408
477
  if (attr.value === '') {
409
478
  ctx.push(`${el}.setAttribute(${JSON.stringify(attr.name)}, '');`);
410
479
  }
@@ -412,16 +481,48 @@ function emitStatic(ctx, node, tracked, effectsVar) {
412
481
  ctx.push(`${el}.setAttribute(${JSON.stringify(attr.name)}, ${JSON.stringify(escapeHtml(attr.value))});`);
413
482
  }
414
483
  }
484
+ // Hydrate-mode A5 snapshot: exactly one text-ish child that is a reactive
485
+ // dynamic binding gets initialized from the claimed parent's SSR text.
486
+ // Computed only in hydrate mode — normal mode pays zero for this.
487
+ let singleReactiveText = false;
488
+ if (ctx.hydrate) {
489
+ const textKids = children.filter((c) => c instanceof TextNode || (c instanceof DynamicBinding && c.kind === 'text'));
490
+ singleReactiveText = textKids.length === 1 && textKids[0] instanceof DynamicBinding
491
+ && isReactiveExpression(textKids[0].expression, tracked);
492
+ }
493
+ let residueBefore = 0;
415
494
  for (const child of children) {
416
- const childVar = emitNode(ctx, child, tracked, effectsVar, el);
495
+ let childVar;
496
+ if (singleReactiveText && child instanceof DynamicBinding && child.kind === 'text') {
497
+ childVar = emitDynamicBinding(ctx, child, tracked, effectsVar, `${el}.__vsk_ssrText`);
498
+ }
499
+ else {
500
+ childVar = emitNode(ctx, child, tracked, effectsVar, el);
501
+ }
417
502
  if (childVar) {
418
503
  if (ctx.hydrate) {
419
- ctx.push(`if (${childVar}.parentNode !== ${el}) { if (!${el} || ${childVar}.parentNode == null || !${el}.contains(${childVar})) ${el}.appendChild(${childVar}); }`);
504
+ // Fresh text nodes (static or dynamic) cannot be appended at the end of
505
+ // a claimed element: its SSR element children (component-boundary
506
+ // wrappers, skipped static markup) are still in the DOM at their SSR
507
+ // slots, so appending would move the text AFTER them, reordering
508
+ // mixed text + component children (the hero "compiler-first framework ·
509
+ // <VersionBadge />" label). Insert the fresh node at the SSR position
510
+ // of the text instead: the k-th source text child precedes the residue
511
+ // elements rendered before it, so it slots in before
512
+ // `el.__vsk_ssrEls[k]` (the residue list captured by the runtime at
513
+ // claim time).
514
+ if (child instanceof TextNode || child instanceof DynamicBinding) {
515
+ ctx.push(`if (${childVar}.parentNode !== ${el}) { const __ssr = ${el}.__vsk_ssrEls || []; if (${residueBefore} < __ssr.length) ${el}.insertBefore(${childVar}, __ssr[${residueBefore}]); else ${el}.appendChild(${childVar}); }`);
516
+ }
517
+ else {
518
+ ctx.push(`if (${childVar}.parentNode !== ${el}) { if (!${el} || ${childVar}.parentNode == null || !${el}.contains(${childVar})) ${el}.appendChild(${childVar}); }`);
519
+ }
420
520
  }
421
521
  else {
422
522
  ctx.push(`${el}.appendChild(${childVar});`);
423
523
  }
424
524
  }
525
+ residueBefore += ssrResidueEstimate(child);
425
526
  }
426
527
  for (const attr of dynAttrs) {
427
528
  const target = attr.target || '';
@@ -444,12 +545,22 @@ function emitStatic(ctx, node, tracked, effectsVar) {
444
545
  }
445
546
  ctx.push(`${el}.setAttribute('data-vsk-ev', '');`);
446
547
  }
548
+ else if (target === 'style') {
549
+ const expr = transformTracked(attr.expression, tracked);
550
+ const eff = `effect(() => { applyStyle(${el}, ${expr}); })`;
551
+ if (effectsVar) {
552
+ ctx.push(`${effectsVar}.push(${eff});`);
553
+ }
554
+ else {
555
+ ctx.effects.push(`${eff};`);
556
+ }
557
+ }
447
558
  else {
448
559
  const expr = transformTracked(attr.expression, tracked);
449
560
  const useProp = PROPERTY_ATTRS[node.tag]?.has(target);
450
561
  const eff = useProp
451
562
  ? `effect(() => { ${el}.${target} = ${expr}; })`
452
- : `effect(() => { ${el}.setAttribute(${JSON.stringify(target)}, String(${expr})); })`;
563
+ : `effect(() => { const __v = ${expr}; if (__v != null && __v !== false) ${el}.setAttribute(${JSON.stringify(target)}, __v === true ? 'true' : String(__v)); })`;
453
564
  if (effectsVar) {
454
565
  ctx.push(`${effectsVar}.push(${eff});`);
455
566
  }
@@ -604,7 +715,7 @@ function isReactiveExpression(node, tracked) {
604
715
  });
605
716
  return reactive;
606
717
  }
607
- function emitDynamicBinding(ctx, node, tracked, effectsVar) {
718
+ function emitDynamicBinding(ctx, node, tracked, effectsVar, ssrTextExpr = null) {
608
719
  if (node.kind === 'attribute')
609
720
  return null;
610
721
  const expr = transformTracked(node.expression, tracked);
@@ -613,7 +724,13 @@ function emitDynamicBinding(ctx, node, tracked, effectsVar) {
613
724
  ctx.push(`const ${v} = document.createTextNode(String(${expr}));`);
614
725
  return v;
615
726
  }
616
- ctx.push(`const ${v} = document.createTextNode('');`);
727
+ // Hydrate-mode sole-dynamic-text snapshot (Hydrate-Todo A5): initialize from
728
+ // the claimed parent's stashed SSR text so a missed effect degrades to stale
729
+ // text instead of an empty element. The effect below still overwrites on its
730
+ // first run, so live values are unaffected. Fresh (unclaimed) parents stash
731
+ // nothing and the `?? ''` keeps the old empty initial.
732
+ const init = ssrTextExpr ? `String(${ssrTextExpr} ?? '')` : `''`;
733
+ ctx.push(`const ${v} = document.createTextNode(${init});`);
617
734
  const eff = `effect(() => { ${v}.data = String(${expr}); })`;
618
735
  if (effectsVar) {
619
736
  ctx.push(`${effectsVar}.push(${eff});`);
@@ -623,6 +740,29 @@ function emitDynamicBinding(ctx, node, tracked, effectsVar) {
623
740
  }
624
741
  return v;
625
742
  }
743
+ /**
744
+ * Emits a fresh DocumentFragment built from `nodes` (used for both the
745
+ * `children` of a component call and every named content slot). Children
746
+ * effects are collected into `effectsVar` (or `ctx.effects`) exactly as the
747
+ * component-call path does today, so slot bodies inherit identical effects,
748
+ * claim, and async semantics as regular children.
749
+ */
750
+ function emitFragment(ctx, nodes, tracked, effectsVar) {
751
+ const frag = ctx.n();
752
+ const savedEffects = ctx.effects;
753
+ ctx.effects = [];
754
+ ctx.push(`const ${frag} = (() => { const $f = document.createDocumentFragment();`);
755
+ for (const child of nodes) {
756
+ const childVar = emitNode(ctx, child, tracked, effectsVar, '$f');
757
+ if (childVar)
758
+ ctx.push(`$f.appendChild(${childVar});`);
759
+ }
760
+ for (const eff of ctx.effects)
761
+ ctx.push(effectsVar ? `${effectsVar}.push(${effectBlockToHandlerExpr(eff)});` : eff);
762
+ ctx.effects = savedEffects;
763
+ ctx.push(`return $f; })();`);
764
+ return frag;
765
+ }
626
766
  function emitComponentCall(ctx, node, tracked, effectsVar, parentVar, compPrefix = '__components') {
627
767
  const propsEntries = node.props.map((p) => {
628
768
  if (typeof p.value === 'string')
@@ -638,37 +778,60 @@ function emitComponentCall(ctx, node, tracked, effectsVar, parentVar, compPrefix
638
778
  const expr = transformTracked(sp, tracked);
639
779
  propsEntries.push(`...${expr}`);
640
780
  }
641
- const propsObj = `{ ${propsEntries.join(', ')} }`;
642
781
  const v = ctx.n();
643
- const awaitKw = ctx.asyncComps.has(node.componentName) ? 'await ' : '';
782
+ // Await children whenever the calling scope is async. Async-ness is
783
+ // parent-driven (mirroring the server side) so that imported async
784
+ // components from other files — whose names are not in the local
785
+ // asyncComps set — still resolve to nodes before append/insert.
786
+ const awaitKw = ctx.isAsyncScope ? 'await ' : '';
644
787
  // Member-expression tags (`<it.icon>`) carry the raw component-valued
645
788
  // expression — invoke it directly; it is never a registry name.
646
789
  const calleeExpr = node.calleeExpr ? `(${node.calleeExpr})` : null;
790
+ // Named content slots (`trigger={<Button/>}`) thread through the same channel
791
+ // as `children`: a fresh DocumentFragment per slot hoisted from the IR.
792
+ const slotChildren = node.children.filter((c) => c instanceof PropSlot);
793
+ const regChildren = node.children.filter((c) => !(c instanceof PropSlot));
794
+ const callArgs = () => `{ ${propsEntries.join(', ')} }`;
647
795
  if (ctx.hydrate) {
648
796
  const access = calleeExpr
649
797
  ?? (ctx.importedNames.has(node.componentName)
650
798
  ? node.componentName
651
799
  : `__components[${JSON.stringify(node.componentName)}]`);
652
- const subScope = () => ctx.inTryBody
653
- ? `${ctx.walker}.subWalker(${parentVar})`
654
- : `${ctx.walker}.subWalker(${ctx.walker}.nextElement())`;
655
- if (node.children.length > 0) {
656
- const walkerVar = ctx.n();
657
- ctx.push(`const ${walkerVar} = ${subScope()};`);
800
+ // SSR for component calls is marker-ONLY (`<!--vsk-->` + the component's
801
+ // own root; no display:contents wrapper). Compiled `.vsk` components claim
802
+ // their own root on the SHARED walker. Call targets that are not known
803
+ // compiled components (imported values, member-expression tags) cannot
804
+ // claim, so after the call we adopt their SSR root ourselves and replace it
805
+ // with the fresh node they returned — never a duplicate, never a box.
806
+ const plainTarget = !!(calleeExpr || ctx.importedNames.has(node.componentName)) && !ctx.selfClaimNames.has(node.componentName);
807
+ const walkerArg = ctx.walker;
808
+ const maybeReplace = (v) => {
809
+ if (!plainTarget)
810
+ return;
811
+ // nodeType 11 = DocumentFragment: self-claiming imports (Link/Md/Form)
812
+ // return a fragment when they adopted their SSR root, so there is nothing
813
+ // to replace — claiming here would steal the NEXT sibling's root.
814
+ ctx.push(`if (${v} && ${v}.parentNode == null && ${v}.nodeType !== 11) { const __sr = ${walkerArg}.claimOnly(); if (__sr && __sr.parentNode) __sr.parentNode.replaceChild(${v}, __sr); }`);
815
+ };
816
+ const maybeRetire = () => {
817
+ if (ctx.selfClaimNames.has(node.componentName)) {
818
+ ctx.push(`${walkerArg}.retireDetached();`);
819
+ }
820
+ };
821
+ for (const slot of slotChildren) {
822
+ const frag = emitFragment(ctx, slot.body, tracked, effectsVar);
823
+ propsEntries.push(`${JSON.stringify(slot.propName)}: ${frag}`);
824
+ }
825
+ if (regChildren.length > 0) {
658
826
  const frag = ctx.n();
659
- ctx.push(`const ${frag} = (() => { const $f = document.createDocumentFragment();`);
660
- const savedWalker = ctx.walker;
661
- const savedEffects = ctx.effects;
662
- // Link/NavLink hydrate branches WIPE their SSR anchor (a.replaceChildren())
663
- // and remount the children fragment. Suppressed fully-static children would
664
- // therefore be lost, so rebuild the whole subtree fresh instead of claiming.
665
827
  const wipeMount = ctx.linkNames.has(node.componentName);
666
828
  const savedHydrate = ctx.hydrate;
667
829
  if (wipeMount)
668
830
  ctx.hydrate = false;
669
- ctx.walker = walkerVar;
831
+ ctx.push(`const ${frag} = (() => { const $f = document.createDocumentFragment();`);
832
+ const savedEffects = ctx.effects;
670
833
  ctx.effects = [];
671
- for (const child of node.children) {
834
+ for (const child of regChildren) {
672
835
  const childVar = emitNode(ctx, child, tracked, effectsVar, '$f');
673
836
  if (childVar)
674
837
  ctx.push(`$f.appendChild(${childVar});`);
@@ -676,23 +839,26 @@ function emitComponentCall(ctx, node, tracked, effectsVar, parentVar, compPrefix
676
839
  for (const eff of ctx.effects)
677
840
  ctx.push(effectsVar ? `${effectsVar}.push(${effectBlockToHandlerExpr(eff)});` : eff);
678
841
  ctx.effects = savedEffects;
679
- ctx.walker = savedWalker;
680
842
  ctx.hydrate = savedHydrate;
681
843
  ctx.push(`return $f; })();`);
682
844
  propsEntries.push(`children: ${frag}`);
683
- ctx.push(`const ${v} = ${awaitKw}${access}({ ${propsEntries.join(', ')} }, __registry, ${walkerVar});`);
684
- return v;
685
845
  }
686
- ctx.push(`const ${v} = ${awaitKw}${access}(${propsObj}, __registry, ${subScope()});`);
846
+ ctx.push(`const ${v} = ${awaitKw}${access}(${callArgs()}, __registry, ${walkerArg});`);
847
+ maybeReplace(v);
848
+ maybeRetire();
687
849
  return v;
688
850
  }
689
851
  else {
690
- if (node.children.length > 0) {
852
+ for (const slot of slotChildren) {
853
+ const frag = emitFragment(ctx, slot.body, tracked, effectsVar);
854
+ propsEntries.push(`${JSON.stringify(slot.propName)}: ${frag}`);
855
+ }
856
+ if (regChildren.length > 0) {
691
857
  const frag = ctx.n();
692
- ctx.push(`const ${frag} = (() => { const $f = document.createDocumentFragment();`);
693
858
  const savedEffects = ctx.effects;
694
859
  ctx.effects = [];
695
- for (const child of node.children) {
860
+ ctx.push(`const ${frag} = (() => { const $f = document.createDocumentFragment();`);
861
+ for (const child of regChildren) {
696
862
  const childVar = emitNode(ctx, child, tracked, effectsVar, '$f');
697
863
  if (childVar)
698
864
  ctx.push(`$f.appendChild(${childVar});`);
@@ -704,13 +870,13 @@ function emitComponentCall(ctx, node, tracked, effectsVar, parentVar, compPrefix
704
870
  propsEntries.push(`children: ${frag}`);
705
871
  }
706
872
  if (calleeExpr) {
707
- ctx.push(`const ${v} = ${calleeExpr}({ ${propsEntries.join(', ')} });`);
873
+ ctx.push(`const ${v} = ${calleeExpr}(${callArgs()});`);
708
874
  }
709
875
  else if (ctx.importedNames.has(node.componentName)) {
710
- ctx.push(`const ${v} = ${awaitKw}${node.componentName}({ ${propsEntries.join(', ')} });`);
876
+ ctx.push(`const ${v} = ${awaitKw}${node.componentName}(${callArgs()});`);
711
877
  }
712
878
  else {
713
- ctx.push(`const ${v} = ${awaitKw}${compPrefix}[${JSON.stringify(node.componentName)}]({ ${propsEntries.join(', ')} });`);
879
+ ctx.push(`const ${v} = ${awaitKw}${compPrefix}[${JSON.stringify(node.componentName)}](${callArgs()});`);
714
880
  }
715
881
  }
716
882
  return v;
@@ -1329,7 +1495,7 @@ function emitSwitchBlock(ctx, node, tracked, parentVar) {
1329
1495
  }`);
1330
1496
  return null;
1331
1497
  }
1332
- function emitMap(ctx, node, tracked, parentVar) {
1498
+ function emitMap(ctx, node, tracked, effTarget, parentVar) {
1333
1499
  const arrExpr = transformTracked(node.expression, tracked);
1334
1500
  const itemVar = node.itemVariable;
1335
1501
  const anchor = ctx.n();
@@ -1349,6 +1515,26 @@ function emitMap(ctx, node, tracked, parentVar) {
1349
1515
  // emitting a top-level effect there would reference the loop/statement local
1350
1516
  // (`col`, `pages`) which is out of scope — `ReferenceError` at hydrate.
1351
1517
  const reactiveSource = isReactiveExpression(node.expression, tracked);
1518
+ // The re-render effect below closes over the anchors/reconciler declared
1519
+ // inline above. When this map is nested inside a branch/item builder those
1520
+ // bindings live in that nested closure — so the effect must be created in
1521
+ // the same scope (registered on the enclosing effects array), not flushed
1522
+ // to the component top level where the names are out of scope
1523
+ // (`$nNN is not defined` at hydration, plus an esbuild shadowing rename).
1524
+ const pushEffect = (lets, effCall) => {
1525
+ if (effTarget) {
1526
+ ctx.push(`${effTarget}.push((() => {
1527
+ ${lets}
1528
+ return ${effCall};
1529
+ })());`);
1530
+ }
1531
+ else {
1532
+ ctx.effects.push(`{
1533
+ ${lets}
1534
+ ${effCall};
1535
+ }`);
1536
+ }
1537
+ };
1352
1538
  ctx.push(`const ${anchor} = document.createComment('map');`);
1353
1539
  if (!hyd && !hydKeyed)
1354
1540
  ctx.push(`${parent}.appendChild(${anchor});`);
@@ -1436,6 +1622,13 @@ function emitMap(ctx, node, tracked, parentVar) {
1436
1622
  ctx.push(`const ${hasItems} = () => { const __l = ${arrExpr}; return __l != null && __l.length > 0; };`);
1437
1623
  if (node.keyExpr) {
1438
1624
  const keyExpr = transformTracked(node.keyExpr, tracked);
1625
+ // A `key` may reference the loop's `index` variable (`for (… ; key i; index i)`).
1626
+ // The key function is evaluated outside the item renderer (where the index is
1627
+ // in scope), so it must receive the index as its own parameter — otherwise the
1628
+ // emitted `item => i` key closes over an undefined `i` (ReferenceError).
1629
+ // Parenthesize the parameters: without them `item, i => …` parses as a comma
1630
+ // sequence expression rather than a two-parameter arrow in argument position.
1631
+ const keyFnSrc = `(${itemVar}${node.indexVariable ? `, ${node.indexVariable}` : ''}) => ${keyExpr}`;
1439
1632
  const reconciler = ctx.n();
1440
1633
  const initList = ctx.n();
1441
1634
  ctx.push(`let ${reconciler} = () => {};`);
@@ -1444,10 +1637,10 @@ function emitMap(ctx, node, tracked, parentVar) {
1444
1637
  // inside `parent` (pre-attach, `anchor.parentNode` is null) and peeks all
1445
1638
  // keys before claim-by-key consumes the walker, so server-rendered items
1446
1639
  // are adopted and wired in place instead of being rebuilt.
1447
- ctx.push(`const ${initList} = () => { ${reconciler} = reconcileHydrated(${anchor}, ${endAnchor}, ${arrExpr}, ${itemVar} => ${keyExpr}, (${itemVar}, __i, __e, __root) => ${renderItem}(${itemVar}${indexParam ? ', __i, __e' : ', __e'}, null, __root), __hydrate, ${parent}); };`);
1640
+ ctx.push(`const ${initList} = () => { ${reconciler} = reconcileHydrated(${anchor}, ${endAnchor}, ${arrExpr}, ${keyFnSrc}, (${itemVar}, __i, __e, __root) => ${renderItem}(${itemVar}${indexParam ? ', __i, __e' : ', __e'}, null, __root), __hydrate, ${parent}); };`);
1448
1641
  }
1449
1642
  else {
1450
- ctx.push(`const ${initList} = () => { ${reconciler} = reconcile(${anchor}, ${endAnchor}, ${arrExpr}, ${itemVar} => ${keyExpr}, (${itemVar}, __i, __e) => ${renderItem}(${itemVar}${indexParam ? ', __i, __e' : ', __e'})); };`);
1643
+ ctx.push(`const ${initList} = () => { ${reconciler} = reconcile(${anchor}, ${endAnchor}, ${arrExpr}, ${keyFnSrc}, (${itemVar}, __i, __e) => ${renderItem}(${itemVar}${indexParam ? ', __i, __e' : ', __e'})); };`);
1451
1644
  }
1452
1645
  if (emptyRenderName) {
1453
1646
  const isEmptyVar = ctx.n();
@@ -1455,9 +1648,7 @@ function emitMap(ctx, node, tracked, parentVar) {
1455
1648
  ctx.push(`if (!${isEmptyVar}) { ${initList}(); } else { ${aw}${emptyRenderName}(); }`);
1456
1649
  if (!reactiveSource)
1457
1650
  return null;
1458
- ctx.effects.push(`{
1459
- let __first = true;
1460
- effect(${effOpen}
1651
+ pushEffect(`let __first = true;`, `effect(${effOpen}
1461
1652
  const __new = ${hasItems}();
1462
1653
  if (__first) { __first = false; __new; return; }
1463
1654
  if (__new !== ${isEmptyVar}) {
@@ -1469,21 +1660,17 @@ function emitMap(ctx, node, tracked, parentVar) {
1469
1660
  ${effectsVar}.length = 0;
1470
1661
  __cleanup(${anchor}, ${endAnchor});
1471
1662
  if (__new) { ${initList}(); } else { ${aw}${emptyRenderName}(); }
1472
- });
1473
- }`);
1663
+ })`);
1474
1664
  }
1475
1665
  else {
1476
1666
  ctx.push(`${initList}();`);
1477
1667
  if (!reactiveSource)
1478
1668
  return null;
1479
- ctx.effects.push(`{
1480
- let __first = true;
1481
- effect(() => {
1669
+ pushEffect(`let __first = true;`, `effect(() => {
1482
1670
  const __nv = ${arrExpr};
1483
1671
  if (__first) { __first = false; __nv; ${reconciler}(${arrExpr}); return; }
1484
1672
  ${reconciler}(${arrExpr});
1485
- });
1486
- }`);
1673
+ })`);
1487
1674
  }
1488
1675
  }
1489
1676
  else {
@@ -1516,9 +1703,7 @@ function emitMap(ctx, node, tracked, parentVar) {
1516
1703
  ctx.push(`__place(${anchor}, ${endAnchor}, ${collectVar}, ${parent});`);
1517
1704
  if (!reactiveSource)
1518
1705
  return null;
1519
- ctx.effects.push(`{
1520
- let __first = true;
1521
- effect(${effOpen}
1706
+ pushEffect(`let __first = true;`, `effect(${effOpen}
1522
1707
  const __new = ${hasItems}();
1523
1708
  if (__first) { __first = false; __new; return; }
1524
1709
  if (__new !== ${isEmptyVar}) {
@@ -1537,8 +1722,7 @@ ${renderAllItems(' ', null)}
1537
1722
  if (__new) {
1538
1723
  ${renderAllItems(' ', null)}
1539
1724
  } else { ${aw}${emptyRenderName}(${collectVar ? collectVar : ''}); }
1540
- });
1541
- }`);
1725
+ })`);
1542
1726
  }
1543
1727
  else {
1544
1728
  const collectVar = hyd ? ctx.n() : null;
@@ -1549,17 +1733,14 @@ ${renderAllItems(' ', null)}
1549
1733
  ctx.push(`__place(${anchor}, ${endAnchor}, ${collectVar}, ${parent});`);
1550
1734
  if (!reactiveSource)
1551
1735
  return null;
1552
- ctx.effects.push(`{
1553
- let __first = true;
1554
- effect(${effOpen}
1736
+ pushEffect(`let __first = true;`, `effect(${effOpen}
1555
1737
  const __nv = ${arrExpr};
1556
1738
  if (__first) { __first = false; __nv; return; }
1557
1739
  for (const e of ${effectsVar}) destroy_block(e);
1558
1740
  ${effectsVar}.length = 0;
1559
1741
  __cleanup(${anchor}, ${endAnchor});
1560
1742
  ${renderAllItems(' ', null)}
1561
- });
1562
- }`);
1743
+ })`);
1563
1744
  }
1564
1745
  }
1565
1746
  return null;
@@ -1567,11 +1748,12 @@ ${renderAllItems(' ', null)}
1567
1748
  function computeAsyncComponents(comps) {
1568
1749
  return new Set(comps.filter((c) => c.isAsync).map((c) => c.name));
1569
1750
  }
1570
- function generateComponent(comp, importedNames = new Set(), hydrate = false, asyncComps = new Set(), linkNames = new Set()) {
1751
+ function generateComponent(comp, importedNames = new Set(), hydrate = false, asyncComps = new Set(), linkNames = new Set(), selfClaimNames = new Set(), alloc) {
1571
1752
  const tracked = collectTrackedNames(comp.body);
1572
- const ctx = new Ctx();
1753
+ const ctx = new Ctx(alloc);
1573
1754
  ctx.importedNames = importedNames;
1574
1755
  ctx.linkNames = linkNames;
1756
+ ctx.selfClaimNames = selfClaimNames;
1575
1757
  ctx.hydrate = hydrate;
1576
1758
  ctx.asyncComps = asyncComps;
1577
1759
  ctx.isAsyncScope = comp.isAsync || asyncComps.has(comp.name);
@@ -1587,7 +1769,16 @@ function generateComponent(comp, importedNames = new Set(), hydrate = false, asy
1587
1769
  ctx.push(indent(`}`));
1588
1770
  }
1589
1771
  if (ctx.hydrate) {
1772
+ // Two distinct roles must not be conflated:
1773
+ // - `$root` is the shared page container. It is only ever a last-resort
1774
+ // mount target for detached dynamic regions (`__place`) that could not
1775
+ // be claimed in place; it must never be handed back to a caller.
1776
+ // - `$mount` is THIS component's own first top-level node. It is what the
1777
+ // hydrator returns so the parent can position/guard the component. A
1778
+ // component's own node is never an ancestor of the caller, so the
1779
+ // parent's `contains()` guards and `__place` end-anchor bounds are valid.
1590
1780
  ctx.push(indent(`const $root = __hydrate.root;`));
1781
+ ctx.push(indent(`let $mount = null;`));
1591
1782
  }
1592
1783
  else {
1593
1784
  ctx.push(indent(`const $root = document.createDocumentFragment();`));
@@ -1596,11 +1787,23 @@ function generateComponent(comp, importedNames = new Set(), hydrate = false, asy
1596
1787
  const paramInit = buildParamInit(comp.paramNames);
1597
1788
  if (paramInit)
1598
1789
  ctx.push(indent(paramInit));
1790
+ // Hoist TrackDecls to top to avoid TDZ when Head/effects reference them before declaration
1599
1791
  for (const node of comp.body) {
1792
+ if (node instanceof TrackDecl) {
1793
+ emitNode(ctx, node, tracked, null);
1794
+ }
1795
+ }
1796
+ for (const node of comp.body) {
1797
+ if (node instanceof TrackDecl)
1798
+ continue;
1600
1799
  const v = emitNode(ctx, node, tracked, null);
1601
1800
  if (v) {
1602
1801
  if (ctx.hydrate) {
1603
- ctx.push(indent(`if (${v}.parentNode !== $root) { if (!$root || ${v}.parentNode == null || !$root.contains(${v})) $root.appendChild(${v}); }`));
1802
+ // The first top-level node becomes `$mount`. Late top-level nodes are
1803
+ // already in the DOM when claimed; only a divergent fresh node is
1804
+ // nested onto the mount (matching the pre-marker wrapper behaviour).
1805
+ ctx.push(indent(`if ($mount === null) $mount = ${v};`));
1806
+ ctx.push(indent(`if (${v} !== $mount && ${v}.parentNode == null) $mount.appendChild(${v});`));
1604
1807
  }
1605
1808
  else {
1606
1809
  ctx.push(indent(`$root.appendChild(${v});`));
@@ -1613,7 +1816,15 @@ function generateComponent(comp, importedNames = new Set(), hydrate = false, asy
1613
1816
  const delCode = ctx.emitDelegates();
1614
1817
  if (delCode)
1615
1818
  ctx.push(indent(delCode.trim()));
1616
- ctx.push(indent(`return __pendingChild || $root;`));
1819
+ if (ctx.hydrate) {
1820
+ // A hydrator with no claimable nodes must still hand its caller a value the
1821
+ // parent guards can safely inspect (an empty fragment places nothing).
1822
+ ctx.push(indent(`if ($mount === null) $mount = document.createDocumentFragment();`));
1823
+ ctx.push(indent(`return __pendingChild || $mount;`));
1824
+ }
1825
+ else {
1826
+ ctx.push(indent(`return __pendingChild || $root;`));
1827
+ }
1617
1828
  ctx.push(indent(`} finally {`));
1618
1829
  ctx.push(indent(`\tsetActiveComponent(__prev);`));
1619
1830
  ctx.push(indent(`}`));
@@ -1627,7 +1838,7 @@ function buildParamInit(paramNames) {
1627
1838
  return '';
1628
1839
  return `const { ${paramNames.join(', ')} } = props;`;
1629
1840
  }
1630
- function buildComponentMap(irRoot, hydrate = false) {
1841
+ function buildComponentMap(irRoot, hydrate = false, alloc) {
1631
1842
  const mapLines = [];
1632
1843
  mapLines.push(`const __components = {};`);
1633
1844
  const asyncComps = computeAsyncComponents(irRoot.components);
@@ -1635,7 +1846,19 @@ function buildComponentMap(irRoot, hydrate = false) {
1635
1846
  // hydration, so their children fragments must be rebuilt in full. Track the
1636
1847
  // file-local binding names regardless of aliasing (`const Nav = NavLink`).
1637
1848
  const linkNames = new Set();
1849
+ // Runtime components that self-claim during hydration (adopt their own SSR
1850
+ // root via nextElement and return a fragment when claimed). Excluded from the
1851
+ // call-site adoption guard; the walker cursor passes them but never a
1852
+ // claimOnly() on their behalf.
1853
+ const selfClaimNames = new Set();
1854
+ const SELF_CLAIM_RUNTIME = new Set(['Link', 'NavLink', 'Md', 'Form', 'Field', 'LoadingIndicator']);
1638
1855
  for (const imp of irRoot.imports) {
1856
+ if (importModuleTarget(imp) !== '@vesk/runtime')
1857
+ continue;
1858
+ for (const pair of importBindingPairs(imp)) {
1859
+ if (SELF_CLAIM_RUNTIME.has(pair.imported))
1860
+ selfClaimNames.add(pair.local);
1861
+ }
1639
1862
  for (const pair of importBindingPairs(imp)) {
1640
1863
  if (pair.imported === 'Link' || pair.imported === 'NavLink')
1641
1864
  linkNames.add(pair.local);
@@ -1650,11 +1873,13 @@ function buildComponentMap(irRoot, hydrate = false) {
1650
1873
  const directNames = new Set([...extractRuntimeNames(irRoot.imports), ...localValueImportNames(irRoot.imports), ...topValueNames]);
1651
1874
  for (const comp of irRoot.components) {
1652
1875
  if (hydrate && isStaticComponent(comp)) {
1653
- const stub = `(props, __registry, __hydrate) => { return __hydrate.root; }`;
1876
+ // Static components keep their SSR content untouched: claim the root
1877
+ // WITHOUT stripping its direct text (nothing will re-create it).
1878
+ const stub = `(props, __registry, __hydrate) => { return __hydrate.claimOnly(); }`;
1654
1879
  mapLines.push(`__components[${JSON.stringify(comp.name)}] = ${stub};`);
1655
1880
  continue;
1656
1881
  }
1657
- const code = generateComponent(comp, directNames, hydrate, asyncComps, linkNames);
1882
+ const code = generateComponent(comp, directNames, hydrate, asyncComps, linkNames, selfClaimNames, alloc);
1658
1883
  mapLines.push(`__components[${JSON.stringify(comp.name)}] = ${code};`);
1659
1884
  }
1660
1885
  mapLines.push(`function __cleanup(start, end) {`);
@@ -1683,6 +1908,40 @@ function buildComponentMap(irRoot, hydrate = false) {
1683
1908
  mapLines.push(`}`);
1684
1909
  return mapLines.join('\n\n');
1685
1910
  }
1911
+ function ssrResidueEstimate(node) {
1912
+ if (Array.isArray(node)) {
1913
+ let total = 0;
1914
+ for (const n of node)
1915
+ total += ssrResidueEstimate(n);
1916
+ return total;
1917
+ }
1918
+ if (node instanceof StaticNode)
1919
+ return 1;
1920
+ if (node instanceof ComponentCall)
1921
+ return 1;
1922
+ if (node instanceof OpaqueDynamicRegion) {
1923
+ return Math.max(ssrResidueEstimate(node.consequentNodes), ssrResidueEstimate(node.alternateNodes));
1924
+ }
1925
+ if (node instanceof MapRegion) {
1926
+ return Math.max(ssrResidueEstimate(node.bodyTemplate), ssrResidueEstimate(node.alternateNodes));
1927
+ }
1928
+ if (node instanceof ForLoop)
1929
+ return ssrResidueEstimate(node.bodyTemplate);
1930
+ if (node instanceof WhileLoop)
1931
+ return ssrResidueEstimate(node.bodyTemplate);
1932
+ if (node instanceof SwitchBlock) {
1933
+ let max = 0;
1934
+ for (const c of node.cases)
1935
+ max = Math.max(max, ssrResidueEstimate(c.body));
1936
+ return max;
1937
+ }
1938
+ if (node instanceof TryCatch) {
1939
+ return Math.max(ssrResidueEstimate(node.bodyTemplate), ssrResidueEstimate(node.catchBody));
1940
+ }
1941
+ // TextNode, DynamicBinding, TrackDecl, RuntimeStatement, SlotNode,
1942
+ // ServerBlock, ClientBlock, HeadBlock emit no SSR element residue.
1943
+ return 0;
1944
+ }
1686
1945
  function isStaticIR(body) {
1687
1946
  for (const node of body) {
1688
1947
  if (node instanceof StaticNode) {
@@ -1778,6 +2037,10 @@ function findBindingInIR(nodes, names) {
1778
2037
  if (findBindingInIR(node.children, names))
1779
2038
  return true;
1780
2039
  }
2040
+ else if (node instanceof PropSlot) {
2041
+ if (findBindingInIR(node.body, names))
2042
+ return true;
2043
+ }
1781
2044
  else if (node instanceof RuntimeStatement) {
1782
2045
  if (astHasBinding(node.ast, names))
1783
2046
  return true;
@@ -1806,6 +2069,10 @@ function findFormNameInIR(nodes, name) {
1806
2069
  if (findFormNameInIR(node.children, name))
1807
2070
  return true;
1808
2071
  }
2072
+ if (node instanceof PropSlot) {
2073
+ if (findFormNameInIR(node.body, name))
2074
+ return true;
2075
+ }
1809
2076
  if (node instanceof DynamicBinding && node.expression && node.expression.raw) {
1810
2077
  if (node.expression.raw.includes(fnPattern))
1811
2078
  return true;
@@ -1884,7 +2151,7 @@ function emitClientFromIR(ir, options) {
1884
2151
  if (!options.forceClient && !needsClient) {
1885
2152
  return '';
1886
2153
  }
1887
- const componentMapCode = buildComponentMap(ir, options.hydrate);
2154
+ const componentMapCode = buildComponentMap(ir, options.hydrate, options.nameAllocator);
1888
2155
  const importLines = ir.imports.length > 0 ? ir.imports.join('\n') + '\n' : '';
1889
2156
  const topCode = (options.includeTopLevel === false ? [] : transformTopLevelForActions(ir.topLevelCode, 'client')).join('\n') + '\n';
1890
2157
  const exportLines = [];
@@ -1899,7 +2166,7 @@ function emitClientFromIR(ir, options) {
1899
2166
  }
1900
2167
  }
1901
2168
  const exportCode = exportLines.join('\n');
1902
- const runtimeNames = ['track', 'get', 'set', 'destroy_block', 'getActiveComponent', 'setActiveComponent', 'reactiveProps'];
2169
+ const runtimeNames = ['track', 'get', 'set', 'destroy_block', 'getActiveComponent', 'setActiveComponent', 'reactiveProps', 'applyStyle'];
1903
2170
  if (ir.components.some(c => !isStaticIR(c.body)))
1904
2171
  runtimeNames.push('effect');
1905
2172
  for (const name of usedRuntimeBindings(ir))
@@ -1950,7 +2217,7 @@ export function compileClient(source, _componentName, options = {}) {
1950
2217
  }
1951
2218
  const ast = parse(source, options.sourcePath ? { filename: options.sourcePath } : {});
1952
2219
  const ir = generateIR(ast, source, options.sourcePath);
1953
- return emitClientFromIR(ir, options);
2220
+ return emitClientFromIR(ir, { ...options, nameAllocator: options.nameAllocator ?? nameAllocFor(options.sourcePath) });
1954
2221
  }
1955
2222
  /**
1956
2223
  * Compiles a component source in BOTH client modes (plain + hydrate)
@@ -1979,9 +2246,12 @@ export function compileClientBoth(source, _componentName, sourcePath) {
1979
2246
  if (exportedComp)
1980
2247
  name = exportedComp.name;
1981
2248
  }
2249
+ // Comp and hyd land in the same scoped file block, so they share one
2250
+ // allocator: hyd continues after the names comp already consumed.
2251
+ const alloc = nameAllocFor(sourcePath);
1982
2252
  return {
1983
- comp: emitClientFromIR(ir, { forceClient: true }),
1984
- hyd: emitClientFromIR(irHyd, { forceClient: true, hydrate: true, includeTopLevel: false }),
2253
+ comp: emitClientFromIR(ir, { forceClient: true, nameAllocator: alloc }),
2254
+ hyd: emitClientFromIR(irHyd, { forceClient: true, hydrate: true, includeTopLevel: false, nameAllocator: alloc }),
1985
2255
  name,
1986
2256
  };
1987
2257
  }