@barefootjs/jsx 0.31.2 → 0.31.4

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 (65) hide show
  1. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  2. package/dist/adapters/template-imports.d.ts +27 -15
  3. package/dist/adapters/template-imports.d.ts.map +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/debug.d.ts.map +1 -1
  6. package/dist/identifier-pattern.d.ts +62 -0
  7. package/dist/identifier-pattern.d.ts.map +1 -0
  8. package/dist/index.d.ts +3 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +280 -119
  11. package/dist/ir-to-client-js/collect-elements.d.ts +23 -2
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/csr-substitute.d.ts +12 -1
  16. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/html-template.d.ts +20 -12
  18. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/prop-handling.d.ts +25 -2
  21. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/reactivity.d.ts +30 -2
  23. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  26. package/dist/jsx-to-ir.d.ts.map +1 -1
  27. package/dist/module-exports.d.ts.map +1 -1
  28. package/dist/prop-rewrite.d.ts +1 -1
  29. package/dist/relocate.d.ts.map +1 -1
  30. package/dist/scope/binding-scope.d.ts +179 -0
  31. package/dist/scope/binding-scope.d.ts.map +1 -0
  32. package/dist/types.d.ts +8 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/__tests__/binding-scope-preamble-shadowing.test.ts +115 -0
  36. package/src/__tests__/binding-scope-ratchet.test.ts +194 -0
  37. package/src/__tests__/binding-scope.test.ts +200 -0
  38. package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +76 -0
  39. package/src/__tests__/csr-substitute-enclosing-scope.test.ts +77 -0
  40. package/src/__tests__/identifier-pattern.test.ts +170 -0
  41. package/src/__tests__/let-type-annotation.test.ts +208 -0
  42. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +107 -0
  43. package/src/__tests__/rewrite-dynamic-imports.test.ts +98 -0
  44. package/src/adapters/jsx-adapter.ts +34 -5
  45. package/src/adapters/template-imports.ts +93 -0
  46. package/src/analyzer.ts +20 -3
  47. package/src/debug.ts +4 -3
  48. package/src/identifier-pattern.ts +79 -0
  49. package/src/index.ts +5 -1
  50. package/src/ir-to-client-js/collect-elements.ts +44 -15
  51. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +2 -1
  52. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +2 -1
  53. package/src/ir-to-client-js/csr-substitute.ts +19 -2
  54. package/src/ir-to-client-js/html-template.ts +37 -31
  55. package/src/ir-to-client-js/imports.ts +2 -1
  56. package/src/ir-to-client-js/prop-handling.ts +28 -1
  57. package/src/ir-to-client-js/reactivity.ts +54 -4
  58. package/src/ir-to-client-js/rewrite-props-object.ts +2 -1
  59. package/src/ir-to-client-js/utils.ts +9 -8
  60. package/src/jsx-to-ir.ts +187 -73
  61. package/src/module-exports.ts +3 -2
  62. package/src/prop-rewrite.ts +1 -1
  63. package/src/relocate.ts +2 -1
  64. package/src/scope/binding-scope.ts +238 -0
  65. package/src/types.ts +8 -0
package/src/jsx-to-ir.ts CHANGED
@@ -62,6 +62,8 @@ import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
62
62
  import { reconstructAsSegments } from './strip-types.ts'
63
63
  import { templatePartsToJsExpr } from './template-parts.ts'
64
64
  import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
65
+ import { BindingScope } from './scope/binding-scope.ts'
66
+ import { identifierPattern, identifierCallPattern } from './identifier-pattern.ts'
65
67
 
66
68
  // =============================================================================
67
69
  // Transform Context
@@ -102,12 +104,18 @@ interface TransformContext {
102
104
  * `_p.<local>` (#2524 CSR half: `_p` is always caller-keyed).
103
105
  */
104
106
  _destructuredPropAliases?: Map<string, string> | null
105
- /** Active loop parameter names for slotId assignment to loop-param-dependent expressions */
106
- loopParams: Set<string>
107
+ /**
108
+ * The active `.map()`/callback binding stack (#2482 Stage 1a) — replaces
109
+ * the former mutable per-name `Set<string>` mutated in lockstep with
110
+ * `transformMapCall` entry/exit. `enterLoopRow`/`enterCallback` return a
111
+ * NEW `BindingScope`; restoring the saved parent reference on exit is
112
+ * the whole mechanism (no `.delete()` bookkeeping to get wrong).
113
+ */
114
+ scope: BindingScope
107
115
  /**
108
116
  * Count of enclosing `.map()` loops (0 = outermost), incremented/
109
117
  * decremented in lockstep with entering/leaving `transformMapCall`.
110
- * Unlike `loopParams` (a name Set that can gain several entries for
118
+ * Unlike `scope` (a binding stack that can gain several bound names for
111
119
  * ONE loop level via destructuring), this is a plain per-level
112
120
  * counter — the single source of truth `IRLoop.depth` is stamped
113
121
  * from, so every adapter's `data-key`/`data-key-N` suffix derives
@@ -133,8 +141,9 @@ interface TransformContext {
133
141
  /**
134
142
  * Memoized free-refs binding environment. Built lazily by
135
143
  * `makeBindingEnv` and reused across every `resolveFreeRefs` call as
136
- * long as `loopParams` content is unchanged. Invalidated by serializing
137
- * `loopParams` into `_bindingEnvLoopKey` and comparing on read.
144
+ * long as `scope`'s bound VALUE names are unchanged. Invalidated by
145
+ * serializing `scope.valueBoundNames()` into `_bindingEnvLoopKey` and
146
+ * comparing on read.
138
147
  */
139
148
  _bindingEnv?: BindingEnvironment
140
149
  _bindingEnvLoopKey?: string
@@ -525,15 +534,21 @@ function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext)
525
534
  let propNames = getDestructuredPropNames(ctx)
526
535
  if (!propNames) return dateLowered === text ? undefined : dateLowered
527
536
  // #2222: a name bound as an enclosing loop callback's item/index param
528
- // refers to the loop binding, not the prop, at THIS transform position —
529
- // `ctx.loopParams` is the live loop-param set (destructured binding
530
- // names and the index included), maintained by `transformMapCall` as it
531
- // enters/leaves each callback, so this guard is scope-accurate rather
532
- // than the coarse whole-component exclusion the SSR adapters use
533
- // (#2221). Filter into a fresh set `getDestructuredPropNames` caches
534
- // its set on ctx and must not be mutated.
535
- if (ctx.loopParams.size > 0) {
536
- const filtered = new Set([...propNames].filter(n => !ctx.loopParams.has(n)))
537
+ // (or, during the return-expression transform window, a preamble-
538
+ // declared local #2482 Stage 1a Commit 2 re-enters `ctx.scope` with
539
+ // the preamble for that window) refers to the loop binding, not the
540
+ // prop, at THIS transform position maintained by `transformMapCall`
541
+ // as it enters/leaves each callback, so this guard is scope-accurate
542
+ // rather than the coarse whole-component exclusion the SSR adapters use
543
+ // (#2221). This is a SHADOW-GUARD query every `ScopeBindingSource`
544
+ // qualifies, so it reads all-sources `boundNames()`, not
545
+ // `valueBoundNames()` (see that method's doc comment on
546
+ // `BindingScope` for the two-consumer-classes split). Filter into a
547
+ // fresh set — `getDestructuredPropNames` caches its set on ctx and must
548
+ // not be mutated.
549
+ const shadowingNames = ctx.scope.boundNames()
550
+ if (shadowingNames.size > 0) {
551
+ const filtered = new Set([...propNames].filter(n => !shadowingNames.has(n)))
537
552
  if (filtered.size === 0) return dateLowered === text ? undefined : dateLowered
538
553
  propNames = filtered
539
554
  }
@@ -662,24 +677,24 @@ function createTransformContext(analyzer: AnalyzerContext): TransformContext {
662
677
  spreadIdCounter: 0,
663
678
  isRoot: true,
664
679
  insideComponentChildren: false,
665
- loopParams: new Set(),
680
+ scope: BindingScope.EMPTY,
666
681
  loopDepth: 0,
667
682
  patterns: {
668
683
  signals: analyzer.signals.map(s => ({
669
684
  getter: s.getter,
670
- pattern: new RegExp(`\\b${s.getter}\\s*\\(`),
685
+ pattern: identifierCallPattern(s.getter),
671
686
  })),
672
687
  memos: analyzer.memos.map(m => ({
673
688
  name: m.name,
674
- pattern: new RegExp(`\\b${m.name}\\s*\\(`),
689
+ pattern: identifierCallPattern(m.name),
675
690
  })),
676
691
  props: analyzer.propsParams
677
692
  .filter(p => p.name !== 'children')
678
- .map(p => ({ name: p.name, pattern: new RegExp(`\\b${p.name}\\b`) })),
693
+ .map(p => ({ name: p.name, pattern: identifierPattern(p.name) })),
679
694
  constants: analyzer.localConstants.map(c => ({
680
695
  name: c.name,
681
696
  value: c.value,
682
- pattern: new RegExp(`\\b${c.name}\\b`),
697
+ pattern: identifierPattern(c.name),
683
698
  })),
684
699
  },
685
700
  getJS(node: ts.Node): string {
@@ -791,21 +806,27 @@ function generateSpreadSlotId(ctx: TransformContext): string {
791
806
  /**
792
807
  * Build the binding environment for `resolveFreeRefs` from the current
793
808
  * transform context. The analyzer's collected bindings (signals, memos,
794
- * props, locals, imports) plus the active loop params form the resolution
795
- * frame; the TypeChecker, when available, is forwarded so library getters
796
- * carrying the Reactive<T> brand are recognised.
809
+ * props, locals, imports) plus the active loop-bound VALUE names (item/
810
+ * index/destructure see `BindingScope.valueBoundNames`'s doc comment
811
+ * for why preamble locals are excluded here, #2482 Stage 1a Commit 2)
812
+ * form the resolution frame; the TypeChecker, when available, is
813
+ * forwarded so library getters carrying the Reactive<T> brand are
814
+ * recognised.
797
815
  *
798
816
  * Memoized on `ctx`: the returned env is identity-stable as long as
799
- * `loopParams` content is unchanged, which keeps the `WeakMap`-keyed
800
- * binding-table cache in `free-refs.ts` warm across every expression in
801
- * the same loop scope. `loopParams` is `.add`/`.delete`-mutated as the
802
- * visitor enters / leaves `.map()` callbacks, so we serialize its
803
- * contents into a key rather than relying on Set identity.
817
+ * `ctx.scope`'s bound VALUE names are unchanged, which keeps the
818
+ * `WeakMap`-keyed binding-table cache in `free-refs.ts` warm across every
819
+ * expression in the same loop scope. `ctx.scope` is REASSIGNED (never
820
+ * mutated) as the visitor enters / leaves `.map()` callbacks (#2482 Stage
821
+ * 1a), so we serialize its bound names into a key rather than relying on
822
+ * object identity — a sibling loop at the same nesting level would
823
+ * otherwise get a spurious cache miss/hit mismatch across restores.
804
824
  */
805
825
  function makeBindingEnv(ctx: TransformContext): BindingEnvironment {
806
- const loopKey = ctx.loopParams.size === 0
826
+ const boundNames = ctx.scope.valueBoundNames()
827
+ const loopKey = boundNames.size === 0
807
828
  ? ''
808
- : Array.from(ctx.loopParams).sort().join('\0')
829
+ : Array.from(boundNames).sort().join('\0')
809
830
  if (ctx._bindingEnv && ctx._bindingEnvLoopKey === loopKey) {
810
831
  return ctx._bindingEnv
811
832
  }
@@ -820,9 +841,11 @@ function makeBindingEnv(ctx: TransformContext): BindingEnvironment {
820
841
  localFunctions: a.localFunctions,
821
842
  imports: a.imports,
822
843
  ambientGlobals: a.ambientGlobals,
823
- // Snapshot the env must observe a stable view even if `ctx.loopParams`
824
- // is later mutated by an enclosing visitor frame.
825
- loopParams: new Set(ctx.loopParams),
844
+ // `valueBoundNames()` returns a per-instance set that is never
845
+ // mutated (cached on the immutable `BindingScope`) a stable
846
+ // snapshot even if `ctx.scope` is later reassigned by an enclosing
847
+ // visitor frame, which swaps the instance rather than mutating it.
848
+ loopParams: boundNames,
826
849
  checker: a.checker,
827
850
  }
828
851
  ctx._bindingEnv = env
@@ -2169,9 +2192,17 @@ function transformExpressionInner(
2169
2192
  const reactive = isReactiveExpression(exprText, ctx, expr) || isReactiveOrigin(origin)
2170
2193
  // @client expressions always need slotId and are treated as reactive for client-side evaluation
2171
2194
  // Expressions inside loops that reference the loop parameter need slotId
2172
- // so fine-grained effects can target them for per-item signal updates
2173
- const refsLoopParam = ctx.loopParams.size > 0
2174
- && Array.from(ctx.loopParams).some(p => new RegExp(`\\b${p}\\b`).test(exprText))
2195
+ // so fine-grained effects can target them for per-item signal updates.
2196
+ // REACTIVITY/slotId classifier value bindings only (item/index/
2197
+ // destructure), NOT preamble locals: those already get their own
2198
+ // dedicated slot/region-patch machinery (#2447), so folding them in
2199
+ // here would double-allocate and (via `hasDynamicContent` reading this
2200
+ // same `reactive`-adjacent signal) move an unrelated row-root slotId
2201
+ // decision — see `BindingScope.valueBoundNames`'s doc comment
2202
+ // (#2482 Stage 1a Commit 2).
2203
+ const scopeValueNames = ctx.scope.valueBoundNames()
2204
+ const refsLoopParam = scopeValueNames.size > 0
2205
+ && Array.from(scopeValueNames).some(p => identifierPattern(p).test(exprText))
2175
2206
 
2176
2207
  // Compute AST-derived flags. `callsReactive` recognises signal-getter / memo
2177
2208
  // calls even inside deeper expressions (e.g., `format(count())`); `hasCalls`
@@ -2237,7 +2268,11 @@ function transformJsxFunctionCall(
2237
2268
  const substitutedGetJS = (node: ts.Node) => {
2238
2269
  let text = baseGetJS(node)
2239
2270
  for (const [paramName, argExpr] of substitutions) {
2240
- text = text.replace(new RegExp(`\\b${paramName}\\b`, 'g'), argExpr)
2271
+ // Replacer function, not the string form: `argExpr` is arbitrary
2272
+ // caller-supplied JS text and may itself contain `$` sequences
2273
+ // (`$&`, `$1`, …) that `String.replace`'s string-replacement form
2274
+ // would interpret specially instead of inserting literally (#2592).
2275
+ text = text.replace(identifierPattern(paramName, 'g'), () => argExpr)
2241
2276
  }
2242
2277
  return text
2243
2278
  }
@@ -2289,7 +2324,11 @@ function transformMultiReturnJsxFunctionCall(
2289
2324
  const substitutedGetJS = (node: ts.Node) => {
2290
2325
  let text = baseGetJS(node)
2291
2326
  for (const [paramName, argExpr] of substitutions) {
2292
- text = text.replace(new RegExp(`\\b${paramName}\\b`, 'g'), argExpr)
2327
+ // Replacer function, not the string form: `argExpr` is arbitrary
2328
+ // caller-supplied JS text and may itself contain `$` sequences
2329
+ // (`$&`, `$1`, …) that `String.replace`'s string-replacement form
2330
+ // would interpret specially instead of inserting literally (#2592).
2331
+ text = text.replace(identifierPattern(paramName, 'g'), () => argExpr)
2293
2332
  }
2294
2333
  return text
2295
2334
  }
@@ -4021,8 +4060,13 @@ function transformMapCall(
4021
4060
  method: 'map' | 'flatMap' = 'map'
4022
4061
  ): IRLoop | null {
4023
4062
  // Capture nesting depth before we register this map's own params.
4024
- // ctx.loopParams is populated by the *outer* map; if non-empty we are inside one.
4025
- const isNested = ctx.loopParams.size > 0
4063
+ // ctx.scope is populated by the *outer* map; if any VALUE names (item/
4064
+ // index/destructure) are bound we are inside one. Reads
4065
+ // `valueBoundNames()`, not `boundNames()`, for consistency with the
4066
+ // other structural/reactivity consumers (#2482 Stage 1a Commit 2) —
4067
+ // though a bound outer-loop row always carries at least one value
4068
+ // binding regardless, so this can't actually change the answer.
4069
+ const isNested = ctx.scope.valueBoundNames().size > 0
4026
4070
  // Diagnostic count at entry — the structural net at the scalar fallthrough
4027
4071
  // de-dups against refusals fired DURING this call (leaf-wiring, DSL gates),
4028
4072
  // never against unrelated diagnostics recorded before it.
@@ -4322,17 +4366,17 @@ function transformMapCall(
4322
4366
  }
4323
4367
  }
4324
4368
 
4325
- // Register loop params so expressions referencing them get slotId.
4326
- // For destructured patterns, register the individual binding names
4327
- // `\b${param}\b` never matches a bare name like `cfg` when `param` is
4328
- // `[, cfg]`, which would otherwise leave reactive-expression detection
4329
- // silently broken for destructured callbacks.
4330
- if (paramBindings) {
4331
- for (const b of paramBindings) ctx.loopParams.add(b.name)
4332
- } else {
4333
- ctx.loopParams.add(param)
4334
- }
4335
- if (index) ctx.loopParams.add(index)
4369
+ // Register loop-bound names so expressions referencing them get slotId,
4370
+ // by entering a new BindingScope frame for this row (#2482 Stage 1a).
4371
+ // For destructured patterns, the frame binds the individual binding
4372
+ // names — `\b${param}\b` never matches a bare name like `cfg` when
4373
+ // `param` is `[, cfg]`, which would otherwise leave reactive-expression
4374
+ // detection silently broken for destructured callbacks.
4375
+ // `savedScope` is restored (not deleted-from) at the matching exit site
4376
+ // below, so a nested loop reusing a param name can never corrupt the
4377
+ // outer scope.
4378
+ const savedScope = ctx.scope
4379
+ ctx.scope = ctx.scope.enterLoopRow({ param, index, paramBindings })
4336
4380
  ctx.loopDepth++
4337
4381
 
4338
4382
  // Logical control flow (`cond && <X/>`, `a ?? themeLogo()`) as the map
@@ -4459,6 +4503,51 @@ function transformMapCall(
4459
4503
  (s): s is ts.ReturnStatement => ts.isReturnStatement(s) && s.expression != null
4460
4504
  )
4461
4505
  : undefined
4506
+
4507
+ // #2482 Stage 1a Commit 2 — ordering note: the STRUCTURED preamble
4508
+ // (`MapCallbackPreamble`, built further down by
4509
+ // `preambleFromValueStatements` / `buildPreambleSegments`, whichever
4510
+ // branch below the return-expression shape takes) isn't known until
4511
+ // well AFTER this point. But `transformNode(returnExpr, ctx)` just
4512
+ // below walks the return expression's own child expressions RIGHT
4513
+ // NOW — before that happens — so a preamble-declared local shadowing
4514
+ // an outer const/prop must already be bound in `ctx.scope`, or
4515
+ // `tryResolveTemplateSpanFromConst` / `tryResolveIdentifierAsTemplateLiteral`
4516
+ // / `rewriteBarePropRefs` bake the OUTER value into every row instead
4517
+ // of leaving the row-local unresolved (#2222-family, the const/prop-
4518
+ // shadow bug class). Fix: a lightweight declared-names-only pre-scan
4519
+ // — the exact same "statements before the return" shape
4520
+ // `preambleFromValueStatements`/`buildPreambleSegments` scan below —
4521
+ // re-enters the row's `BindingScope` frame (off `savedScope`, the
4522
+ // pre-loop parent, so this REPLACES the param/index-only frame
4523
+ // rather than stacking a second one) with the preamble included, for
4524
+ // the duration of the return-expression transform. Restored back to
4525
+ // the preamble-less row scope right after that window closes (see
4526
+ // `rowScopeBeforePreamble` below) — everything past this window
4527
+ // (the flatMap fallbacks, and the whole rest of `transformMapCall`
4528
+ // after this `if (ts.isBlock(body))` branch) must keep observing
4529
+ // EXACTLY the old (Commit 1) membership, since slotId-allocation
4530
+ // classifiers there read `ctx.scope.valueBoundNames()`, which never
4531
+ // included preamble names to begin with — only shadow guards need
4532
+ // the widened view, and only for this window.
4533
+ let rowScopeBeforePreamble: BindingScope | null = null
4534
+ if (returnStmt) {
4535
+ const preambleNames = new Set<string>()
4536
+ for (const stmt of body.statements) {
4537
+ if (stmt === returnStmt) break
4538
+ collectPreambleDeclaredNames(stmt, preambleNames)
4539
+ }
4540
+ if (preambleNames.size > 0) {
4541
+ rowScopeBeforePreamble = ctx.scope
4542
+ ctx.scope = savedScope.enterLoopRow({
4543
+ param,
4544
+ index,
4545
+ paramBindings,
4546
+ preamble: { declaredNames: [...preambleNames] },
4547
+ })
4548
+ }
4549
+ }
4550
+
4462
4551
  if (returnStmt && returnStmt.expression) {
4463
4552
  let returnExpr = returnStmt.expression
4464
4553
  while (ts.isParenthesizedExpression(returnExpr)) {
@@ -4617,6 +4706,14 @@ function transformMapCall(
4617
4706
  }
4618
4707
  }
4619
4708
 
4709
+ // Window closed — restore the preamble-less row scope so every
4710
+ // consumer past this point (the flatMap fallbacks immediately
4711
+ // below, and the rest of `transformMapCall` after this branch)
4712
+ // keeps observing EXACTLY the pre-rework membership.
4713
+ if (rowScopeBeforePreamble) {
4714
+ ctx.scope = rowScopeBeforePreamble
4715
+ }
4716
+
4620
4717
  // flatMap block body fallback: compile JSX inline when children
4621
4718
  // couldn't be extracted via the standard single-return path. A pure
4622
4719
  // single-`return <call>` projection is NOT taken here — it lowers to
@@ -4697,13 +4794,10 @@ function transformMapCall(
4697
4794
  )
4698
4795
  }
4699
4796
 
4700
- // Unregister loop params
4701
- if (paramBindings) {
4702
- for (const b of paramBindings) ctx.loopParams.delete(b.name)
4703
- } else {
4704
- ctx.loopParams.delete(param)
4705
- }
4706
- if (index) ctx.loopParams.delete(index)
4797
+ // Restore the parent scope — the BindingScope twin of the old
4798
+ // "unregister loop params" delete block, but by reference rather than
4799
+ // by name, so it can never miss an entry the add-site added.
4800
+ ctx.scope = savedScope
4707
4801
  ctx.loopDepth--
4708
4802
  }
4709
4803
 
@@ -6134,10 +6228,14 @@ function tryResolveTemplateSpanFromConst(
6134
6228
  // ${IDENT}
6135
6229
  if (ts.isIdentifier(expr)) {
6136
6230
  // #2222-family: inside a loop callback the name may be the loop's
6137
- // item/index binding shadowing a same-named const — resolving the
6138
- // const would bake the outer value into every row. Fall back to
6139
- // the bare-expression path, which sees the loop binding.
6140
- if (ctx.loopParams.has(expr.text)) return null
6231
+ // item/index/preamble binding shadowing a same-named const —
6232
+ // resolving the const would bake the outer value into every row.
6233
+ // Fall back to the bare-expression path, which sees the loop
6234
+ // binding. SHADOW-GUARD query — `isBound` (all sources), not
6235
+ // `valueBoundNames()` — see `BindingScope.valueBoundNames`'s doc
6236
+ // comment for the two-consumer-classes split (#2482 Stage 1a
6237
+ // Commit 2).
6238
+ if (ctx.scope.isBound(expr.text)) return null
6141
6239
  const constInfo = findLocalConst(expr.text, ctx.analyzer)
6142
6240
  if (!constInfo) return null
6143
6241
  const ast = parseConstInitializer(constInfo)
@@ -6154,7 +6252,7 @@ function tryResolveTemplateSpanFromConst(
6154
6252
  // Same loop-shadowing guard as the ${IDENT} arm: `tone[k]` inside
6155
6253
  // `items.map((tone) => …)` must read the row's `tone`, not a
6156
6254
  // same-named module/component record const.
6157
- if (ctx.loopParams.has(expr.expression.text)) return null
6255
+ if (ctx.scope.isBound(expr.expression.text)) return null
6158
6256
  const constInfo = findLocalConst(expr.expression.text, ctx.analyzer)
6159
6257
  if (!constInfo) return null
6160
6258
  const ast = parseConstInitializer(constInfo)
@@ -6326,9 +6424,13 @@ function tryResolveIdentifierAsTemplateLiteral(
6326
6424
  // outer const's literal into the IR here bakes the same hard-coded
6327
6425
  // value into EVERY adapter's output (e.g. `key={label}` inside
6328
6426
  // `.map((label) => ...)` becoming a constant duplicate key).
6329
- // `ctx.loopParams` is the live loop-param set (destructured binding
6330
- // names and index included), so the guard is scope-accurate.
6331
- if (ctx.loopParams.has(ident.text)) return null
6427
+ // `ctx.scope` is the live loop-binding stack (destructured binding
6428
+ // names and index included — plus, during the return-expression
6429
+ // transform window, the preamble's declared names once
6430
+ // `transformMapCall` re-enters the row scope with them, #2482 Stage 1a
6431
+ // Commit 2), so the guard is scope-accurate. This is a SHADOW-GUARD
6432
+ // query — reads `isBound` (all sources), not `valueBoundNames()`.
6433
+ if (ctx.scope.isBound(ident.text)) return null
6332
6434
  const constInfo = findLocalConst(ident.text, ctx.analyzer)
6333
6435
  if (!constInfo) return null
6334
6436
  const ast = parseConstInitializer(constInfo)
@@ -7002,11 +7104,16 @@ function isSignalOrMemoArray(array: string, ctx: TransformContext): boolean {
7002
7104
  * Used by conditional transforms to assign slotId for per-item signal reactivity.
7003
7105
  * NOT added to isReactiveExpression to avoid promoting text expressions
7004
7106
  * like {item.name} to reactive (they use a separate slotId path).
7107
+ *
7108
+ * REACTIVITY/slotId classifier — reads `valueBoundNames()` (item/index/
7109
+ * destructure only), NOT preamble locals; see `BindingScope.valueBoundNames`'s
7110
+ * doc comment (#2482 Stage 1a Commit 2).
7005
7111
  */
7006
7112
  function referencesLoopParam(expr: string, ctx: TransformContext): boolean {
7007
- if (ctx.loopParams.size === 0) return false
7008
- for (const p of ctx.loopParams) {
7009
- if (new RegExp(`\\b${p}\\b`).test(expr)) return true
7113
+ const boundNames = ctx.scope.valueBoundNames()
7114
+ if (boundNames.size === 0) return false
7115
+ for (const p of boundNames) {
7116
+ if (identifierPattern(p).test(expr)) return true
7010
7117
  }
7011
7118
  return false
7012
7119
  }
@@ -7129,11 +7236,18 @@ function hasReactiveAttributes(attrs: IRAttribute[], ctx: TransformContext): boo
7129
7236
  if (isSignalOrMemoReference(valueToCheck, ctx) || isPropsReference(valueToCheck, ctx)) {
7130
7237
  return true
7131
7238
  }
7132
- // Check if attribute references any active loop parameters
7133
- // loop root elements need a slotId so className can be updated reactively.
7134
- if (ctx.loopParams.size > 0) {
7135
- for (const p of ctx.loopParams) {
7136
- if (new RegExp(`\\b${p}\\b`).test(valueToCheck)) return true
7239
+ // Check if attribute references any active loop-bound VALUE names
7240
+ // loop root elements need a slotId so className can be updated
7241
+ // reactively. REACTIVITY/slotId classifier — value bindings only
7242
+ // (item/index/destructure), not preamble locals; see
7243
+ // `BindingScope.valueBoundNames`'s doc comment (#2482 Stage 1a
7244
+ // Commit 2) — this is the exact check whose over-widening flipped
7245
+ // the `tag-cloud`/`preamble-cells` conformance fixtures before the
7246
+ // source-filtered query existed.
7247
+ const scopeValueNames = ctx.scope.valueBoundNames()
7248
+ if (scopeValueNames.size > 0) {
7249
+ for (const p of scopeValueNames) {
7250
+ if (identifierPattern(p).test(valueToCheck)) return true
7137
7251
  }
7138
7252
  }
7139
7253
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ComponentIR, ParamInfo } from './types.ts'
9
+ import { identifierPattern } from './identifier-pattern.ts'
9
10
 
10
11
  /**
11
12
  * Emit module-level exports for local declarations and `export { ... } [from '...']`
@@ -140,7 +141,7 @@ export function findReachableNames(
140
141
  const queue: string[] = []
141
142
 
142
143
  for (const name of allNames) {
143
- if (new RegExp(`\\b${name}\\b`).test(primaryRefs)) {
144
+ if (identifierPattern(name).test(primaryRefs)) {
144
145
  reachable.add(name)
145
146
  queue.push(name)
146
147
  }
@@ -150,7 +151,7 @@ export function findReachableNames(
150
151
  const current = queue.shift()!
151
152
  const body = bodyMap.get(current) || ''
152
153
  for (const name of allNames) {
153
- if (!reachable.has(name) && new RegExp(`\\b${name}\\b`).test(body)) {
154
+ if (!reachable.has(name) && identifierPattern(name).test(body)) {
154
155
  reachable.add(name)
155
156
  queue.push(name)
156
157
  }
@@ -14,7 +14,7 @@
14
14
  * stack, so `items.map((title) => title.a)` never turns into the
15
15
  * syntactically invalid `.map((_p.title) => _p.title.a)` when `title`
16
16
  * is also a prop. (Names bound by loop callbacks that ENCLOSE the
17
- * expression are the caller's job — see the `ctx.loopParams` filter in
17
+ * expression are the caller's job — see the `ctx.scope` filter in
18
18
  * `jsx-to-ir.ts`'s `rewriteBarePropRefs` wrapper, #2222.)
19
19
  */
20
20
 
package/src/relocate.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  } from './adapters/interface.ts'
23
23
  import { tsNodeToParsedExpr, type ParsedExpr } from './expression-parser.ts'
24
24
  import { prepareLoweringMatchers, type LoweringMatcher } from './lowering-registry.ts'
25
+ import { identifierPattern } from './identifier-pattern.ts'
25
26
 
26
27
  export interface RelocateEnv {
27
28
  /**
@@ -713,7 +714,7 @@ function scanRefsByName(
713
714
  ): Map<string, ts.Identifier[]> {
714
715
  const result = new Map<string, ts.Identifier[]>()
715
716
  for (const name of bindings.keys()) {
716
- const re = new RegExp(`\\b${name}\\b`)
717
+ const re = identifierPattern(name)
717
718
  if (re.test(text)) result.set(name, [])
718
719
  }
719
720
  return result