@barefootjs/jsx 0.31.4 → 0.31.5

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 (54) hide show
  1. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  2. package/dist/adapters/loop-bound-names.d.ts +18 -0
  3. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  4. package/dist/adapters/test-adapter.d.ts.map +1 -1
  5. package/dist/augment-inherited-props.d.ts +12 -2
  6. package/dist/augment-inherited-props.d.ts.map +1 -1
  7. package/dist/compiler.d.ts.map +1 -1
  8. package/dist/debug.d.ts.map +1 -1
  9. package/dist/free-refs.d.ts +11 -2
  10. package/dist/free-refs.d.ts.map +1 -1
  11. package/dist/index.js +765 -649
  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-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  15. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/reactivity.d.ts +16 -0
  18. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/types.d.ts +16 -0
  20. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +15 -0
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/module-exports.d.ts +64 -0
  24. package/dist/module-exports.d.ts.map +1 -1
  25. package/dist/scope/binding-scope.d.ts +1 -1
  26. package/dist/types.d.ts +41 -0
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/binding-scope-ratchet.test.ts +146 -21
  30. package/src/__tests__/component-type-parameters.test.ts +70 -0
  31. package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +10 -1
  32. package/src/__tests__/free-refs.test.ts +1 -1
  33. package/src/__tests__/mutable-binding-writers.test.ts +133 -0
  34. package/src/__tests__/preamble-conditional-reactivity.test.ts +191 -0
  35. package/src/__tests__/signal-setter-updater-type.test.ts +81 -0
  36. package/src/adapters/jsx-adapter.ts +29 -3
  37. package/src/adapters/loop-bound-names.ts +18 -0
  38. package/src/adapters/test-adapter.ts +4 -1
  39. package/src/augment-inherited-props.ts +13 -1
  40. package/src/compiler.ts +18 -0
  41. package/src/debug.ts +34 -21
  42. package/src/free-refs.ts +14 -5
  43. package/src/ir-to-client-js/collect-elements.ts +17 -2
  44. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  45. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  46. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +12 -2
  47. package/src/ir-to-client-js/html-template.ts +5 -0
  48. package/src/ir-to-client-js/reactivity.ts +4 -2
  49. package/src/ir-to-client-js/types.ts +16 -0
  50. package/src/ir-to-client-js/utils.ts +15 -0
  51. package/src/jsx-to-ir.ts +117 -1
  52. package/src/module-exports.ts +137 -0
  53. package/src/scope/binding-scope.ts +1 -1
  54. package/src/types.ts +41 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Regression test for #2573 (chart TS7006 family): the SSR no-op signal
3
+ * setter stub was declared as `(..._args: any[]) => {}`. Calling it with
4
+ * an updater function — `setBars((prev) => [...prev, bar])` — puts that
5
+ * arrow in a rest-`any[]` argument position, not a function-typed one, so
6
+ * TypeScript has no contextual signature to infer the arrow's own
7
+ * parameter from and flags it implicit-any (TS7006). Runtime output
8
+ * (client JS) was always correct; this is a type-level emission defect in
9
+ * the SSR template only.
10
+ *
11
+ * The real `createSignal<T>` setter accepts `T | ((prev: T) => T)`
12
+ * (`packages/client/src/reactive.ts`'s `Signal<T>`). The stub now mirrors
13
+ * that signature whenever the signal's type is known (`SignalInfo.type`,
14
+ * the same field `needsTypeAssertion` already reads for the getter), so
15
+ * the updater arrow's parameter infers from the real element type.
16
+ */
17
+
18
+ import { describe, test, expect } from 'bun:test'
19
+ import { compileJSX } from '../compiler'
20
+ import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
21
+
22
+ describe('signal setter updater-function typing in emitted templates (#2573)', () => {
23
+ test('a typed signal gets an updater-aware setter stub', () => {
24
+ const honoAdapter = new HonoAdapter()
25
+ const source = `
26
+ 'use client'
27
+ import { createSignal } from '@barefootjs/client'
28
+
29
+ interface Bar { id: string; height: number }
30
+
31
+ export function Chart() {
32
+ const [bars, setBars] = createSignal<Bar[]>([])
33
+
34
+ // Called directly from the returned JSX (not from an onXxx handler
35
+ // prop, which SSR stubs to a no-op) so it — and the setter call
36
+ // inside it — stays reachable into the emitted template.
37
+ const addBar = (bar: Bar) => {
38
+ setBars((prev) => [...prev, bar])
39
+ return bars().length
40
+ }
41
+
42
+ return <div>{addBar({ id: 'a', height: 1 })}</div>
43
+ }
44
+ `
45
+
46
+ const result = compileJSX(source, 'Chart.tsx', { adapter: honoAdapter })
47
+ expect(result.errors).toHaveLength(0)
48
+
49
+ const template = result.files.find((f) => f.type === 'markedTemplate')!
50
+ expect(template).toBeDefined()
51
+ expect(template.content).toContain(
52
+ 'const setBars: (valueOrFn: Bar[] | ((prev: Bar[]) => Bar[])) => void = () => {}',
53
+ )
54
+ })
55
+
56
+ test('a signal with no resolvable type keeps the untyped rest-args stub', () => {
57
+ const honoAdapter = new HonoAdapter()
58
+ const source = `
59
+ 'use client'
60
+ import { createSignal } from '@barefootjs/client'
61
+
62
+ export function Widget(props: { initial: unknown }) {
63
+ const [value, setValue] = createSignal(props.initial)
64
+
65
+ const reset = () => {
66
+ setValue(props.initial)
67
+ return value()
68
+ }
69
+
70
+ return <div>{String(reset())}</div>
71
+ }
72
+ `
73
+
74
+ const result = compileJSX(source, 'Widget.tsx', { adapter: honoAdapter })
75
+ expect(result.errors).toHaveLength(0)
76
+
77
+ const template = result.files.find((f) => f.type === 'markedTemplate')!
78
+ expect(template).toBeDefined()
79
+ expect(template.content).toContain('const setValue = (..._args: any[]) => {}')
80
+ })
81
+ })
@@ -17,7 +17,7 @@ import { BF_SCOPE, BF_SLOT, BF_COND } from '@barefootjs/shared'
17
17
  import { BaseAdapter } from './interface.ts'
18
18
  import type { CallbackBodyAcceptor } from './interface.ts'
19
19
  import { ENV_SIGNAL_CLIENT_FACTORY } from './env-signal.ts'
20
- import { formatParamWithType, findReachableNames } from '../module-exports.ts'
20
+ import { formatParamWithType, closeOverWritersOfMutableBindings } from '../module-exports.ts'
21
21
  import { extractFreeIdentifiersFromText } from '../ir-to-client-js/csr-substitute.ts'
22
22
  import { identifierPattern } from '../identifier-pattern.ts'
23
23
 
@@ -113,7 +113,15 @@ export abstract class JsxAdapter extends BaseAdapter {
113
113
  ]
114
114
 
115
115
  // Find reachable declarations via transitive dependency analysis
116
- const reachable = findReachableNames(primaryRefText, declarations)
116
+ const reachable = closeOverWritersOfMutableBindings(
117
+ primaryRefText,
118
+ declarations,
119
+ new Set(
120
+ ir.metadata.localConstants
121
+ .filter(c => (c.declarationKind ?? 'const') !== 'const')
122
+ .map(c => c.name),
123
+ ),
124
+ )
117
125
 
118
126
  // Also check which signal setters are referenced
119
127
  const reachableBodies = [...reachable].map(name => {
@@ -168,7 +176,25 @@ export abstract class JsxAdapter extends BaseAdapter {
168
176
  if (signal.setter) {
169
177
  const setterUsed = identifierPattern(signal.setter).test(setterRefText)
170
178
  if (setterUsed) {
171
- lines.push(` const ${signal.setter} = (..._args: any[]) => {}`)
179
+ // The real `createSignal<T>` setter accepts `T | ((prev: T) =>
180
+ // T)` (`packages/client/src/reactive.ts`'s `Signal<T>`). The
181
+ // untyped `(..._args: any[]) => {}` stub gave every `setX((prev)
182
+ // => ...)` updater-function call site an `any[]`-typed argument
183
+ // position — not a function-typed one — so the arrow's own
184
+ // `prev` parameter had no contextual type to infer from and
185
+ // `tsc` flagged it implicit-any (TS7006, #2573 chart family).
186
+ // Mirror the real setter's signature whenever the signal's type
187
+ // is known, so updater-function callers keep their inference;
188
+ // fall back to the untyped stub only when it isn't (matches
189
+ // `needsTypeAssertion`'s `'unknown'` guard just above).
190
+ const setterType = preserveTypes && signal.type.kind !== 'unknown'
191
+ ? `(valueOrFn: ${signal.type.raw} | ((prev: ${signal.type.raw}) => ${signal.type.raw})) => void`
192
+ : null
193
+ lines.push(
194
+ setterType
195
+ ? ` const ${signal.setter}: ${setterType} = () => {}`
196
+ : ` const ${signal.setter} = (..._args: any[]) => {}`,
197
+ )
172
198
  }
173
199
  }
174
200
  }
@@ -16,6 +16,24 @@
16
16
  * degrades to the ALREADY-accepted residual (`+` falls back to numeric,
17
17
  * same as before #2212 for an unresolvable operand) rather than ever
18
18
  * producing silently-wrong output.
19
+ *
20
+ * #2482 (BindingScope) does NOT replace this: it answers "is NAME bound
21
+ * AT THIS POSITION", a live, position-accurate query built by walking
22
+ * INTO scopes during a render-time (or render-shaped) tree walk. This
23
+ * function answers a deliberately different, coarser question —
24
+ * "is NAME EVER a loop-bound name ANYWHERE in the component" — a single
25
+ * whole-component prepass run once at `generate()` entry, before any
26
+ * loop scope exists to thread. The two are complementary, not
27
+ * duplicative: swapping this for `BindingScope` would require re-deriving
28
+ * a position-accurate answer at every string-typed-operand call site,
29
+ * which is exactly the coarse-but-safe trade-off this function exists to
30
+ * avoid. Stays outside the `binding-scope-ratchet.test.ts` ledger for an
31
+ * incidental reason too — a lowercase-`l`-led spelling of this file's own
32
+ * export once lived as a per-adapter ref-counted-map field name (fully
33
+ * migrated away in an earlier #2482 stage) that the ledger's scan tracked;
34
+ * this function's own `collectLoopBoundNames` name is capitalized
35
+ * differently (a capital `L`) and so was never inside that scan's reach in
36
+ * the first place.
19
37
  */
20
38
 
21
39
  import type { ComponentIR, IRNode } from '../types.ts'
@@ -164,7 +164,10 @@ export class TestAdapter extends JsxAdapter {
164
164
  // Module-export keyword belongs to the adapter: it knows the target language
165
165
  // and whether the source declared the component as exported.
166
166
  const exportPrefix = ir.metadata.isExported === false ? '' : 'export '
167
- lines.push(`${exportPrefix}function ${name}(${fullPropsDestructure}${typeAnnotation}${noArgDefault}) {`)
167
+ // Carry the source component's own generic type parameters, if any
168
+ // (mirrors `HonoAdapter` — see `IRMetadata.typeParameters`'s docstring).
169
+ const typeParameters = ir.metadata.typeParameters ?? ''
170
+ lines.push(`${exportPrefix}function ${name}${typeParameters}(${fullPropsDestructure}${typeAnnotation}${noArgDefault}) {`)
168
171
 
169
172
  // Generate scope ID
170
173
  if (hasClientInteractivity) {
@@ -464,14 +464,26 @@ export function collectModuleStringConsts(
464
464
  * compile-time lookup (the icon registry's `strokePaths['chevron-down']`,
465
465
  * pagination's `variantClasses.ghost`; #1896 / #1897). Returns the
466
466
  * looked-up scalar, or `null` for any other shape so callers fall back
467
- * to their generic lowering. Shared by all three SSR template adapters;
467
+ * to their generic lowering. Shared by all seven template-string adapters;
468
468
  * the prop-KEYED variant of the pattern lives in `parseRecordIndexAccess`.
469
+ *
470
+ * `isShadowed` is REQUIRED (#2482 Stage 2) rather than left to caller
471
+ * discipline: an enclosing loop callback's own param/index/destructure/
472
+ * preamble binding of the same name as `objectName` (`.map((cfg) =>
473
+ * <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`) must resolve to
474
+ * the ROW value, not the outer const's member — every call site used to
475
+ * check this itself, against its own ad-hoc per-adapter shadow-name
476
+ * bookkeeping, with no static guarantee a new caller wouldn't forget it.
477
+ * Pass `scope.isBound` (or `scope.asShadowPredicate()`) from the caller's
478
+ * threaded `BindingScope`.
469
479
  */
470
480
  export function lookupStaticRecordLiteral(
471
481
  objectName: string,
472
482
  key: string,
473
483
  constants: IRMetadata['localConstants'] | undefined,
484
+ isShadowed: (name: string) => boolean,
474
485
  ): { kind: 'string' | 'number'; text: string } | null {
486
+ if (isShadowed(objectName)) return null
475
487
  const constInfo = (constants ?? []).find(c => c.name === objectName && c.isModule)
476
488
  if (constInfo?.value === undefined) return null
477
489
  const sf = ts.createSourceFile(
package/src/compiler.ts CHANGED
@@ -624,6 +624,23 @@ function compileMultipleComponents(
624
624
  // Helpers
625
625
  // =============================================================================
626
626
 
627
+ /**
628
+ * Verbatim text of the component function's own generic type parameter
629
+ * list (`<NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase
630
+ * = EdgeBase>`), or `null` when the component isn't generic. Source text
631
+ * per parameter (`node.getText(sourceFile)`), not a re-printed AST, so
632
+ * constraints/defaults/comments round-trip exactly like `ConstantInfo.
633
+ * typeAnnotation` does for `let` (#2589) — see `IRMetadata.typeParameters`.
634
+ */
635
+ function componentTypeParametersText(
636
+ componentNode: ts.FunctionDeclaration | ts.ArrowFunction | null,
637
+ sourceFile: ts.SourceFile,
638
+ ): string | null {
639
+ const typeParameters = componentNode?.typeParameters
640
+ if (!typeParameters || typeParameters.length === 0) return null
641
+ return `<${typeParameters.map(p => p.getText(sourceFile)).join(', ')}>`
642
+ }
643
+
627
644
  export function buildMetadata(
628
645
  ctx: ReturnType<typeof analyzeComponent>,
629
646
  ): IRMetadata {
@@ -634,6 +651,7 @@ export function buildMetadata(
634
651
  isClientComponent: ctx.hasUseClientDirective,
635
652
  typeDefinitions: ctx.typeDefinitions,
636
653
  propsType: ctx.propsType,
654
+ typeParameters: componentTypeParametersText(ctx.componentNode, ctx.sourceFile),
637
655
  propsParams: ctx.propsParams,
638
656
  propsObjectName: ctx.propsObjectName,
639
657
  restPropsName: ctx.restPropsName,
package/src/debug.ts CHANGED
@@ -30,6 +30,7 @@ import type { WrapReason } from './ir-to-client-js/reactivity.ts'
30
30
  import { decideWrapFromAstFlags } from './ir-to-client-js/reactivity.ts'
31
31
  import { tokenContainsIdent } from './ir-to-client-js/utils.ts'
32
32
  import { identifierCallPattern } from './identifier-pattern.ts'
33
+ import { BindingScope } from './scope/binding-scope.ts'
33
34
 
34
35
  // =============================================================================
35
36
  // Types
@@ -360,7 +361,7 @@ export function buildGraphFromIR(ir: ComponentIR): ComponentGraph {
360
361
 
361
362
  // Collect DOM bindings from IR tree
362
363
  const domBindings: DomBinding[] = []
363
- collectDomBindings(ir.root, domBindings, signalGetters, memoNames, undefined, new Set(), exprReadsProp)
364
+ collectDomBindings(ir.root, domBindings, signalGetters, memoNames, undefined, BindingScope.EMPTY, exprReadsProp)
364
365
 
365
366
  // Build consumer lists for signals
366
367
  const signalConsumers = new Map<string, string[]>()
@@ -1676,30 +1677,41 @@ function collectDomBindings(
1676
1677
  signalGetters: Set<string>,
1677
1678
  memoNames: Set<string>,
1678
1679
  parentTag?: string,
1679
- // Loop-param names in scope (#1690, #1795 Phase 2). Inside a `map(it => …)`
1680
+ // Loop-param names in scope (#1690, #1795 Phase 2; threaded via the shared
1681
+ // `BindingScope` service since #2482 Stage 4). Inside a `map(it => …)`
1680
1682
  // body the emitter rewrites every `it.x` read into a reactive accessor and
1681
1683
  // wraps the binding in `createEffect`, yet `it` is neither a signal nor a
1682
1684
  // memo — so without this context loop-child text / attribute bindings are
1683
1685
  // invisible to the graph. When a binding expression references one of these
1684
1686
  // names it is treated as reactive (matching the emitter's gate), giving the
1685
1687
  // profiler a `domBinding` (slotId + loc) to resolve `<Comp>#binding:<slotId>`.
1686
- loopParams: Set<string> = new Set(),
1688
+ // This is the REACTIVITY / SLOT-ID CLASSIFIER consumer class (see
1689
+ // `BindingScope.valueBoundNames`'s docstring) — reads `valueBoundNames()`,
1690
+ // not `boundNames()`.
1691
+ scope: BindingScope = BindingScope.EMPTY,
1687
1692
  // Predicate: does an attribute expression read a component prop? Mirrors the
1688
1693
  // emitter's `needsEffectWrapper` prop detection so a prop-driven attribute
1689
1694
  // (wrapped in `createEffect` at codegen, hence emitting `#binding:<slot>`) is
1690
1695
  // tracked here too — otherwise its profiler id resolves to `(unresolved)`.
1691
1696
  readsProp: (expr: string, freeIds?: ReadonlySet<string>) => boolean = () => false,
1692
1697
  ): void {
1698
+ const boundNames = scope.valueBoundNames()
1693
1699
  // Does a loop-child binding read a loop param (or index)? Use the analyzer's
1694
1700
  // lexer-resolved metadata, NOT a raw-string regex — so a param name that only
1695
1701
  // appears inside a string literal (index `i` vs `'i'`) is not mistaken for a
1696
1702
  // reactive read. Text expressions carry `origin.freeRefs` (a `render-item`
1697
1703
  // kind == map-callback param); attributes carry `freeIdentifiers` (bare
1698
1704
  // identifier set). This matches the emitter's actual loop-param gate.
1705
+ // Set-intersection test without spreading into an array — these run per
1706
+ // node in the graph walk, so avoid the per-call allocation.
1707
+ const setSomeIn = (names: ReadonlySet<string>, other: ReadonlySet<string>): boolean => {
1708
+ for (const n of names) if (other.has(n)) return true
1709
+ return false
1710
+ }
1699
1711
  const exprReadsLoopParam = (n: IRExpression): boolean =>
1700
- loopParams.size > 0 && (n.origin?.freeRefs?.some(r => loopParams.has(r.name)) ?? false)
1712
+ boundNames.size > 0 && (n.origin?.freeRefs?.some(r => boundNames.has(r.name)) ?? false)
1701
1713
  const attrReadsLoopParam = (free: ReadonlySet<string> | undefined): boolean =>
1702
- loopParams.size > 0 && free !== undefined && [...loopParams].some(p => free.has(p))
1714
+ boundNames.size > 0 && free !== undefined && setSomeIn(boundNames, free)
1703
1715
  switch (node.type) {
1704
1716
  case 'element': {
1705
1717
  // Dynamic attribute bindings (style, class, aria-*, data-*, etc.)
@@ -1713,7 +1725,7 @@ function collectDomBindings(
1713
1725
  // `key` is consumed by the loop's keyFn, never emitted as an attribute
1714
1726
  // effect — skip it inside loops so a `key={it.id}` read isn't mistaken
1715
1727
  // for a reactive binding (matches `collectLoopChildBindings`).
1716
- if (attr.name === 'key' && loopParams.size > 0) continue
1728
+ if (attr.name === 'key' && boundNames.size > 0) continue
1717
1729
  const expr = attrValueToString(attr.value)
1718
1730
  if (!expr) continue
1719
1731
  const deps = extractReactiveDeps(expr, signalGetters, memoNames)
@@ -1756,7 +1768,7 @@ function collectDomBindings(
1756
1768
  }
1757
1769
  // Recurse — pass element tag as parent context for text bindings
1758
1770
  for (const child of node.children) {
1759
- collectDomBindings(child, bindings, signalGetters, memoNames, node.tag, loopParams, readsProp)
1771
+ collectDomBindings(child, bindings, signalGetters, memoNames, node.tag, scope, readsProp)
1760
1772
  }
1761
1773
  break
1762
1774
  }
@@ -1794,7 +1806,7 @@ function collectDomBindings(
1794
1806
  // (the emitter wraps its `insert()` in a per-item effect) even though the
1795
1807
  // param is neither signal nor memo. Use the resolved `origin.freeRefs`.
1796
1808
  const loopReactive =
1797
- loopParams.size > 0 && (node.origin?.freeRefs?.some(r => loopParams.has(r.name)) ?? false)
1809
+ boundNames.size > 0 && (node.origin?.freeRefs?.some(r => boundNames.has(r.name)) ?? false)
1798
1810
  if ((decision.wrap || loopReactive) && node.slotId) {
1799
1811
  const deps = extractReactiveDeps(node.condition, signalGetters, memoNames)
1800
1812
  bindings.push({
@@ -1813,8 +1825,8 @@ function collectDomBindings(
1813
1825
  jsxPreview: `{${truncateExpr(node.condition)} ? ... : ...}`,
1814
1826
  })
1815
1827
  }
1816
- collectDomBindings(node.whenTrue, bindings, signalGetters, memoNames, parentTag, loopParams, readsProp)
1817
- collectDomBindings(node.whenFalse, bindings, signalGetters, memoNames, parentTag, loopParams, readsProp)
1828
+ collectDomBindings(node.whenTrue, bindings, signalGetters, memoNames, parentTag, scope, readsProp)
1829
+ collectDomBindings(node.whenFalse, bindings, signalGetters, memoNames, parentTag, scope, readsProp)
1818
1830
  break
1819
1831
  }
1820
1832
  case 'loop': {
@@ -1828,9 +1840,9 @@ function collectDomBindings(
1828
1840
  // An inner loop whose array reads an outer loop param (`r.tags.map(...)`)
1829
1841
  // is reactive per item — use the resolved `arrayFreeIdentifiers`.
1830
1842
  const loopReactive =
1831
- loopParams.size > 0 &&
1843
+ boundNames.size > 0 &&
1832
1844
  node.arrayFreeIdentifiers !== undefined &&
1833
- [...loopParams].some(p => node.arrayFreeIdentifiers!.has(p))
1845
+ setSomeIn(boundNames, node.arrayFreeIdentifiers)
1834
1846
  const isReactive = deps.length > 0 || node.callsReactiveGetters === true || loopReactive
1835
1847
  const isFallback = !isReactive && node.hasFunctionCalls === true
1836
1848
  if (isReactive || isFallback) {
@@ -1859,12 +1871,13 @@ function collectDomBindings(
1859
1871
  })
1860
1872
  }
1861
1873
  }
1862
- // Loop-param names enter scope for the children (#1690, #1795 Phase 2).
1863
- const childLoopParams = new Set(loopParams)
1864
- for (const p of extractLoopParamNames(node.param, node)) childLoopParams.add(p)
1865
- if (node.index) childLoopParams.add(node.index)
1874
+ // Loop-param names enter scope for the children (#1690, #1795 Phase 2),
1875
+ // via the same `BindingScope.enterLoopRow` the emitter uses — `IRLoop`
1876
+ // already satisfies `LoopBindingSource` structurally (`param`/`index`/
1877
+ // `paramBindings`/`preamble`), so no bespoke Set bookkeeping is needed.
1878
+ const childScope = scope.enterLoopRow(node)
1866
1879
  for (const child of node.children) {
1867
- collectDomBindings(child, bindings, signalGetters, memoNames, parentTag, childLoopParams, readsProp)
1880
+ collectDomBindings(child, bindings, signalGetters, memoNames, parentTag, childScope, readsProp)
1868
1881
  }
1869
1882
  break
1870
1883
  }
@@ -1903,21 +1916,21 @@ function collectDomBindings(
1903
1916
  }
1904
1917
  }
1905
1918
  for (const child of node.children) {
1906
- collectDomBindings(child, bindings, signalGetters, memoNames, parentTag, loopParams, readsProp)
1919
+ collectDomBindings(child, bindings, signalGetters, memoNames, parentTag, scope, readsProp)
1907
1920
  }
1908
1921
  break
1909
1922
  }
1910
1923
  case 'fragment':
1911
1924
  case 'provider': {
1912
1925
  for (const child of node.children) {
1913
- collectDomBindings(child, bindings, signalGetters, memoNames, parentTag, loopParams, readsProp)
1926
+ collectDomBindings(child, bindings, signalGetters, memoNames, parentTag, scope, readsProp)
1914
1927
  }
1915
1928
  break
1916
1929
  }
1917
1930
  case 'if-statement': {
1918
- collectDomBindings(node.consequent, bindings, signalGetters, memoNames, parentTag, loopParams, readsProp)
1931
+ collectDomBindings(node.consequent, bindings, signalGetters, memoNames, parentTag, scope, readsProp)
1919
1932
  if (node.alternate) {
1920
- collectDomBindings(node.alternate, bindings, signalGetters, memoNames, parentTag, loopParams, readsProp)
1933
+ collectDomBindings(node.alternate, bindings, signalGetters, memoNames, parentTag, scope, readsProp)
1921
1934
  }
1922
1935
  break
1923
1936
  }
package/src/free-refs.ts CHANGED
@@ -41,14 +41,23 @@ export interface BindingEnvironment {
41
41
  localFunctions: readonly FunctionInfo[]
42
42
  imports: readonly ImportInfo[]
43
43
  ambientGlobals: ReadonlySet<string>
44
- /** Active `.map()` callback parameter names — present inside loop bodies. */
45
- loopParams?: ReadonlySet<string>
44
+ /**
45
+ * Active `.map()` callback VALUE-bound names (item/index/destructure —
46
+ * excludes preamble locals and callback params) — present inside loop
47
+ * bodies. Named after, and fed from, `BindingScope.valueBoundNames()`
48
+ * (#2482 Stage 4 rename from `loopParams`, which predated the shared
49
+ * `BindingScope` service and named the mechanism, not the query). This
50
+ * is the REACTIVITY / SLOT-ID CLASSIFIER consumer class — see that
51
+ * method's docstring for why it must stay `valueBoundNames`, not
52
+ * `boundNames`.
53
+ */
54
+ loopValueBoundNames?: ReadonlySet<string>
46
55
  checker: ts.TypeChecker | null
47
56
  }
48
57
 
49
58
  /**
50
59
  * Per-environment cache for the binding map. `BindingEnvironment` identity
51
- * is stable per (analyzer, loopParams snapshot) — `jsx-to-ir.ts` memoizes
60
+ * is stable per (analyzer, loopValueBoundNames snapshot) — `jsx-to-ir.ts` memoizes
52
61
  * `makeBindingEnv` so the same object is reused across every
53
62
  * `resolveFreeRefs` call within a loop scope. With N expressions per
54
63
  * component and M bindings per env, this drops binding-table construction
@@ -114,8 +123,8 @@ function buildBindingMap(env: BindingEnvironment): Map<string, BindingKind> {
114
123
  map.set(m.name, 'memo-getter')
115
124
  }
116
125
  // Highest precedence — innermost scope.
117
- if (env.loopParams) {
118
- for (const name of env.loopParams) map.set(name, 'render-item')
126
+ if (env.loopValueBoundNames) {
127
+ for (const name of env.loopValueBoundNames) map.set(name, 'render-item')
119
128
  }
120
129
 
121
130
  _bindingMapCache.set(env, map)
@@ -5,10 +5,11 @@
5
5
  import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp, pickAttrMetaFromIR } from '../types.ts'
6
6
  import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types.ts'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
- import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings, buildLoopRowScope } from './reactivity.ts'
8
+ import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings, buildLoopRowScope, anyNameIn } from './reactivity.ts'
9
9
  import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, renderFlatMapClientBody, renderFlatMapProjectionClientBody, flatMapCallbackHasKeyedLeaf, type SkeletonSlotPaths } from './html-template.ts'
10
10
  import { templateRootIsSvg } from './control-flow/stringify/template-parse.ts'
11
11
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
12
+ import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
12
13
  import { walkIR, stopAt } from './walker.ts'
13
14
  import { buildLoopChainExpr } from '../loop-chain.ts'
14
15
  import { identifierPattern } from '../identifier-pattern.ts'
@@ -1428,9 +1429,22 @@ export function collectLoopChildConditionals(
1428
1429
  // paying for constant expansion — matches the legacy short-circuit.
1429
1430
  if (!n.reactive && !refsLoopParamInSource) return
1430
1431
  const expanded = expandConstantForReactivity(n.condition, ctx, sourceFreeIds, scope)
1432
+ // A `.map()` callback preamble local (#2596, twin of
1433
+ // `collectLoopChildReactiveAttrs`'s `readsPreamble` #2447). Phase 1
1434
+ // already proved this condition reactive when it's set (`n.reactive`
1435
+ // came from `markPreambleConditionalReactivity`, gated on the local's
1436
+ // OWN initializer reading a signal) — `classifyReactivity` below
1437
+ // cannot independently confirm that: `expandConstantForReactivity`
1438
+ // leaves a preamble-bound identifier like `label` unexpanded on
1439
+ // purpose (the #2482 Stage 1b shadow guard, `scope.isBound`), so the
1440
+ // raw token never string-matches a signal/memo/prop pattern.
1441
+ const readsPreamble =
1442
+ preambleNames !== undefined &&
1443
+ preambleNames.size > 0 &&
1444
+ anyNameIn(expanded.freeIds ?? extractFreeIdentifiersFromText(expanded.expr), preambleNames)
1431
1445
  // Loop-param conditionals are reactive via per-item signal accessors;
1432
1446
  // classifyReactivity sees both paths (signal/memo/prop + loop-param).
1433
- if (classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') return
1447
+ if (!readsPreamble && classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') return
1434
1448
 
1435
1449
  const loopParamsForCond = loopParam
1436
1450
  ? [{ param: loopParam, bindings: loopParamBindings }]
@@ -1449,6 +1463,7 @@ export function collectLoopChildConditionals(
1449
1463
  whenTrue: summarizeLoopChildBranch(n.whenTrue, ctx, siblingOffsets, loopParam, loopParamBindings, preambleNames, loopIndex),
1450
1464
  whenFalse: summarizeLoopChildBranch(n.whenFalse, ctx, siblingOffsets, loopParam, loopParamBindings, preambleNames, loopIndex),
1451
1465
  ...(expanded.freeIds !== undefined && { conditionFreeIdentifiers: expanded.freeIds }),
1466
+ ...(readsPreamble && { readsPreamble: true }),
1452
1467
  })
1453
1468
  },
1454
1469
  })
@@ -112,6 +112,7 @@ export function buildReactiveEffectsPlan(
112
112
  whenFalseTemplateHtml: addCondAttrToTemplate(wrap(cond.whenFalseHtml), cond.slotId),
113
113
  whenTrueArm: buildOuterArm(cond.whenTrue, wrap, loopParam, loopParamBindings, profileComponentName),
114
114
  whenFalseArm: buildOuterArm(cond.whenFalse, wrap, loopParam, loopParamBindings, profileComponentName),
115
+ ...(cond.readsPreamble && { readsPreamble: true }),
115
116
  })
116
117
  }
117
118
  }
@@ -62,6 +62,13 @@ export interface NestedConditionalPlan {
62
62
  whenFalseTemplateHtml: string
63
63
  whenTrueArm: LoopChildArmPlan
64
64
  whenFalseArm: LoopChildArmPlan
65
+ /**
66
+ * `wrappedCondition` reads a `.map()` callback preamble local (#2596,
67
+ * twin of `ReactiveAttrEffect.readsPreamble`) — the stringifier must run
68
+ * the row's preamble ahead of evaluating the condition getter passed to
69
+ * `insert()`; the local isn't otherwise in scope there.
70
+ */
71
+ readsPreamble?: boolean
65
72
  }
66
73
 
67
74
  export interface ReactiveEffectsPlan {
@@ -140,7 +140,7 @@ export function stringifyReactiveEffects(
140
140
  // from the attrs/texts/regions merge above (its own per-branch disposable
141
141
  // effects), so it's untouched by row-granularity consolidation.
142
142
  for (const cond of conditionals) {
143
- emitOuterConditional(lines, indent, elVar, cond, pc)
143
+ emitOuterConditional(lines, indent, elVar, cond, pc, mapPreambleWrapped)
144
144
  }
145
145
  }
146
146
 
@@ -342,12 +342,22 @@ function emitOuterConditional(
342
342
  elVar: string,
343
343
  cond: NestedConditionalPlan,
344
344
  pc: string | undefined,
345
+ mapPreambleWrapped: string | undefined,
345
346
  ): void {
346
347
  const armIndent = `${indent} `
347
348
 
348
349
  // Body-form arrows so live `Node` returns from Child-position
349
350
  // interpolations route through `__bfSlot` and survive the splice (#1213).
350
- lines.push(`${indent}insert(${elVar}, '${cond.slotId}', () => ${cond.wrappedCondition}, {`)
351
+ // A condition reading a preamble local (#2596) needs the preamble re-run
352
+ // INSIDE the getter — `insert()` re-invokes this closure on every
353
+ // dependency change to decide the branch, and the local isn't otherwise in
354
+ // scope here (it's a plain per-row `const`, not a signal `insert()` can see
355
+ // through on its own). Same treatment as `readsPreamble` attrs
356
+ // (`emitAttrUpdate`'s callers) get ahead of their own write.
357
+ const conditionGetter = cond.readsPreamble && mapPreambleWrapped
358
+ ? `() => { ${mapPreambleWrapped}; return (${cond.wrappedCondition}) }`
359
+ : `() => ${cond.wrappedCondition}`
360
+ lines.push(`${indent}insert(${elVar}, '${cond.slotId}', ${conditionGetter}, {`)
351
361
  lines.push(`${indent} template: () => { const __slots = []; return { html: \`${cond.whenTrueTemplateHtml}\`, slots: __slots } },`)
352
362
  lines.push(`${indent} bindEvents: (__branchScope, { isFirstRun: __bfFirstRun = false } = {}) => {`)
353
363
  stringifyLoopChildArm(lines, cond.whenTrueArm, armIndent, pc)
@@ -729,6 +729,11 @@ function escapeLeafTextExpressions(ir: IRNode): IRNode {
729
729
  }
730
730
  }
731
731
 
732
+ // `loopParams` here is the accessor-rewrite spec list `wrapExprWithLoopParams`
733
+ // consumes, not a `BindingScope`-trackable name set — see that function's
734
+ // docstring (`ir-to-client-js/utils.ts`) for why this stays outside #2482's
735
+ // migration (it's the client-JS-emitter twin of the Go adapter's
736
+ // `loopBindingStack`).
732
737
  export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, inHoistedChildren = false): string {
733
738
  const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, inHoistedChildren)
734
739
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
@@ -671,8 +671,10 @@ export function collectLoopChildReactiveTexts(
671
671
  * `loopIndex` (Copilot review on #2595): see `collectLoopChildReactiveTexts`.
672
672
  */
673
673
  /** Does any name in `names` appear in `set`? Iterates rather than
674
- * spreading — this runs per attribute (Copilot review). */
675
- function anyNameIn(names: Iterable<string>, set: ReadonlySet<string>): boolean {
674
+ * spreading — this runs per attribute (Copilot review). Exported for
675
+ * `collectLoopChildConditionals`'s `readsPreamble` check (#2596), the
676
+ * condition-position twin of the attr check just below this uses it for. */
677
+ export function anyNameIn(names: Iterable<string>, set: ReadonlySet<string>): boolean {
676
678
  for (const n of names) if (set.has(n)) return true
677
679
  return false
678
680
  }
@@ -578,6 +578,22 @@ export interface LoopChildConditional {
578
578
  * constants' own `freeIdentifiers`.
579
579
  */
580
580
  conditionFreeIdentifiers?: ReadonlySet<string>
581
+ /**
582
+ * `condition` references a `.map()` callback preamble-declared name
583
+ * (#2596, twin of `LoopChildReactiveAttr.readsPreamble` #2447) — the
584
+ * emitter must run the preamble ahead of evaluating the condition getter
585
+ * passed to `insert()`, since the local is not otherwise in scope there.
586
+ * Set whenever the condition mentions ANY preamble-declared name
587
+ * (`preambleNamesOf`), regardless of why the conditional was classified
588
+ * reactive — the scope obligation is the same either way. Whether the
589
+ * conditional is wired reactive AT ALL is the separate, stricter Phase-1
590
+ * question (`IRLoop.preamble.reactiveNames` via
591
+ * `markPreambleConditionalReactivity`, plus the ordinary signal/prop
592
+ * classifiers); `classifyReactivity` can't answer it from the token
593
+ * alone because `expandConstantForReactivity`'s shadow guard leaves a
594
+ * preamble-bound identifier unexpanded on purpose (#2482 Stage 1b).
595
+ */
596
+ readsPreamble?: boolean
581
597
  }
582
598
 
583
599
  export interface TopLevelLoop extends LoopCore {
@@ -698,6 +698,21 @@ export interface LoopParamSpec {
698
698
  * avoiding post-hoc regex replacement on full template strings.
699
699
  *
700
700
  * Accepts either a bare param name or a spec carrying destructure bindings.
701
+ *
702
+ * #2482: this `loopParams` parameter (and the same-named param on
703
+ * `irToHtmlTemplate` / `irToPlaceholderTemplate` in `html-template.ts`,
704
+ * threaded through `collect-elements.ts` / `build-event-delegation.ts`) is
705
+ * NOT one of the ratchet's tracked ad-hoc scope devices, even though the
706
+ * ledger's textual pattern happens to match its spelling. `BindingScope`
707
+ * answers EXISTENCE/kind/depth queries about bound names; this ordered
708
+ * `ReadonlyArray<string | LoopParamSpec>` instead carries the ACCESSOR-
709
+ * REWRITE payload for outermost-to-innermost text substitution
710
+ * (`item` → `__bfItem().path`) — a rendering/codegen concern `BindingScope`
711
+ * has no field for by design, the client-JS-emitter twin of the Go
712
+ * adapter's `loopBindingStack` (see that field's docstring on
713
+ * `GoTemplateAdapter`). Order matters here (each nesting level's wrap
714
+ * composes over the previous), which is exactly what a scope EXISTENCE
715
+ * stack does not model.
701
716
  */
702
717
  export function wrapExprWithLoopParams(expr: string, loopParams?: ReadonlyArray<string | LoopParamSpec>): string {
703
718
  if (!loopParams) return expr