@barefootjs/jsx 0.18.4 → 0.18.7

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 (109) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts +77 -0
  2. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts +22 -0
  4. package/dist/adapters/loop-bound-names.d.ts.map +1 -0
  5. package/dist/adapters/parsed-expr-emitter.d.ts +2 -2
  6. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  7. package/dist/analyzer.d.ts.map +1 -1
  8. package/dist/expression-parser.d.ts +2 -1
  9. package/dist/expression-parser.d.ts.map +1 -1
  10. package/dist/index.d.ts +14 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1108 -307
  13. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +10 -0
  19. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  23. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
  25. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
  29. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
  31. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
  33. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
  34. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  35. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/csr-substitute.d.ts +24 -0
  37. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/html-template.d.ts +42 -0
  39. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/imports.d.ts +2 -2
  41. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
  43. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  44. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  45. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
  46. package/dist/ir-to-client-js/types.d.ts +30 -0
  47. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  48. package/dist/ir-to-client-js/utils.d.ts +25 -0
  49. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  50. package/dist/jsx-to-ir.d.ts.map +1 -1
  51. package/dist/signal-init-eval.d.ts +82 -0
  52. package/dist/signal-init-eval.d.ts.map +1 -0
  53. package/dist/static-literal.d.ts +45 -0
  54. package/dist/static-literal.d.ts.map +1 -0
  55. package/dist/types.d.ts +66 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +2 -2
  58. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
  59. package/src/__tests__/create-selector.test.ts +110 -0
  60. package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
  61. package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
  62. package/src/__tests__/event-delegation-index-param.test.ts +130 -0
  63. package/src/__tests__/expression-parser.test.ts +38 -0
  64. package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
  65. package/src/__tests__/ir-walker.test.ts +1 -0
  66. package/src/__tests__/loop-bound-names.test.ts +102 -0
  67. package/src/__tests__/loop-plan-classification.test.ts +2 -0
  68. package/src/__tests__/map-function-reference.test.ts +303 -0
  69. package/src/__tests__/materialize-getter-calls.test.ts +1 -0
  70. package/src/__tests__/nested-loop-index-param.test.ts +285 -0
  71. package/src/__tests__/signal-init-eval.test.ts +138 -0
  72. package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
  73. package/src/__tests__/static-literal.test.ts +126 -0
  74. package/src/__tests__/string-concat-identifier.test.ts +55 -0
  75. package/src/adapters/dangerous-inner-html.ts +186 -0
  76. package/src/adapters/loop-bound-names.ts +80 -0
  77. package/src/adapters/parsed-expr-emitter.ts +19 -1
  78. package/src/analyzer.ts +6 -0
  79. package/src/expression-parser.ts +59 -25
  80. package/src/index.ts +19 -3
  81. package/src/ir-to-client-js/collect-elements.ts +18 -3
  82. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +6 -0
  83. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
  84. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
  85. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  86. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +10 -0
  87. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
  88. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  89. package/src/ir-to-client-js/control-flow/shared.ts +103 -6
  90. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +31 -7
  91. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
  92. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
  93. package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
  94. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
  95. package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
  96. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
  97. package/src/ir-to-client-js/csr-substitute.ts +54 -1
  98. package/src/ir-to-client-js/html-template.ts +292 -13
  99. package/src/ir-to-client-js/imports.ts +1 -0
  100. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
  101. package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
  102. package/src/ir-to-client-js/reactivity.ts +6 -0
  103. package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
  104. package/src/ir-to-client-js/types.ts +31 -0
  105. package/src/ir-to-client-js/utils.ts +34 -1
  106. package/src/jsx-to-ir.ts +322 -48
  107. package/src/signal-init-eval.ts +165 -0
  108. package/src/static-literal.ts +128 -0
  109. package/src/types.ts +68 -0
@@ -70,6 +70,43 @@ describe('expression-parser', () => {
70
70
  expect(result.kind).toBe('member')
71
71
  if (result.kind === 'member') {
72
72
  expect(result.property).toBe('name')
73
+ expect(result.optional).toBe(false)
74
+ }
75
+ })
76
+
77
+ // #2168 optional-chaining-prop: `optional` must reflect the source `?.`
78
+ // token exactly — set on the one written hop, `false` everywhere else
79
+ // (a plain `.`/`[]` access, including a literal-index computed access).
80
+ test('parses optional chaining (?.) into `member.optional`', () => {
81
+ const dotResult = parseExpression('user?.name')
82
+ expect(dotResult.kind).toBe('member')
83
+ if (dotResult.kind === 'member') {
84
+ expect(dotResult.property).toBe('name')
85
+ expect(dotResult.computed).toBe(false)
86
+ expect(dotResult.optional).toBe(true)
87
+ }
88
+
89
+ const stringKeyResult = parseExpression("obj?.['key']")
90
+ expect(stringKeyResult.kind).toBe('member')
91
+ if (stringKeyResult.kind === 'member') {
92
+ expect(stringKeyResult.property).toBe('key')
93
+ expect(stringKeyResult.computed).toBe(true)
94
+ expect(stringKeyResult.optional).toBe(true)
95
+ }
96
+
97
+ const numericIndexResult = parseExpression('arr?.[0]')
98
+ expect(numericIndexResult.kind).toBe('member')
99
+ if (numericIndexResult.kind === 'member') {
100
+ expect(numericIndexResult.property).toBe('0')
101
+ expect(numericIndexResult.computed).toBe(true)
102
+ expect(numericIndexResult.optional).toBe(true)
103
+ }
104
+
105
+ // Non-optional literal-index access stays `optional: false`.
106
+ const plainIndexResult = parseExpression('arr[0]')
107
+ expect(plainIndexResult.kind).toBe('member')
108
+ if (plainIndexResult.kind === 'member') {
109
+ expect(plainIndexResult.optional).toBe(false)
73
110
  }
74
111
  })
75
112
 
@@ -1230,6 +1267,7 @@ describe('expression-parser', () => {
1230
1267
  object: { kind: 'identifier', name: 'x' },
1231
1268
  property: 'done',
1232
1269
  computed: false,
1270
+ optional: false,
1233
1271
  })
1234
1272
  })
1235
1273
 
@@ -0,0 +1,245 @@
1
+ /**
2
+ * SVG-rooted loop item templates cloned via `template.innerHTML` (#2219).
3
+ *
4
+ * `template.innerHTML` parses in the HTML namespace, so an inner reactive
5
+ * loop whose item root is an SVG element (`<line>`, `<circle>`, ...) cloned
6
+ * as an `HTMLUnknownElement` — present in the DOM but never drawn by the
7
+ * SVG renderer, with no error. The top-level loop path has wrapped these
8
+ * templates in a synthetic `<svg>` since #135/#1088 (`templateRootIsSvg` /
9
+ * `emitTemplateCloneInline` in `stringify/template-parse.ts`) and the
10
+ * branch-arm path since its plan-ification, but the inner-loop reactive
11
+ * clone (`stringify/inner-loop.ts`) and the static-loop CSR materialize
12
+ * clones (#1247, `stringify/loop.ts`) parsed bare and stayed in the wrong
13
+ * namespace.
14
+ *
15
+ * The fix mirrors the established wrap: parse `<svg>${template}</svg>` and
16
+ * descend one extra level (`.firstElementChild.firstElementChild`).
17
+ * HTML-rooted templates keep byte-identical output.
18
+ */
19
+
20
+ import { describe, test, expect } from 'bun:test'
21
+ import { compileJSX } from '../compiler'
22
+ import { TestAdapter } from '../adapters/test-adapter'
23
+
24
+ const adapter = new TestAdapter()
25
+
26
+ function clientJsFor(source: string): string {
27
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
28
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
29
+ const clientJs = result.files.find(f => f.type === 'clientJs')
30
+ expect(clientJs).toBeDefined()
31
+ return clientJs!.content
32
+ }
33
+
34
+ describe('inner reactive loop with an SVG element root (#2219)', () => {
35
+ test('SVG-rooted inner item clones inside a synthetic <svg> wrap', () => {
36
+ const content = clientJsFor(`
37
+ 'use client'
38
+ import { createSignal } from '@barefootjs/client'
39
+ interface Sheet { id: number; ticks: number[] }
40
+ export function Repro() {
41
+ const [sheets] = createSignal<Sheet[]>([])
42
+ return (
43
+ <div>
44
+ {sheets().map((s) => (
45
+ <svg key={s.id} viewBox="0 0 100 100">
46
+ {s.ticks.map((y) => (
47
+ <line key={y} x1="0" x2="100" y1={y} y2={y} stroke="black" />
48
+ ))}
49
+ </svg>
50
+ ))}
51
+ </div>
52
+ )
53
+ }
54
+ `)
55
+
56
+ // The inner renderItem clone parses inside `<svg>...</svg>` and descends
57
+ // one extra level to reach the real `<line>` root.
58
+ expect(content).toMatch(/__t\.innerHTML = `<svg><line /)
59
+ expect(content).toContain('return __t.content.firstElementChild.firstElementChild.cloneNode(true)')
60
+ })
61
+
62
+ test('HTML-rooted inner item keeps the bare clone byte-identical', () => {
63
+ const content = clientJsFor(`
64
+ 'use client'
65
+ import { createSignal } from '@barefootjs/client'
66
+ interface Group { id: number; items: string[] }
67
+ export function Repro() {
68
+ const [groups] = createSignal<Group[]>([])
69
+ return (
70
+ <ul>
71
+ {groups().map((g) => (
72
+ <li key={g.id}>
73
+ {g.items.map((t) => (
74
+ <span key={t}>{t}</span>
75
+ ))}
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ )
80
+ }
81
+ `)
82
+
83
+ expect(content).toMatch(/__t\.innerHTML = `<span /)
84
+ expect(content).toContain('return __t.content.firstElementChild.cloneNode(true)')
85
+ // No synthetic wrap anywhere in the inner clone.
86
+ expect(content).not.toContain('`<svg><span')
87
+ })
88
+
89
+ test('conditional inner body whose branches are all SVG wraps too (#1088 shape)', () => {
90
+ const content = clientJsFor(`
91
+ 'use client'
92
+ import { createSignal } from '@barefootjs/client'
93
+ interface Sheet { id: number; ticks: number[] }
94
+ export function Repro() {
95
+ const [sheets] = createSignal<Sheet[]>([])
96
+ return (
97
+ <div>
98
+ {sheets().map((s) => (
99
+ <svg key={s.id}>
100
+ {s.ticks.map((y) => (
101
+ y > 50
102
+ ? <line key={y} y1={y} y2={y} />
103
+ : <circle key={y} cy={y} r="1" />
104
+ ))}
105
+ </svg>
106
+ ))}
107
+ </div>
108
+ )
109
+ }
110
+ `)
111
+
112
+ // Both branches are SVG roots, so the interpolated conditional template
113
+ // still gets the wrap (templateRootIsSvg recurses into the branches).
114
+ expect(content).toMatch(/__t\.innerHTML = `<svg>\$\{/)
115
+ expect(content).toContain('return __t.content.firstElementChild.firstElementChild.cloneNode(true)')
116
+ })
117
+ })
118
+
119
+ describe('reactive multi-root fragment with an <svg>-container first root (#2233 review)', () => {
120
+ test('emitMultiRootTemplateCloneLines skips the wrap for <svg>-first fragments', () => {
121
+ const content = clientJsFor(`
122
+ 'use client'
123
+ import { createSignal } from '@barefootjs/client'
124
+ interface Chart { id: number; r: number }
125
+ export function Repro() {
126
+ const [charts] = createSignal<Chart[]>([])
127
+ return (
128
+ <div>
129
+ {charts().map((c) => (
130
+ <>
131
+ <svg key={c.id} viewBox="0 0 10 10"><circle r={c.r} /></svg>
132
+ <span>{c.id}</span>
133
+ </>
134
+ ))}
135
+ </div>
136
+ )
137
+ }
138
+ `)
139
+
140
+ // The multi-root clone parses the fragment bare — no synthetic wrap —
141
+ // so the HTML <span> sibling stays in the HTML namespace.
142
+ expect(content).not.toContain('`<svg><svg')
143
+ expect(content).toContain('__tpl.content.firstElementChild.cloneNode(true)')
144
+ })
145
+ })
146
+
147
+ describe('static-loop CSR materialize with an SVG element root (#2219, #1247 path)', () => {
148
+ test('SVG-rooted materialize clone wraps and descends the extra level', () => {
149
+ const content = clientJsFor(`
150
+ 'use client'
151
+ type Props = { ticks: Record<string, number> }
152
+ export function Repro(props: Props) {
153
+ const entries = Object.entries(props.ticks ?? {}).filter(([, y]) => y > 0)
154
+ return (
155
+ <svg viewBox="0 0 100 100">
156
+ {entries.map(([id, y]) => (
157
+ <line key={id} x1="0" x2="100" y1={y} y2={y} />
158
+ ))}
159
+ </svg>
160
+ )
161
+ }
162
+ `)
163
+
164
+ // Materialize branch present (unsafe prop-derived array, #1247) ...
165
+ expect(content).toMatch(/if \(!__iterEl\)/)
166
+ // ... and its template parse is namespace-aware.
167
+ expect(content).toMatch(/__tpl\.innerHTML = `<svg><line /)
168
+ expect(content).toContain('const __cloned = __tpl.content.firstElementChild.firstElementChild')
169
+ })
170
+
171
+ test('multi-root (fragment) SVG materialize iterates the wrap one level down', () => {
172
+ const content = clientJsFor(`
173
+ 'use client'
174
+ type Props = { ticks: Record<string, number> }
175
+ export function Repro(props: Props) {
176
+ const entries = Object.entries(props.ticks ?? {}).filter(([, y]) => y > 0)
177
+ return (
178
+ <svg viewBox="0 0 100 100">
179
+ {entries.map(([id, y]) => (
180
+ <>
181
+ <line key={id} x1="0" x2="100" y1={y} y2={y} />
182
+ <text x="0" y={y}>{id}</text>
183
+ </>
184
+ ))}
185
+ </svg>
186
+ )
187
+ }
188
+ `)
189
+
190
+ expect(content).toMatch(/__mtpl\.innerHTML = `<svg><line /)
191
+ // Sibling iteration starts inside the synthetic wrap.
192
+ expect(content).toContain('let __sib = __mtpl.content.firstElementChild.firstElementChild')
193
+ })
194
+
195
+ test('<svg>-container-first fragment is NOT over-wrapped (#2233 review)', () => {
196
+ // An <svg> CONTAINER parses into the correct namespace bare — the HTML
197
+ // parser enters foreign content on its own. Wrapping the whole fragment
198
+ // would drag the HTML <span> sibling into the SVG namespace instead.
199
+ const content = clientJsFor(`
200
+ 'use client'
201
+ type Props = { charts: Record<string, number> }
202
+ export function Repro(props: Props) {
203
+ const entries = Object.entries(props.charts ?? {}).filter(([, v]) => v > 0)
204
+ return (
205
+ <div>
206
+ {entries.map(([id, v]) => (
207
+ <>
208
+ <svg key={id} viewBox="0 0 10 10"><circle r={v} /></svg>
209
+ <span>{id}</span>
210
+ </>
211
+ ))}
212
+ </div>
213
+ )
214
+ }
215
+ `)
216
+
217
+ expect(content).toMatch(/__mtpl\.innerHTML = `<svg /)
218
+ expect(content).not.toContain('`<svg><svg')
219
+ // Sibling iteration starts at the template content directly (no wrap).
220
+ expect(content).toContain('let __sib = __mtpl.content.firstElementChild')
221
+ expect(content).not.toContain('let __sib = __mtpl.content.firstElementChild.firstElementChild')
222
+ })
223
+
224
+ test('HTML-rooted materialize clone keeps the bare parse byte-identical', () => {
225
+ const content = clientJsFor(`
226
+ 'use client'
227
+ type Props = { reactions: Record<string, string[]> }
228
+ export function Repro(props: Props) {
229
+ const entries = Object.entries(props.reactions ?? {}).filter(([, users]) => users.length > 0)
230
+ return (
231
+ <div>
232
+ {entries.map(([emoji, users]) => (
233
+ <button key={emoji} type="button">{emoji}: {String(users.length)}</button>
234
+ ))}
235
+ </div>
236
+ )
237
+ }
238
+ `)
239
+
240
+ expect(content).toMatch(/if \(!__iterEl\)/)
241
+ expect(content).toMatch(/__tpl\.innerHTML = `<button /)
242
+ expect(content).toContain('const __cloned = __tpl.content.firstElementChild')
243
+ expect(content).not.toContain('`<svg>')
244
+ })
245
+ })
@@ -53,6 +53,7 @@ function loop(children: IRNode[]): IRNode {
53
53
  markerId: 'l0',
54
54
  loc,
55
55
  isStaticArray: false,
56
+ depth: 0,
56
57
  }
57
58
  }
58
59
 
@@ -0,0 +1,102 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { analyzeComponent } from '../analyzer'
3
+ import { jsxToIR } from '../jsx-to-ir'
4
+ import { collectLoopBoundNames } from '../adapters/loop-bound-names'
5
+ import type { ComponentIR } from '../types'
6
+
7
+ // #2212 (Fable review): `collectLoopBoundNames` finds every name a loop
8
+ // callback binds as its item/index parameter, so each adapter's
9
+ // `collectStringValueNames` can exclude it from string-concat detection —
10
+ // a loop param shadowing an outer string-typed binding of the same name
11
+ // must never be misdetected as string-typed.
12
+ describe('collectLoopBoundNames (#2212)', () => {
13
+ // `collectLoopBoundNames` only reads `.root`; the rest of `ComponentIR`
14
+ // is irrelevant to this walk, so a minimal stand-in is fine here.
15
+ function ir(source: string): ComponentIR {
16
+ const ctx = analyzeComponent(source, 'Test.tsx')
17
+ const root = jsxToIR(ctx)
18
+ if (!root) throw new Error('expected IR')
19
+ return { version: '0.1', metadata: {} as ComponentIR['metadata'], root, errors: [] }
20
+ }
21
+
22
+ test('a component with no loops has no bound names', () => {
23
+ const component = ir(`
24
+ function Test() { return <div>hi</div> }
25
+ export { Test }
26
+ `)
27
+ expect(collectLoopBoundNames(component).size).toBe(0)
28
+ })
29
+
30
+ test('a simple loop param is bound', () => {
31
+ const component = ir(`
32
+ function Test({ items }: { items: string[] }) {
33
+ return <ul>{items.map(name => <li key={name}>{name}</li>)}</ul>
34
+ }
35
+ export { Test }
36
+ `)
37
+ expect(collectLoopBoundNames(component)).toEqual(new Set(['name']))
38
+ })
39
+
40
+ test('both item and index params are bound', () => {
41
+ const component = ir(`
42
+ function Test({ items }: { items: string[] }) {
43
+ return <ul>{items.map((name, idx) => <li key={idx}>{name}</li>)}</ul>
44
+ }
45
+ export { Test }
46
+ `)
47
+ expect(collectLoopBoundNames(component)).toEqual(new Set(['name', 'idx']))
48
+ })
49
+
50
+ test('nested loops contribute all their params', () => {
51
+ const component = ir(`
52
+ function Test({ groups }: { groups: { label: string; items: string[] }[] }) {
53
+ return <div>{groups.map(group => <ul key={group.label}>{group.items.map(item => <li key={item}>{item}</li>)}</ul>)}</div>
54
+ }
55
+ export { Test }
56
+ `)
57
+ expect(collectLoopBoundNames(component)).toEqual(new Set(['group', 'item']))
58
+ })
59
+
60
+ test('a loop body wrapping a child component still contributes its param', () => {
61
+ const component = ir(`
62
+ import { Row } from './row'
63
+ function Test({ items }: { items: string[] }) {
64
+ return <ul>{items.map(item => <Row key={item} label={item} />)}</ul>
65
+ }
66
+ export { Test }
67
+ `)
68
+ expect(collectLoopBoundNames(component)).toEqual(new Set(['item']))
69
+ })
70
+
71
+ // Fable re-review (#2212): `param` holds the raw pattern text for a
72
+ // destructured callback (`"{ name }"`), not the bound name itself — the
73
+ // real bound names live in `paramBindings`. Adapters that lower a
74
+ // destructure to a `{% set name = __bf_item.name %}`-style local (#2087)
75
+ // leave `name` reachable as a bare identifier in the loop body, so it
76
+ // needs the same exclusion as a plain loop param. `node.param`'s raw
77
+ // pattern text still ends up in the set too (added unconditionally,
78
+ // before this arm existed) — harmless noise, since it can never collide
79
+ // with a real identifier name.
80
+ test('a destructured loop param contributes its extracted binding names', () => {
81
+ const component = ir(`
82
+ function Test({ rows }: { rows: { name: string }[] }) {
83
+ return <ul>{rows.map(({ name }) => <li key={name}>{name}</li>)}</ul>
84
+ }
85
+ export { Test }
86
+ `)
87
+ expect(collectLoopBoundNames(component)).toEqual(new Set(['{ name }', 'name']))
88
+ })
89
+
90
+ // Fable re-review (#2212): a `.filter(pred).map(cb)` chain's filter
91
+ // predicate is emitted through the same binary/string-name machinery
92
+ // using its OWN param, which may differ from the map callback's `param`.
93
+ test('a filter().map() chain contributes both the filter predicate param and the map param', () => {
94
+ const component = ir(`
95
+ function Test({ values }: { values: number[] }) {
96
+ return <ul>{values.filter(n => n > 3).map(v => <li key={v}>{v}</li>)}</ul>
97
+ }
98
+ export { Test }
99
+ `)
100
+ expect(collectLoopBoundNames(component)).toEqual(new Set(['n', 'v']))
101
+ })
102
+ })
@@ -92,6 +92,7 @@ describe('buildLoopPlan decision tree (#1253)', () => {
92
92
  kind: 'nested',
93
93
  array: 'item.subs',
94
94
  param: 'sub',
95
+ index: null,
95
96
  key: null,
96
97
  markerId: 'inner-m1',
97
98
  template: '<span></span>',
@@ -161,6 +162,7 @@ describe('buildLoopPlan decision tree (#1253)', () => {
161
162
  kind: 'nested',
162
163
  array: 'item.subs',
163
164
  param: 'sub',
165
+ index: null,
164
166
  key: null,
165
167
  markerId: 'inner-m1',
166
168
  template: '<span></span>',