@barefootjs/jsx 0.31.2 → 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.
package/src/jsx-to-ir.ts CHANGED
@@ -62,6 +62,7 @@ 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'
65
66
 
66
67
  // =============================================================================
67
68
  // Transform Context
@@ -102,12 +103,18 @@ interface TransformContext {
102
103
  * `_p.<local>` (#2524 CSR half: `_p` is always caller-keyed).
103
104
  */
104
105
  _destructuredPropAliases?: Map<string, string> | null
105
- /** Active loop parameter names for slotId assignment to loop-param-dependent expressions */
106
- loopParams: Set<string>
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
107
114
  /**
108
115
  * Count of enclosing `.map()` loops (0 = outermost), incremented/
109
116
  * decremented in lockstep with entering/leaving `transformMapCall`.
110
- * Unlike `loopParams` (a name Set that can gain several entries for
117
+ * Unlike `scope` (a binding stack that can gain several bound names for
111
118
  * ONE loop level via destructuring), this is a plain per-level
112
119
  * counter — the single source of truth `IRLoop.depth` is stamped
113
120
  * from, so every adapter's `data-key`/`data-key-N` suffix derives
@@ -133,8 +140,9 @@ interface TransformContext {
133
140
  /**
134
141
  * Memoized free-refs binding environment. Built lazily by
135
142
  * `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.
143
+ * long as `scope`'s bound VALUE names are unchanged. Invalidated by
144
+ * serializing `scope.valueBoundNames()` into `_bindingEnvLoopKey` and
145
+ * comparing on read.
138
146
  */
139
147
  _bindingEnv?: BindingEnvironment
140
148
  _bindingEnvLoopKey?: string
@@ -525,15 +533,21 @@ function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext)
525
533
  let propNames = getDestructuredPropNames(ctx)
526
534
  if (!propNames) return dateLowered === text ? undefined : dateLowered
527
535
  // #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)))
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)))
537
551
  if (filtered.size === 0) return dateLowered === text ? undefined : dateLowered
538
552
  propNames = filtered
539
553
  }
@@ -662,7 +676,7 @@ function createTransformContext(analyzer: AnalyzerContext): TransformContext {
662
676
  spreadIdCounter: 0,
663
677
  isRoot: true,
664
678
  insideComponentChildren: false,
665
- loopParams: new Set(),
679
+ scope: BindingScope.EMPTY,
666
680
  loopDepth: 0,
667
681
  patterns: {
668
682
  signals: analyzer.signals.map(s => ({
@@ -791,21 +805,27 @@ function generateSpreadSlotId(ctx: TransformContext): string {
791
805
  /**
792
806
  * Build the binding environment for `resolveFreeRefs` from the current
793
807
  * 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.
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.
797
814
  *
798
815
  * 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.
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.
804
823
  */
805
824
  function makeBindingEnv(ctx: TransformContext): BindingEnvironment {
806
- const loopKey = ctx.loopParams.size === 0
825
+ const boundNames = ctx.scope.valueBoundNames()
826
+ const loopKey = boundNames.size === 0
807
827
  ? ''
808
- : Array.from(ctx.loopParams).sort().join('\0')
828
+ : Array.from(boundNames).sort().join('\0')
809
829
  if (ctx._bindingEnv && ctx._bindingEnvLoopKey === loopKey) {
810
830
  return ctx._bindingEnv
811
831
  }
@@ -820,9 +840,11 @@ function makeBindingEnv(ctx: TransformContext): BindingEnvironment {
820
840
  localFunctions: a.localFunctions,
821
841
  imports: a.imports,
822
842
  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),
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,
826
848
  checker: a.checker,
827
849
  }
828
850
  ctx._bindingEnv = env
@@ -2169,9 +2191,17 @@ function transformExpressionInner(
2169
2191
  const reactive = isReactiveExpression(exprText, ctx, expr) || isReactiveOrigin(origin)
2170
2192
  // @client expressions always need slotId and are treated as reactive for client-side evaluation
2171
2193
  // 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))
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))
2175
2205
 
2176
2206
  // Compute AST-derived flags. `callsReactive` recognises signal-getter / memo
2177
2207
  // calls even inside deeper expressions (e.g., `format(count())`); `hasCalls`
@@ -4021,8 +4051,13 @@ function transformMapCall(
4021
4051
  method: 'map' | 'flatMap' = 'map'
4022
4052
  ): IRLoop | null {
4023
4053
  // 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
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
4026
4061
  // Diagnostic count at entry — the structural net at the scalar fallthrough
4027
4062
  // de-dups against refusals fired DURING this call (leaf-wiring, DSL gates),
4028
4063
  // never against unrelated diagnostics recorded before it.
@@ -4322,17 +4357,17 @@ function transformMapCall(
4322
4357
  }
4323
4358
  }
4324
4359
 
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)
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 })
4336
4371
  ctx.loopDepth++
4337
4372
 
4338
4373
  // Logical control flow (`cond && <X/>`, `a ?? themeLogo()`) as the map
@@ -4459,6 +4494,51 @@ function transformMapCall(
4459
4494
  (s): s is ts.ReturnStatement => ts.isReturnStatement(s) && s.expression != null
4460
4495
  )
4461
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
+
4462
4542
  if (returnStmt && returnStmt.expression) {
4463
4543
  let returnExpr = returnStmt.expression
4464
4544
  while (ts.isParenthesizedExpression(returnExpr)) {
@@ -4617,6 +4697,14 @@ function transformMapCall(
4617
4697
  }
4618
4698
  }
4619
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
+
4620
4708
  // flatMap block body fallback: compile JSX inline when children
4621
4709
  // couldn't be extracted via the standard single-return path. A pure
4622
4710
  // single-`return <call>` projection is NOT taken here — it lowers to
@@ -4697,13 +4785,10 @@ function transformMapCall(
4697
4785
  )
4698
4786
  }
4699
4787
 
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)
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
4707
4792
  ctx.loopDepth--
4708
4793
  }
4709
4794
 
@@ -6134,10 +6219,14 @@ function tryResolveTemplateSpanFromConst(
6134
6219
  // ${IDENT}
6135
6220
  if (ts.isIdentifier(expr)) {
6136
6221
  // #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
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
6141
6230
  const constInfo = findLocalConst(expr.text, ctx.analyzer)
6142
6231
  if (!constInfo) return null
6143
6232
  const ast = parseConstInitializer(constInfo)
@@ -6154,7 +6243,7 @@ function tryResolveTemplateSpanFromConst(
6154
6243
  // Same loop-shadowing guard as the ${IDENT} arm: `tone[k]` inside
6155
6244
  // `items.map((tone) => …)` must read the row's `tone`, not a
6156
6245
  // same-named module/component record const.
6157
- if (ctx.loopParams.has(expr.expression.text)) return null
6246
+ if (ctx.scope.isBound(expr.expression.text)) return null
6158
6247
  const constInfo = findLocalConst(expr.expression.text, ctx.analyzer)
6159
6248
  if (!constInfo) return null
6160
6249
  const ast = parseConstInitializer(constInfo)
@@ -6326,9 +6415,13 @@ function tryResolveIdentifierAsTemplateLiteral(
6326
6415
  // outer const's literal into the IR here bakes the same hard-coded
6327
6416
  // value into EVERY adapter's output (e.g. `key={label}` inside
6328
6417
  // `.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
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
6332
6425
  const constInfo = findLocalConst(ident.text, ctx.analyzer)
6333
6426
  if (!constInfo) return null
6334
6427
  const ast = parseConstInitializer(constInfo)
@@ -7002,10 +7095,15 @@ function isSignalOrMemoArray(array: string, ctx: TransformContext): boolean {
7002
7095
  * Used by conditional transforms to assign slotId for per-item signal reactivity.
7003
7096
  * NOT added to isReactiveExpression to avoid promoting text expressions
7004
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).
7005
7102
  */
7006
7103
  function referencesLoopParam(expr: string, ctx: TransformContext): boolean {
7007
- if (ctx.loopParams.size === 0) return false
7008
- 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) {
7009
7107
  if (new RegExp(`\\b${p}\\b`).test(expr)) return true
7010
7108
  }
7011
7109
  return false
@@ -7129,10 +7227,17 @@ function hasReactiveAttributes(attrs: IRAttribute[], ctx: TransformContext): boo
7129
7227
  if (isSignalOrMemoReference(valueToCheck, ctx) || isPropsReference(valueToCheck, ctx)) {
7130
7228
  return true
7131
7229
  }
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) {
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) {
7136
7241
  if (new RegExp(`\\b${p}\\b`).test(valueToCheck)) return true
7137
7242
  }
7138
7243
  }
@@ -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
 
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `BindingScope`: the one shared, immutable, stack-shaped model of "names
3
+ * bound by a loop callback" (#2482 Stage 0). Six independent ad-hoc
4
+ * mechanisms across the compiler currently answer this same question —
5
+ * `ctx.loopParams` (a mutated `Set<string>` in `jsx-to-ir.ts`),
6
+ * `collectLoopBoundNames` (`adapters/loop-bound-names.ts`),
7
+ * `resolveStaticLoopSource`'s `isNameShadowed` callback
8
+ * (`static-literal.ts`), and others — each reimplementing the same
9
+ * item/index/destructure/preamble-local bookkeeping with its own bugs and
10
+ * its own blind spots. This module is the single door those mechanisms
11
+ * migrate onto in later stages (Stage 0 only ships the service + tests;
12
+ * NO call site is migrated yet).
13
+ *
14
+ * Immutability is the point, not an incidental style choice:
15
+ * `ctx.loopParams` is `.add`/`.delete`-mutated as `jsx-to-ir.ts` walks in
16
+ * and back out of nested loops, so a caller that forgets (or races) a
17
+ * `.delete()` — or that holds a reference to the "current" set across a
18
+ * push/pop it didn't expect — silently observes the WRONG scope. A
19
+ * restore-bug of that shape is impossible by construction here:
20
+ * `enterLoopRow`/`enterCallback` never mutate `this`, they return a NEW
21
+ * `BindingScope` whose parent is untouched, so holding an old reference
22
+ * always sees the scope as it was, and there is no delete step to forget.
23
+ *
24
+ * Filter/sort callback params (`.filter(x => ...)`, `.sort((a, b) => ...)`,
25
+ * a nested arrow) are bound as `'callback'` frames via `enterCallback` —
26
+ * never folded into a `'loop-row'` frame's bindings. They are a distinct
27
+ * scope-introduction shape (an inner function's own parameter list, not a
28
+ * row's item/index/destructure/preamble names) even though both end up
29
+ * "just names you can't resolve against component-level state."
30
+ */
31
+
32
+ /**
33
+ * How a name inside a `ScopeFrame` came to be bound — the row shapes
34
+ * (`'item'`/`'index'`/`'destructure'`/`'preamble'`) for `'loop-row'` frames,
35
+ * `'param'` for `'callback'` frames. One shared type because both frame
36
+ * kinds carry the same binding metadata.
37
+ */
38
+ export type ScopeBindingSource = 'item' | 'index' | 'destructure' | 'preamble' | 'param'
39
+
40
+ export interface ScopeBinding {
41
+ readonly source: ScopeBindingSource
42
+ }
43
+
44
+ export interface ScopeFrame {
45
+ readonly kind: 'loop-row' | 'callback'
46
+ readonly bindings: ReadonlyMap<string, ScopeBinding>
47
+ }
48
+
49
+ /**
50
+ * Structural pick satisfied by BOTH `IRLoop` (`packages/jsx/src/types.ts`)
51
+ * and the client-JS `LoopCore` family (`packages/jsx/src/ir-to-client-js/types.ts`)
52
+ * without importing either — this module stays dependency-free and cannot
53
+ * form an import cycle with `types.ts` / `ir-to-client-js`.
54
+ */
55
+ export interface LoopBindingSource {
56
+ readonly param: string
57
+ readonly index?: string | null
58
+ readonly paramBindings?: readonly { readonly name: string }[]
59
+ readonly preamble?: { readonly declaredNames: readonly string[] } | null
60
+ }
61
+
62
+ /**
63
+ * A stack of `ScopeFrame`s, innermost frame at index 0 of the internal
64
+ * array (i.e. `frames[0]` is what `enterLoopRow`/`enterCallback` most
65
+ * recently pushed). `lookup`'s `depth` counts from `frames[0]`, so depth 0
66
+ * always means "the innermost frame," independent of how many ancestor
67
+ * frames exist.
68
+ */
69
+ export class BindingScope {
70
+ static readonly EMPTY: BindingScope = new BindingScope([])
71
+
72
+ private constructor(private readonly frames: readonly ScopeFrame[]) {}
73
+
74
+ /**
75
+ * Child scope with a new `'loop-row'` frame for one loop's per-item
76
+ * bindings. Parent (`this`) is not mutated; the returned scope is a
77
+ * NEW object with `frames = [newFrame, ...this.frames]`.
78
+ *
79
+ * Binding semantics mirror `jsx-to-ir.ts`'s `ctx.loopParams` add site
80
+ * EXACTLY (verified against lines ~4320-4345 and the matching delete
81
+ * site ~4695-4710 of `packages/jsx/src/jsx-to-ir.ts`):
82
+ *
83
+ * - When `loop.paramBindings` is non-empty (a destructured callback
84
+ * param, e.g. `.map(({ id, name }) => ...)`), each `paramBindings[i].name`
85
+ * is bound with source `'destructure'` and the raw `param` text
86
+ * (which for a destructured callback holds the ORIGINAL pattern
87
+ * source, e.g. `"{ id, name }"`, not a usable identifier) is NOT
88
+ * bound. This matches `jsx-to-ir.ts`:
89
+ * `if (paramBindings) { for (const b of paramBindings) ctx.loopParams.add(b.name) }`
90
+ * — the `else` branch (`ctx.loopParams.add(param)`) is skipped
91
+ * entirely when `paramBindings` is present.
92
+ * - Otherwise (a plain identifier param, e.g. `.map(item => ...)`),
93
+ * `param` itself is bound with source `'item'`.
94
+ * - `index` (the second callback param, e.g. `.map((item, i) => ...)`)
95
+ * is bound with source `'index'` when non-null/non-undefined.
96
+ * - Every name in `preamble.declaredNames` (a `.map()` callback's
97
+ * pre-return `const`/`let`/`function` locals, #2447) is bound with
98
+ * source `'preamble'`.
99
+ *
100
+ * NOTE on a sibling mechanism this method does NOT mirror:
101
+ * `adapters/loop-bound-names.ts`'s `collectLoopBoundNames` adds BOTH
102
+ * `node.param` AND every `paramBindings[i].name` unconditionally
103
+ * (never skipping `param` in the destructured case) — a deliberately
104
+ * coarser, over-inclusive collection used only to subtract names from
105
+ * a flat string-typing Set (safe to over-exclude there). This method
106
+ * follows the precise `jsx-to-ir.ts` `ctx.loopParams` semantics, since
107
+ * that is the mechanism actually doing scope-shadowed name RESOLUTION
108
+ * (the behavior `BindingScope` replaces), not coarse exclusion.
109
+ */
110
+ enterLoopRow(loop: LoopBindingSource): BindingScope {
111
+ const bindings = new Map<string, ScopeBinding>()
112
+ if (loop.paramBindings && loop.paramBindings.length > 0) {
113
+ for (const b of loop.paramBindings) bindings.set(b.name, { source: 'destructure' })
114
+ } else {
115
+ bindings.set(loop.param, { source: 'item' })
116
+ }
117
+ if (loop.index != null) bindings.set(loop.index, { source: 'index' })
118
+ for (const name of loop.preamble?.declaredNames ?? []) bindings.set(name, { source: 'preamble' })
119
+
120
+ const frame: ScopeFrame = { kind: 'loop-row', bindings }
121
+ return new BindingScope([frame, ...this.frames])
122
+ }
123
+
124
+ /**
125
+ * Child scope with a new `'callback'` frame binding `params` (a filter
126
+ * predicate's `x`, a sort comparator's `(a, b)`, or a nested arrow's
127
+ * parameter list) with source `'param'`. Parent is not mutated.
128
+ */
129
+ enterCallback(params: readonly string[]): BindingScope {
130
+ const bindings = new Map<string, ScopeBinding>()
131
+ for (const name of params) bindings.set(name, { source: 'param' })
132
+ const frame: ScopeFrame = { kind: 'callback', bindings }
133
+ return new BindingScope([frame, ...this.frames])
134
+ }
135
+
136
+ /** Innermost-first membership check across every frame in the stack. */
137
+ isBound(name: string): boolean {
138
+ for (const frame of this.frames) {
139
+ if (frame.bindings.has(name)) return true
140
+ }
141
+ return false
142
+ }
143
+
144
+ /**
145
+ * Resolves `name` against the frame stack innermost-first. `depth 0`
146
+ * means the innermost (most recently entered) frame; `null` when `name`
147
+ * is not bound in any frame.
148
+ */
149
+ lookup(name: string): { readonly depth: number; readonly frame: ScopeFrame; readonly binding: ScopeBinding } | null {
150
+ for (let depth = 0; depth < this.frames.length; depth++) {
151
+ const frame = this.frames[depth]
152
+ const binding = frame.bindings.get(name)
153
+ if (binding) return { depth, frame, binding }
154
+ }
155
+ return null
156
+ }
157
+
158
+ /**
159
+ * Union of every frame's bound names (every `ScopeBindingSource`), for
160
+ * migration interop with legacy `Set<string>`-shaped consumers (e.g.
161
+ * `collectLoopBoundNames`'s return type) as later stages migrate them
162
+ * onto `BindingScope`.
163
+ *
164
+ * This is the SHADOW-GUARD query — see {@link valueBoundNames} for the
165
+ * other consumer class and why the two must not be conflated.
166
+ */
167
+ boundNames(): ReadonlySet<string> {
168
+ if (this.boundNamesCache) return this.boundNamesCache
169
+ const names = new Set<string>()
170
+ for (const frame of this.frames) {
171
+ for (const name of frame.bindings.keys()) names.add(name)
172
+ }
173
+ this.boundNamesCache = names
174
+ return names
175
+ }
176
+
177
+ // Both name queries are hot (shadow guards, slot/reactivity classifiers,
178
+ // binding-env memo keying) and the scope is immutable, so each computes
179
+ // once per instance. Callers receive the cached set as ReadonlySet —
180
+ // never mutate it.
181
+ private boundNamesCache: ReadonlySet<string> | undefined
182
+ private valueBoundNamesCache: ReadonlySet<string> | undefined
183
+
184
+ /**
185
+ * Union of names bound via `'item'`/`'index'`/`'destructure'` sources
186
+ * only — the loop row's own per-item identity — excluding `'preamble'`
187
+ * (a `.map()` callback's pre-return `const`/`let`/`function` locals,
188
+ * #2447) and `'param'` (an `enterCallback` frame's filter/sort/nested-
189
+ * arrow parameters).
190
+ *
191
+ * `BindingScope` has exactly two consumer classes, and conflating them
192
+ * is the #2482 Stage 1a Commit 2 regression this split exists to
193
+ * prevent (a `ctx.scope`-wide preamble merge flipped `tag-cloud` and
194
+ * `preamble-cells` conformance fixtures before this method existed):
195
+ *
196
+ * - SHADOW GUARDS (`tryResolveTemplateSpanFromConst`,
197
+ * `tryResolveIdentifierAsTemplateLiteral`, `rewriteBarePropRefs`
198
+ * in `jsx-to-ir.ts`) ask "is this name resolved to SOMETHING in
199
+ * this scope, so an outer const/prop of the same name must not be
200
+ * substituted here at this transform position" — every source
201
+ * qualifies, including a preamble local shadowing a module const.
202
+ * These call `isBound` / `boundNames()`.
203
+ * - REACTIVITY / SLOT-ID CLASSIFIERS (`referencesLoopParam`,
204
+ * `hasReactiveAttributes`, and the `BindingEnvironment.loopParams`
205
+ * feed built from `makeBindingEnv`, all in `jsx-to-ir.ts`) ask
206
+ * "does this expression read a value that changes per row and so
207
+ * needs its own patchable slot" — a preamble local already gets
208
+ * ITS OWN dedicated slot/region-patch machinery
209
+ * (`preambleRegions` / `markPreambleAttrSlots`, #2447), so folding
210
+ * it into this classification double-counts it. Worse: widening a
211
+ * text child's `reactive` flag this way is read by
212
+ * `hasDynamicContent` to decide whether the loop ROW's own root
213
+ * element needs a slot — an unrelated, narrower decision that must
214
+ * not move just because a preamble local is now scope-visible.
215
+ * These call `valueBoundNames()`.
216
+ */
217
+ valueBoundNames(): ReadonlySet<string> {
218
+ if (this.valueBoundNamesCache) return this.valueBoundNamesCache
219
+ const names = new Set<string>()
220
+ for (const frame of this.frames) {
221
+ for (const [name, binding] of frame.bindings) {
222
+ if (binding.source === 'item' || binding.source === 'index' || binding.source === 'destructure') {
223
+ names.add(name)
224
+ }
225
+ }
226
+ }
227
+ this.valueBoundNamesCache = names
228
+ return names
229
+ }
230
+
231
+ /**
232
+ * Drop-in for `resolveStaticLoopSource`'s `opts.isNameShadowed`
233
+ * (`packages/jsx/src/static-literal.ts:112-128`).
234
+ */
235
+ asShadowPredicate(): (name: string) => boolean {
236
+ return (name: string) => this.isBound(name)
237
+ }
238
+ }
package/src/types.ts CHANGED
@@ -1882,6 +1882,14 @@ export interface ConstantInfo {
1882
1882
  parsed?: ParsedExpr
1883
1883
  /** Value with TypeScript type annotations preserved, for .tsx output */
1884
1884
  typedValue?: string
1885
+ /**
1886
+ * The declaration's explicit type annotation, verbatim from source
1887
+ * (`node.type.getText()`), when the author wrote one. Distinct from
1888
+ * `type`, which is also populated by inference from the initializer —
1889
+ * emitters must only print THIS field, never an inferred type, onto a
1890
+ * declaration (#2589).
1891
+ */
1892
+ typeAnnotation?: string
1885
1893
  valueBranches?: string[]
1886
1894
  declarationKind: 'const' | 'let'
1887
1895
  isExported?: boolean