@barefootjs/jsx 0.31.1 → 0.31.3

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.
Files changed (80) hide show
  1. package/dist/adapters/interface.d.ts +11 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +92 -1
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/test-adapter.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/compiler.d.ts.map +1 -1
  8. package/dist/css-layer-prefixer.d.ts +16 -0
  9. package/dist/css-layer-prefixer.d.ts.map +1 -1
  10. package/dist/errors.d.ts +1 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/html-types.d.ts +19 -0
  13. package/dist/html-types.d.ts.map +1 -1
  14. package/dist/index.d.ts +5 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1531 -1155
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/phases/props-event-handlers.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +6 -4
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/jsx-runtime/index.d.ts +2 -8
  24. package/dist/jsx-runtime/index.d.ts.map +1 -1
  25. package/dist/jsx-to-ir.d.ts.map +1 -1
  26. package/dist/module-exports.d.ts +9 -1
  27. package/dist/module-exports.d.ts.map +1 -1
  28. package/dist/prop-rewrite.d.ts +8 -3
  29. package/dist/prop-rewrite.d.ts.map +1 -1
  30. package/dist/props-binding.d.ts +40 -0
  31. package/dist/props-binding.d.ts.map +1 -0
  32. package/dist/relocate.d.ts +9 -0
  33. package/dist/relocate.d.ts.map +1 -1
  34. package/dist/scope/binding-scope.d.ts +179 -0
  35. package/dist/scope/binding-scope.d.ts.map +1 -0
  36. package/dist/ssr-defaults.d.ts +43 -0
  37. package/dist/ssr-defaults.d.ts.map +1 -1
  38. package/dist/template-parts.d.ts +53 -0
  39. package/dist/template-parts.d.ts.map +1 -0
  40. package/dist/types.d.ts +18 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +2 -2
  43. package/src/__tests__/adapter-output.test.ts +8 -4
  44. package/src/__tests__/aliased-destructured-prop-csr.test.ts +112 -0
  45. package/src/__tests__/binding-scope-preamble-shadowing.test.ts +115 -0
  46. package/src/__tests__/binding-scope-ratchet.test.ts +194 -0
  47. package/src/__tests__/binding-scope.test.ts +200 -0
  48. package/src/__tests__/css-layer-prefixer.test.ts +72 -0
  49. package/src/__tests__/form-control-value-ssr.test.ts +48 -3
  50. package/src/__tests__/let-type-annotation.test.ts +208 -0
  51. package/src/__tests__/memo-deps-comments.test.ts +99 -0
  52. package/src/__tests__/multi-return-sibling-diagnostic.test.ts +241 -0
  53. package/src/__tests__/ssr-defaults.test.ts +124 -1
  54. package/src/__tests__/staged-ir/08-relocate-unit.test.ts +1 -0
  55. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +1 -0
  56. package/src/adapters/interface.ts +11 -0
  57. package/src/adapters/jsx-adapter.ts +295 -5
  58. package/src/adapters/test-adapter.ts +13 -10
  59. package/src/analyzer.ts +70 -11
  60. package/src/compiler.ts +119 -18
  61. package/src/css-layer-prefixer.ts +80 -24
  62. package/src/errors.ts +18 -0
  63. package/src/html-types.ts +24 -0
  64. package/src/index.ts +11 -1
  65. package/src/ir-to-client-js/collect-elements.ts +4 -1
  66. package/src/ir-to-client-js/emit-reactive.ts +4 -2
  67. package/src/ir-to-client-js/phases/props-event-handlers.ts +4 -3
  68. package/src/ir-to-client-js/phases/props-extraction.ts +7 -4
  69. package/src/ir-to-client-js/plan/build-declaration-emit.ts +6 -3
  70. package/src/ir-to-client-js/utils.ts +5 -24
  71. package/src/jsx-runtime/index.ts +2 -7
  72. package/src/jsx-to-ir.ts +245 -107
  73. package/src/module-exports.ts +11 -2
  74. package/src/prop-rewrite.ts +26 -6
  75. package/src/props-binding.ts +70 -0
  76. package/src/relocate.ts +19 -2
  77. package/src/scope/binding-scope.ts +238 -0
  78. package/src/ssr-defaults.ts +70 -0
  79. package/src/template-parts.ts +81 -0
  80. package/src/types.ts +18 -0
package/src/jsx-to-ir.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  rewriteBarePropRefs as rewriteBarePropRefsCore,
50
50
  collectAstPropRefs,
51
51
  } from './prop-rewrite.ts'
52
+ import { buildPropAliasMap } from './props-binding.ts'
52
53
  import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironment } from './free-refs.ts'
53
54
  import { computeFileScope } from './ir-to-client-js/component-scope.ts'
54
55
  import { createTemplateAwareStringProtector } from './ir-to-client-js/html-template.ts'
@@ -59,7 +60,9 @@ import type { LoweringMatcher } from './lowering-registry.ts'
59
60
  import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx, extractMultiReturnJsxBranches, type MultiReturnJsxBranches } from './analyzer.ts'
60
61
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
61
62
  import { reconstructAsSegments } from './strip-types.ts'
63
+ import { templatePartsToJsExpr } from './template-parts.ts'
62
64
  import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
65
+ import { BindingScope } from './scope/binding-scope.ts'
63
66
 
64
67
  // =============================================================================
65
68
  // Transform Context
@@ -91,12 +94,27 @@ interface TransformContext {
91
94
  _moduleClientSignalNames?: Set<string>
92
95
  /** Cached set of destructured prop names for AST-based rewriting */
93
96
  _destructuredPropNames?: Set<string> | null
94
- /** Active loop parameter names for slotId assignment to loop-param-dependent expressions */
95
- loopParams: Set<string>
97
+ /**
98
+ * Cached local-name → caller-facing-key (`sourceName ?? name`) map for
99
+ * `_destructuredPropNames`, entries only for names that actually rename
100
+ * (`{ n: count }` → `count` → `n`). Built alongside `_destructuredPropNames`
101
+ * so both stay in lockstep with the same shadow-filtered eligible set —
102
+ * consumed by `rewriteBarePropRefsCore` to emit `_p.<caller>` instead of
103
+ * `_p.<local>` (#2524 CSR half: `_p` is always caller-keyed).
104
+ */
105
+ _destructuredPropAliases?: Map<string, string> | null
106
+ /**
107
+ * The active `.map()`/callback binding stack (#2482 Stage 1a) — replaces
108
+ * the former mutable per-name `Set<string>` mutated in lockstep with
109
+ * `transformMapCall` entry/exit. `enterLoopRow`/`enterCallback` return a
110
+ * NEW `BindingScope`; restoring the saved parent reference on exit is
111
+ * the whole mechanism (no `.delete()` bookkeeping to get wrong).
112
+ */
113
+ scope: BindingScope
96
114
  /**
97
115
  * Count of enclosing `.map()` loops (0 = outermost), incremented/
98
116
  * decremented in lockstep with entering/leaving `transformMapCall`.
99
- * Unlike `loopParams` (a name Set that can gain several entries for
117
+ * Unlike `scope` (a binding stack that can gain several bound names for
100
118
  * ONE loop level via destructuring), this is a plain per-level
101
119
  * counter — the single source of truth `IRLoop.depth` is stamped
102
120
  * from, so every adapter's `data-key`/`data-key-N` suffix derives
@@ -122,8 +140,9 @@ interface TransformContext {
122
140
  /**
123
141
  * Memoized free-refs binding environment. Built lazily by
124
142
  * `makeBindingEnv` and reused across every `resolveFreeRefs` call as
125
- * long as `loopParams` content is unchanged. Invalidated by serializing
126
- * `loopParams` into `_bindingEnvLoopKey` and comparing on read.
143
+ * long as `scope`'s bound VALUE names are unchanged. Invalidated by
144
+ * serializing `scope.valueBoundNames()` into `_bindingEnvLoopKey` and
145
+ * comparing on read.
127
146
  */
128
147
  _bindingEnv?: BindingEnvironment
129
148
  _bindingEnvLoopKey?: string
@@ -514,15 +533,21 @@ function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext)
514
533
  let propNames = getDestructuredPropNames(ctx)
515
534
  if (!propNames) return dateLowered === text ? undefined : dateLowered
516
535
  // #2222: a name bound as an enclosing loop callback's item/index param
517
- // refers to the loop binding, not the prop, at THIS transform position —
518
- // `ctx.loopParams` is the live loop-param set (destructured binding
519
- // names and the index included), maintained by `transformMapCall` as it
520
- // enters/leaves each callback, so this guard is scope-accurate rather
521
- // than the coarse whole-component exclusion the SSR adapters use
522
- // (#2221). Filter into a fresh set `getDestructuredPropNames` caches
523
- // its set on ctx and must not be mutated.
524
- if (ctx.loopParams.size > 0) {
525
- const filtered = new Set([...propNames].filter(n => !ctx.loopParams.has(n)))
536
+ // (or, during the return-expression transform window, a preamble-
537
+ // declared local #2482 Stage 1a Commit 2 re-enters `ctx.scope` with
538
+ // the preamble for that window) refers to the loop binding, not the
539
+ // prop, at THIS transform position maintained by `transformMapCall`
540
+ // as it enters/leaves each callback, so this guard is scope-accurate
541
+ // rather than the coarse whole-component exclusion the SSR adapters use
542
+ // (#2221). This is a SHADOW-GUARD query every `ScopeBindingSource`
543
+ // qualifies, so it reads all-sources `boundNames()`, not
544
+ // `valueBoundNames()` (see that method's doc comment on
545
+ // `BindingScope` for the two-consumer-classes split). Filter into a
546
+ // fresh set — `getDestructuredPropNames` caches its set on ctx and must
547
+ // not be mutated.
548
+ const shadowingNames = ctx.scope.boundNames()
549
+ if (shadowingNames.size > 0) {
550
+ const filtered = new Set([...propNames].filter(n => !shadowingNames.has(n)))
526
551
  if (filtered.size === 0) return dateLowered === text ? undefined : dateLowered
527
552
  propNames = filtered
528
553
  }
@@ -536,7 +561,8 @@ function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext)
536
561
  // `_branchScopePropDeps` at branch entry; here we just walk `expr`
537
562
  // for references to those locals and union the matching dep sets.
538
563
  const extraPropRefs = collectBranchLocalPropRefsViaSubstitution(expr, ctx)
539
- return rewriteBarePropRefsCore(dateLowered, expr, propNames, extraPropRefs)
564
+ const propAliases = getDestructuredPropAliases(ctx)
565
+ return rewriteBarePropRefsCore(dateLowered, expr, propNames, extraPropRefs, propAliases ?? undefined)
540
566
  }
541
567
 
542
568
  /**
@@ -618,14 +644,26 @@ function getDestructuredPropNames(ctx: TransformContext): Set<string> | null {
618
644
  if (!isDestructureFromProps) shadowed.add(c.name)
619
645
  }
620
646
  }
621
- const names = ctx.analyzer.propsParams
622
- .map(p => p.name)
623
- .filter(n => !shadowed.has(n))
647
+ const eligible = ctx.analyzer.propsParams.filter(p => !shadowed.has(p.name))
648
+ const names = eligible.map(p => p.name)
624
649
  ctx._destructuredPropNames = names.length > 0 ? new Set(names) : null
650
+ ctx._destructuredPropAliases = buildPropAliasMap(eligible) ?? null
625
651
  }
626
652
  return ctx._destructuredPropNames ?? null
627
653
  }
628
654
 
655
+ /**
656
+ * Companion to `getDestructuredPropNames`: the local-name → caller-key
657
+ * alias map for the SAME shadow-filtered eligible set, populated as a
658
+ * side effect of that call. Always call `getDestructuredPropNames` first
659
+ * (or accept it may return an empty cache) — the two caches are written
660
+ * together in one pass.
661
+ */
662
+ function getDestructuredPropAliases(ctx: TransformContext): Map<string, string> | null {
663
+ if (ctx._destructuredPropNames === undefined) getDestructuredPropNames(ctx)
664
+ return ctx._destructuredPropAliases ?? null
665
+ }
666
+
629
667
  function createTransformContext(analyzer: AnalyzerContext): TransformContext {
630
668
  return {
631
669
  analyzer,
@@ -638,7 +676,7 @@ function createTransformContext(analyzer: AnalyzerContext): TransformContext {
638
676
  spreadIdCounter: 0,
639
677
  isRoot: true,
640
678
  insideComponentChildren: false,
641
- loopParams: new Set(),
679
+ scope: BindingScope.EMPTY,
642
680
  loopDepth: 0,
643
681
  patterns: {
644
682
  signals: analyzer.signals.map(s => ({
@@ -767,21 +805,27 @@ function generateSpreadSlotId(ctx: TransformContext): string {
767
805
  /**
768
806
  * Build the binding environment for `resolveFreeRefs` from the current
769
807
  * transform context. The analyzer's collected bindings (signals, memos,
770
- * props, locals, imports) plus the active loop params form the resolution
771
- * frame; the TypeChecker, when available, is forwarded so library getters
772
- * carrying the Reactive<T> brand are recognised.
808
+ * props, locals, imports) plus the active loop-bound VALUE names (item/
809
+ * index/destructure see `BindingScope.valueBoundNames`'s doc comment
810
+ * for why preamble locals are excluded here, #2482 Stage 1a Commit 2)
811
+ * form the resolution frame; the TypeChecker, when available, is
812
+ * forwarded so library getters carrying the Reactive<T> brand are
813
+ * recognised.
773
814
  *
774
815
  * Memoized on `ctx`: the returned env is identity-stable as long as
775
- * `loopParams` content is unchanged, which keeps the `WeakMap`-keyed
776
- * binding-table cache in `free-refs.ts` warm across every expression in
777
- * the same loop scope. `loopParams` is `.add`/`.delete`-mutated as the
778
- * visitor enters / leaves `.map()` callbacks, so we serialize its
779
- * contents into a key rather than relying on Set identity.
816
+ * `ctx.scope`'s bound VALUE names are unchanged, which keeps the
817
+ * `WeakMap`-keyed binding-table cache in `free-refs.ts` warm across every
818
+ * expression in the same loop scope. `ctx.scope` is REASSIGNED (never
819
+ * mutated) as the visitor enters / leaves `.map()` callbacks (#2482 Stage
820
+ * 1a), so we serialize its bound names into a key rather than relying on
821
+ * object identity — a sibling loop at the same nesting level would
822
+ * otherwise get a spurious cache miss/hit mismatch across restores.
780
823
  */
781
824
  function makeBindingEnv(ctx: TransformContext): BindingEnvironment {
782
- const loopKey = ctx.loopParams.size === 0
825
+ const boundNames = ctx.scope.valueBoundNames()
826
+ const loopKey = boundNames.size === 0
783
827
  ? ''
784
- : Array.from(ctx.loopParams).sort().join('\0')
828
+ : Array.from(boundNames).sort().join('\0')
785
829
  if (ctx._bindingEnv && ctx._bindingEnvLoopKey === loopKey) {
786
830
  return ctx._bindingEnv
787
831
  }
@@ -796,9 +840,11 @@ function makeBindingEnv(ctx: TransformContext): BindingEnvironment {
796
840
  localFunctions: a.localFunctions,
797
841
  imports: a.imports,
798
842
  ambientGlobals: a.ambientGlobals,
799
- // Snapshot the env must observe a stable view even if `ctx.loopParams`
800
- // is later mutated by an enclosing visitor frame.
801
- loopParams: new Set(ctx.loopParams),
843
+ // `valueBoundNames()` returns a per-instance set that is never
844
+ // mutated (cached on the immutable `BindingScope`) a stable
845
+ // snapshot even if `ctx.scope` is later reassigned by an enclosing
846
+ // visitor frame, which swaps the instance rather than mutating it.
847
+ loopParams: boundNames,
802
848
  checker: a.checker,
803
849
  }
804
850
  ctx._bindingEnv = env
@@ -1278,12 +1324,21 @@ function transformJsxElement(
1278
1324
  * - `<textarea>` with no children gains a NON-reactive expression child
1279
1325
  * (the initial value as element content — updates keep flowing through
1280
1326
  * the `.value` effect, deliberately not a live text slot);
1281
- * - `<select>` distributes `selected={(value) === 'opt'}` onto each
1282
- * statically-valued `<option>` (incl. under `<optgroup>`/fragments)
1283
- * the exact per-option comparison shape the `select-option-selected`
1284
- * fixture already proves across every adapter. Options rendered by a
1285
- * dynamic loop can't be statically distributed and are left to the
1286
- * hydrate-time effect (tracked with #2466 on the #2464 thread).
1327
+ * - `<select>` distributes `selected={(value) === optValue}` onto each
1328
+ * `<option>` (incl. under `<optgroup>`/fragments, and under a `.map()`
1329
+ * loop body) — the exact per-option comparison shape the
1330
+ * `select-option-selected` fixture already proves across every adapter.
1331
+ * A literal `optValue` (e.g. `value="banana"`) compares by
1332
+ * `JSON.stringify`; an expression `optValue` (a static dynamic value, or
1333
+ * a loop row reading its item — e.g. `value={o.id}`) compares against
1334
+ * the expression text directly. For a loop row this makes `selected` an
1335
+ * ordinary per-item reactive attribute like any other (`o.id`, the row's
1336
+ * text) — it rides the SAME loop-plan machinery
1337
+ * (`collectLoopChildReactiveAttrs` → `emitAttrUpdate`, which special-cases
1338
+ * `selected` as a boolean DOM PROPERTY write, not just an HTML attribute)
1339
+ * that already reruns per row on item change and per outer-signal change,
1340
+ * so selectedness is recomputed instead of staying attached to whichever
1341
+ * physical `<option>` a reorder happened to rewrite in place (#2466).
1287
1342
  */
1288
1343
  function lowerFormControlValueSsr(
1289
1344
  tagName: string,
@@ -1318,21 +1373,47 @@ function lowerFormControlValueSsr(
1318
1373
  return
1319
1374
  }
1320
1375
 
1321
- const selectedFor = (optValue: string): AttrValue =>
1376
+ const selectedForLiteral = (optValue: string): AttrValue =>
1322
1377
  AttrValueOf.expression(
1323
1378
  `(${expr}) === ${JSON.stringify(optValue)}`,
1324
1379
  templateExpr !== undefined
1325
1380
  ? { templateExpr: `(${templateExpr}) === ${JSON.stringify(optValue)}` }
1326
1381
  : undefined,
1327
1382
  )
1383
+ // An expression-valued `option value` — the shape every `.map()` loop row
1384
+ // uses (`value={o.id}`) since the whole point of the loop is a per-item
1385
+ // value. Compares the controlled value directly against the option's own
1386
+ // value EXPRESSION TEXT (never JSON-stringified — it is JS, not a string
1387
+ // literal). Inside a loop row this expression reads both the row item
1388
+ // (`o.id`) and the outer controlled signal (`expr`, e.g. `val()`), which
1389
+ // is exactly the "reads both" shape `collectLoopChildReactiveAttrs` /
1390
+ // `classifyLazyBinding` already know how to place into both `applyItem`
1391
+ // (row changed) and `applyOuter` (controlled value changed) — no new loop
1392
+ // machinery, just one more per-row reactive attribute (#2466).
1393
+ const selectedForExpr = (optExpr: string, optTemplateExpr: string | undefined): AttrValue =>
1394
+ AttrValueOf.expression(
1395
+ `(${expr}) === (${optExpr})`,
1396
+ templateExpr !== undefined || optTemplateExpr !== undefined
1397
+ ? { templateExpr: `(${templateExpr ?? expr}) === (${optTemplateExpr ?? optExpr})` }
1398
+ : undefined,
1399
+ )
1328
1400
  const distribute = (nodes: IRNode[]): void => {
1329
1401
  for (const n of nodes) {
1330
1402
  if (n.type === 'element' && n.tag === 'option') {
1331
1403
  if (n.attrs.some(a => a.name === 'selected')) continue
1332
1404
  const optValue = n.attrs.find(a => a.name === 'value')
1333
- if (!optValue || optValue.value.kind !== 'literal') continue
1334
- n.attrs.push({ name: 'selected', value: selectedFor(optValue.value.value), loc: n.loc })
1335
- } else if (n.type === 'fragment' || (n.type === 'element' && n.tag === 'optgroup')) {
1405
+ if (!optValue) continue
1406
+ if (optValue.value.kind === 'literal') {
1407
+ n.attrs.push({ name: 'selected', value: selectedForLiteral(optValue.value.value), loc: n.loc })
1408
+ } else if (optValue.value.kind === 'expression') {
1409
+ const selected = selectedForExpr(optValue.value.expr, optValue.value.templateExpr)
1410
+ n.attrs.push({ name: 'selected', value: selected, loc: n.loc })
1411
+ }
1412
+ } else if (
1413
+ n.type === 'fragment' ||
1414
+ n.type === 'loop' ||
1415
+ (n.type === 'element' && n.tag === 'optgroup')
1416
+ ) {
1336
1417
  distribute(n.children)
1337
1418
  }
1338
1419
  }
@@ -2110,9 +2191,17 @@ function transformExpressionInner(
2110
2191
  const reactive = isReactiveExpression(exprText, ctx, expr) || isReactiveOrigin(origin)
2111
2192
  // @client expressions always need slotId and are treated as reactive for client-side evaluation
2112
2193
  // Expressions inside loops that reference the loop parameter need slotId
2113
- // so fine-grained effects can target them for per-item signal updates
2114
- const refsLoopParam = ctx.loopParams.size > 0
2115
- && Array.from(ctx.loopParams).some(p => new RegExp(`\\b${p}\\b`).test(exprText))
2194
+ // so fine-grained effects can target them for per-item signal updates.
2195
+ // REACTIVITY/slotId classifier value bindings only (item/index/
2196
+ // destructure), NOT preamble locals: those already get their own
2197
+ // dedicated slot/region-patch machinery (#2447), so folding them in
2198
+ // here would double-allocate and (via `hasDynamicContent` reading this
2199
+ // same `reactive`-adjacent signal) move an unrelated row-root slotId
2200
+ // decision — see `BindingScope.valueBoundNames`'s doc comment
2201
+ // (#2482 Stage 1a Commit 2).
2202
+ const scopeValueNames = ctx.scope.valueBoundNames()
2203
+ const refsLoopParam = scopeValueNames.size > 0
2204
+ && Array.from(scopeValueNames).some(p => new RegExp(`\\b${p}\\b`).test(exprText))
2116
2205
 
2117
2206
  // Compute AST-derived flags. `callsReactive` recognises signal-getter / memo
2118
2207
  // calls even inside deeper expressions (e.g., `format(count())`); `hasCalls`
@@ -3962,8 +4051,13 @@ function transformMapCall(
3962
4051
  method: 'map' | 'flatMap' = 'map'
3963
4052
  ): IRLoop | null {
3964
4053
  // Capture nesting depth before we register this map's own params.
3965
- // ctx.loopParams is populated by the *outer* map; if non-empty we are inside one.
3966
- const isNested = ctx.loopParams.size > 0
4054
+ // ctx.scope is populated by the *outer* map; if any VALUE names (item/
4055
+ // index/destructure) are bound we are inside one. Reads
4056
+ // `valueBoundNames()`, not `boundNames()`, for consistency with the
4057
+ // other structural/reactivity consumers (#2482 Stage 1a Commit 2) —
4058
+ // though a bound outer-loop row always carries at least one value
4059
+ // binding regardless, so this can't actually change the answer.
4060
+ const isNested = ctx.scope.valueBoundNames().size > 0
3967
4061
  // Diagnostic count at entry — the structural net at the scalar fallthrough
3968
4062
  // de-dups against refusals fired DURING this call (leaf-wiring, DSL gates),
3969
4063
  // never against unrelated diagnostics recorded before it.
@@ -4263,17 +4357,17 @@ function transformMapCall(
4263
4357
  }
4264
4358
  }
4265
4359
 
4266
- // Register loop params so expressions referencing them get slotId.
4267
- // For destructured patterns, register the individual binding names
4268
- // `\b${param}\b` never matches a bare name like `cfg` when `param` is
4269
- // `[, cfg]`, which would otherwise leave reactive-expression detection
4270
- // silently broken for destructured callbacks.
4271
- if (paramBindings) {
4272
- for (const b of paramBindings) ctx.loopParams.add(b.name)
4273
- } else {
4274
- ctx.loopParams.add(param)
4275
- }
4276
- if (index) ctx.loopParams.add(index)
4360
+ // Register loop-bound names so expressions referencing them get slotId,
4361
+ // by entering a new BindingScope frame for this row (#2482 Stage 1a).
4362
+ // For destructured patterns, the frame binds the individual binding
4363
+ // names — `\b${param}\b` never matches a bare name like `cfg` when
4364
+ // `param` is `[, cfg]`, which would otherwise leave reactive-expression
4365
+ // detection silently broken for destructured callbacks.
4366
+ // `savedScope` is restored (not deleted-from) at the matching exit site
4367
+ // below, so a nested loop reusing a param name can never corrupt the
4368
+ // outer scope.
4369
+ const savedScope = ctx.scope
4370
+ ctx.scope = ctx.scope.enterLoopRow({ param, index, paramBindings })
4277
4371
  ctx.loopDepth++
4278
4372
 
4279
4373
  // Logical control flow (`cond && <X/>`, `a ?? themeLogo()`) as the map
@@ -4400,6 +4494,51 @@ function transformMapCall(
4400
4494
  (s): s is ts.ReturnStatement => ts.isReturnStatement(s) && s.expression != null
4401
4495
  )
4402
4496
  : undefined
4497
+
4498
+ // #2482 Stage 1a Commit 2 — ordering note: the STRUCTURED preamble
4499
+ // (`MapCallbackPreamble`, built further down by
4500
+ // `preambleFromValueStatements` / `buildPreambleSegments`, whichever
4501
+ // branch below the return-expression shape takes) isn't known until
4502
+ // well AFTER this point. But `transformNode(returnExpr, ctx)` just
4503
+ // below walks the return expression's own child expressions RIGHT
4504
+ // NOW — before that happens — so a preamble-declared local shadowing
4505
+ // an outer const/prop must already be bound in `ctx.scope`, or
4506
+ // `tryResolveTemplateSpanFromConst` / `tryResolveIdentifierAsTemplateLiteral`
4507
+ // / `rewriteBarePropRefs` bake the OUTER value into every row instead
4508
+ // of leaving the row-local unresolved (#2222-family, the const/prop-
4509
+ // shadow bug class). Fix: a lightweight declared-names-only pre-scan
4510
+ // — the exact same "statements before the return" shape
4511
+ // `preambleFromValueStatements`/`buildPreambleSegments` scan below —
4512
+ // re-enters the row's `BindingScope` frame (off `savedScope`, the
4513
+ // pre-loop parent, so this REPLACES the param/index-only frame
4514
+ // rather than stacking a second one) with the preamble included, for
4515
+ // the duration of the return-expression transform. Restored back to
4516
+ // the preamble-less row scope right after that window closes (see
4517
+ // `rowScopeBeforePreamble` below) — everything past this window
4518
+ // (the flatMap fallbacks, and the whole rest of `transformMapCall`
4519
+ // after this `if (ts.isBlock(body))` branch) must keep observing
4520
+ // EXACTLY the old (Commit 1) membership, since slotId-allocation
4521
+ // classifiers there read `ctx.scope.valueBoundNames()`, which never
4522
+ // included preamble names to begin with — only shadow guards need
4523
+ // the widened view, and only for this window.
4524
+ let rowScopeBeforePreamble: BindingScope | null = null
4525
+ if (returnStmt) {
4526
+ const preambleNames = new Set<string>()
4527
+ for (const stmt of body.statements) {
4528
+ if (stmt === returnStmt) break
4529
+ collectPreambleDeclaredNames(stmt, preambleNames)
4530
+ }
4531
+ if (preambleNames.size > 0) {
4532
+ rowScopeBeforePreamble = ctx.scope
4533
+ ctx.scope = savedScope.enterLoopRow({
4534
+ param,
4535
+ index,
4536
+ paramBindings,
4537
+ preamble: { declaredNames: [...preambleNames] },
4538
+ })
4539
+ }
4540
+ }
4541
+
4403
4542
  if (returnStmt && returnStmt.expression) {
4404
4543
  let returnExpr = returnStmt.expression
4405
4544
  while (ts.isParenthesizedExpression(returnExpr)) {
@@ -4558,6 +4697,14 @@ function transformMapCall(
4558
4697
  }
4559
4698
  }
4560
4699
 
4700
+ // Window closed — restore the preamble-less row scope so every
4701
+ // consumer past this point (the flatMap fallbacks immediately
4702
+ // below, and the rest of `transformMapCall` after this branch)
4703
+ // keeps observing EXACTLY the pre-rework membership.
4704
+ if (rowScopeBeforePreamble) {
4705
+ ctx.scope = rowScopeBeforePreamble
4706
+ }
4707
+
4561
4708
  // flatMap block body fallback: compile JSX inline when children
4562
4709
  // couldn't be extracted via the standard single-return path. A pure
4563
4710
  // single-`return <call>` projection is NOT taken here — it lowers to
@@ -4638,13 +4785,10 @@ function transformMapCall(
4638
4785
  )
4639
4786
  }
4640
4787
 
4641
- // Unregister loop params
4642
- if (paramBindings) {
4643
- for (const b of paramBindings) ctx.loopParams.delete(b.name)
4644
- } else {
4645
- ctx.loopParams.delete(param)
4646
- }
4647
- if (index) ctx.loopParams.delete(index)
4788
+ // Restore the parent scope — the BindingScope twin of the old
4789
+ // "unregister loop params" delete block, but by reference rather than
4790
+ // by name, so it can never miss an entry the add-site added.
4791
+ ctx.scope = savedScope
4648
4792
  ctx.loopDepth--
4649
4793
  }
4650
4794
 
@@ -6075,10 +6219,14 @@ function tryResolveTemplateSpanFromConst(
6075
6219
  // ${IDENT}
6076
6220
  if (ts.isIdentifier(expr)) {
6077
6221
  // #2222-family: inside a loop callback the name may be the loop's
6078
- // item/index binding shadowing a same-named const — resolving the
6079
- // const would bake the outer value into every row. Fall back to
6080
- // the bare-expression path, which sees the loop binding.
6081
- if (ctx.loopParams.has(expr.text)) return null
6222
+ // item/index/preamble binding shadowing a same-named const —
6223
+ // resolving the const would bake the outer value into every row.
6224
+ // Fall back to the bare-expression path, which sees the loop
6225
+ // binding. SHADOW-GUARD query — `isBound` (all sources), not
6226
+ // `valueBoundNames()` — see `BindingScope.valueBoundNames`'s doc
6227
+ // comment for the two-consumer-classes split (#2482 Stage 1a
6228
+ // Commit 2).
6229
+ if (ctx.scope.isBound(expr.text)) return null
6082
6230
  const constInfo = findLocalConst(expr.text, ctx.analyzer)
6083
6231
  if (!constInfo) return null
6084
6232
  const ast = parseConstInitializer(constInfo)
@@ -6095,7 +6243,7 @@ function tryResolveTemplateSpanFromConst(
6095
6243
  // Same loop-shadowing guard as the ${IDENT} arm: `tone[k]` inside
6096
6244
  // `items.map((tone) => …)` must read the row's `tone`, not a
6097
6245
  // same-named module/component record const.
6098
- if (ctx.loopParams.has(expr.expression.text)) return null
6246
+ if (ctx.scope.isBound(expr.expression.text)) return null
6099
6247
  const constInfo = findLocalConst(expr.expression.text, ctx.analyzer)
6100
6248
  if (!constInfo) return null
6101
6249
  const ast = parseConstInitializer(constInfo)
@@ -6267,9 +6415,13 @@ function tryResolveIdentifierAsTemplateLiteral(
6267
6415
  // outer const's literal into the IR here bakes the same hard-coded
6268
6416
  // value into EVERY adapter's output (e.g. `key={label}` inside
6269
6417
  // `.map((label) => ...)` becoming a constant duplicate key).
6270
- // `ctx.loopParams` is the live loop-param set (destructured binding
6271
- // names and index included), so the guard is scope-accurate.
6272
- if (ctx.loopParams.has(ident.text)) return null
6418
+ // `ctx.scope` is the live loop-binding stack (destructured binding
6419
+ // names and index included — plus, during the return-expression
6420
+ // transform window, the preamble's declared names once
6421
+ // `transformMapCall` re-enters the row scope with them, #2482 Stage 1a
6422
+ // Commit 2), so the guard is scope-accurate. This is a SHADOW-GUARD
6423
+ // query — reads `isBound` (all sources), not `valueBoundNames()`.
6424
+ if (ctx.scope.isBound(ident.text)) return null
6273
6425
  const constInfo = findLocalConst(ident.text, ctx.analyzer)
6274
6426
  if (!constInfo) return null
6275
6427
  const ast = parseConstInitializer(constInfo)
@@ -6777,8 +6929,8 @@ function processComponentProps(
6777
6929
  // the bare `classes` identifier with no prop refs, so the rewrite
6778
6930
  // no-ops and the module-scope registration template leaks bare
6779
6931
  // destructured props into `renderChild(...)` (#2468).
6780
- const collapsed = templatePartsToJsString(value.parts)
6781
- const collapsedTemplate = templatePartsToJsString(value.parts, { useTemplate: true })
6932
+ const collapsed = templatePartsToJsExpr(value.parts)
6933
+ const collapsedTemplate = templatePartsToJsExpr(value.parts, { useTemplate: true })
6782
6934
  value = AttrValueOf.expression(collapsed, {
6783
6935
  parts: value.parts,
6784
6936
  ...(collapsedTemplate !== collapsed && { templateExpr: collapsedTemplate }),
@@ -6820,32 +6972,6 @@ function processComponentProps(
6820
6972
  return props
6821
6973
  }
6822
6974
 
6823
- /**
6824
- * Flatten a structured template-literal's parts back into a JS expression
6825
- * string. Used at IR construction time when a structured `template` variant
6826
- * needs to be collapsed into an `expression` for component-prop forwarding —
6827
- * component props are runtime JS values, not HTML attribute bodies.
6828
- */
6829
- function templatePartsToJsString(parts: readonly IRTemplatePart[], opts?: { useTemplate?: boolean }): string {
6830
- let result = '`'
6831
- for (const part of parts) {
6832
- if (part.type === 'string') {
6833
- result += (opts?.useTemplate && part.templateValue) ? part.templateValue : part.value
6834
- } else if (part.type === 'ternary') {
6835
- const cond = (opts?.useTemplate && part.templateCondition) ? part.templateCondition : part.condition
6836
- result += `\${${cond} ? '${part.whenTrue}' : '${part.whenFalse}'}`
6837
- } else if (part.type === 'lookup') {
6838
- const key = (opts?.useTemplate && part.templateKey) ? part.templateKey : part.key
6839
- const obj = '{' + Object.entries(part.cases).map(
6840
- ([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`
6841
- ).join(', ') + '}'
6842
- result += `\${(${obj})[${key}]}`
6843
- }
6844
- }
6845
- result += '`'
6846
- return result
6847
- }
6848
-
6849
6975
  // =============================================================================
6850
6976
  // Helpers
6851
6977
  // =============================================================================
@@ -6969,10 +7095,15 @@ function isSignalOrMemoArray(array: string, ctx: TransformContext): boolean {
6969
7095
  * Used by conditional transforms to assign slotId for per-item signal reactivity.
6970
7096
  * NOT added to isReactiveExpression to avoid promoting text expressions
6971
7097
  * like {item.name} to reactive (they use a separate slotId path).
7098
+ *
7099
+ * REACTIVITY/slotId classifier — reads `valueBoundNames()` (item/index/
7100
+ * destructure only), NOT preamble locals; see `BindingScope.valueBoundNames`'s
7101
+ * doc comment (#2482 Stage 1a Commit 2).
6972
7102
  */
6973
7103
  function referencesLoopParam(expr: string, ctx: TransformContext): boolean {
6974
- if (ctx.loopParams.size === 0) return false
6975
- for (const p of ctx.loopParams) {
7104
+ const boundNames = ctx.scope.valueBoundNames()
7105
+ if (boundNames.size === 0) return false
7106
+ for (const p of boundNames) {
6976
7107
  if (new RegExp(`\\b${p}\\b`).test(expr)) return true
6977
7108
  }
6978
7109
  return false
@@ -7096,10 +7227,17 @@ function hasReactiveAttributes(attrs: IRAttribute[], ctx: TransformContext): boo
7096
7227
  if (isSignalOrMemoReference(valueToCheck, ctx) || isPropsReference(valueToCheck, ctx)) {
7097
7228
  return true
7098
7229
  }
7099
- // Check if attribute references any active loop parameters
7100
- // loop root elements need a slotId so className can be updated reactively.
7101
- if (ctx.loopParams.size > 0) {
7102
- for (const p of ctx.loopParams) {
7230
+ // Check if attribute references any active loop-bound VALUE names
7231
+ // loop root elements need a slotId so className can be updated
7232
+ // reactively. REACTIVITY/slotId classifier — value bindings only
7233
+ // (item/index/destructure), not preamble locals; see
7234
+ // `BindingScope.valueBoundNames`'s doc comment (#2482 Stage 1a
7235
+ // Commit 2) — this is the exact check whose over-widening flipped
7236
+ // the `tag-cloud`/`preamble-cells` conformance fixtures before the
7237
+ // source-filtered query existed.
7238
+ const scopeValueNames = ctx.scope.valueBoundNames()
7239
+ if (scopeValueNames.size > 0) {
7240
+ for (const p of scopeValueNames) {
7103
7241
  if (new RegExp(`\\b${p}\\b`).test(valueToCheck)) return true
7104
7242
  }
7105
7243
  }
@@ -21,10 +21,19 @@ export function generateModuleExports(
21
21
  ir: ComponentIR,
22
22
  extraInlineExported: ReadonlySet<string> = new Set(),
23
23
  rewriteRelativeImport?: (importPath: string) => string,
24
+ options?: {
25
+ /**
26
+ * Skip `export const` / `export function` value declarations — the
27
+ * adapter already emitted them inside its module-scope section, in
28
+ * source order (see `TemplateSections.moduleConstantsIncludeExports`).
29
+ * `export { … } [from '…']` specifier blocks are still emitted.
30
+ */
31
+ skipValueDeclarations?: boolean
32
+ },
24
33
  ): string | null {
25
34
  const lines: string[] = []
26
35
 
27
- for (const constant of ir.metadata.localConstants) {
36
+ for (const constant of options?.skipValueDeclarations ? [] : ir.metadata.localConstants) {
28
37
  if (!constant.isExported) continue
29
38
  const keyword = constant.declarationKind ?? 'const'
30
39
  if (!constant.value) {
@@ -38,7 +47,7 @@ export function generateModuleExports(
38
47
  lines.push(`export ${keyword} ${constant.name} = ${constant.value}`)
39
48
  }
40
49
 
41
- for (const func of ir.metadata.localFunctions) {
50
+ for (const func of options?.skipValueDeclarations ? [] : ir.metadata.localFunctions) {
42
51
  if (!func.isExported) continue
43
52
  // Prefer the source-verbatim signature so type predicates and explicit
44
53
  // `:unknown` parameter annotations survive — see FunctionInfo.typedParams