@barefootjs/jsx 0.18.5 → 0.19.0

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 (96) 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.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/expression-parser.d.ts +8 -0
  8. package/dist/expression-parser.d.ts.map +1 -1
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1002 -284
  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-inner-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  19. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
  21. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
  28. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
  29. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  30. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/csr-substitute.d.ts +23 -0
  32. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/html-template.d.ts +42 -0
  34. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/imports.d.ts +2 -2
  36. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/phases/props-extraction.d.ts +0 -1
  38. package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
  39. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
  40. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  41. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
  43. package/dist/ir-to-client-js/types.d.ts +21 -0
  44. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  45. package/dist/jsx-to-ir.d.ts.map +1 -1
  46. package/dist/signal-init-eval.d.ts +82 -0
  47. package/dist/signal-init-eval.d.ts.map +1 -0
  48. package/dist/static-literal.d.ts +45 -0
  49. package/dist/static-literal.d.ts.map +1 -0
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
  52. package/src/__tests__/client-js-generation.test.ts +40 -0
  53. package/src/__tests__/create-selector.test.ts +110 -0
  54. package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
  55. package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
  56. package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
  57. package/src/__tests__/loop-bound-names.test.ts +102 -0
  58. package/src/__tests__/loop-plan-classification.test.ts +2 -0
  59. package/src/__tests__/map-function-reference.test.ts +303 -0
  60. package/src/__tests__/nested-loop-index-param.test.ts +285 -0
  61. package/src/__tests__/props-destructuring.test.ts +61 -8
  62. package/src/__tests__/signal-init-eval.test.ts +138 -0
  63. package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
  64. package/src/__tests__/static-literal.test.ts +126 -0
  65. package/src/__tests__/string-concat-identifier.test.ts +55 -0
  66. package/src/adapters/dangerous-inner-html.ts +186 -0
  67. package/src/adapters/loop-bound-names.ts +80 -0
  68. package/src/adapters/parsed-expr-emitter.ts +9 -0
  69. package/src/analyzer.ts +36 -22
  70. package/src/expression-parser.ts +33 -0
  71. package/src/index.ts +20 -3
  72. package/src/ir-to-client-js/collect-elements.ts +15 -3
  73. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
  74. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
  75. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  76. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
  77. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  78. package/src/ir-to-client-js/control-flow/shared.ts +103 -6
  79. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
  80. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
  81. package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
  82. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
  83. package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
  84. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
  85. package/src/ir-to-client-js/csr-substitute.ts +53 -0
  86. package/src/ir-to-client-js/html-template.ts +235 -2
  87. package/src/ir-to-client-js/imports.ts +1 -0
  88. package/src/ir-to-client-js/phases/props-extraction.ts +5 -9
  89. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
  90. package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
  91. package/src/ir-to-client-js/reactivity.ts +6 -0
  92. package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
  93. package/src/ir-to-client-js/types.ts +21 -0
  94. package/src/jsx-to-ir.ts +230 -39
  95. package/src/signal-init-eval.ts +165 -0
  96. package/src/static-literal.ts +128 -0
@@ -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>',
@@ -0,0 +1,303 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { analyzeComponent } from '../analyzer'
3
+ import { jsxToIR } from '../jsx-to-ir'
4
+ import { asCallbackMethodCall, isSupported } from '../expression-parser'
5
+ import type { IRExpression } from '../types'
6
+
7
+ // #2206: a bare-identifier callback passed to a value-position higher-order
8
+ // array method (`tags.map(format)`) resolves one hop to its declaration
9
+ // (`resolveCallbackMethodFunctionReferences` in jsx-to-ir.ts), reusing the
10
+ // SAME `findLocalConst` / `findLocalFunction` scope machinery #2090
11
+ // established for `.sort(cb)` — but as a post-parse pass over the whole
12
+ // `ParsedExpr` tree, since the value-returning `.map(cb).join(...)` form is
13
+ // recognized generically downstream of parsing (`asCallbackMethodCall`), not
14
+ // during it.
15
+ describe('function-reference .map(cb) callback (#2206)', () => {
16
+ function textExpr(ir: ReturnType<typeof jsxToIR>): IRExpression {
17
+ if (!ir || ir.type !== 'element') throw new Error('expected a root element')
18
+ const expr = ir.children.find(c => c.type === 'expression')
19
+ if (!expr || expr.type !== 'expression') throw new Error('expected an expression child')
20
+ return expr
21
+ }
22
+
23
+ test('const arrow reference resolves like an inline arrow', () => {
24
+ const source = `
25
+ const format = (t: string) => '#' + t
26
+ function TagLine({ tags }: { tags: string[] }) {
27
+ return <p>{tags.map(format).join(' ')}</p>
28
+ }
29
+ export { TagLine }
30
+ `
31
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
32
+ const ir = jsxToIR(ctx)
33
+ const expr = textExpr(ir)
34
+
35
+ expect(expr.parsed).toBeDefined()
36
+ // .join(' ') is the outer call; its object is the resolved .map(...) call.
37
+ const joinCall = expr.parsed!
38
+ expect(joinCall.kind).toBe('array-method')
39
+ if (joinCall.kind !== 'array-method') return
40
+ const mapCall = asCallbackMethodCall(joinCall.object)
41
+ expect(mapCall).not.toBeNull()
42
+ expect(mapCall!.method).toBe('map')
43
+ expect(mapCall!.arrow.kind).toBe('arrow')
44
+ expect(mapCall!.arrow.params).toEqual(['t'])
45
+ // Resolved body is identical to what an inline `t => '#' + t` would parse to.
46
+ expect(isSupported(joinCall).supported).toBe(true)
47
+ })
48
+
49
+ test('function-declaration reference resolves too', () => {
50
+ const source = `
51
+ function format(t: string) { return '#' + t }
52
+ function TagLine({ tags }: { tags: string[] }) {
53
+ return <p>{tags.map(format).join(' ')}</p>
54
+ }
55
+ export { TagLine }
56
+ `
57
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
58
+ const ir = jsxToIR(ctx)
59
+ const expr = textExpr(ir)
60
+
61
+ expect(expr.parsed).toBeDefined()
62
+ const joinCall = expr.parsed!
63
+ expect(joinCall.kind).toBe('array-method')
64
+ if (joinCall.kind !== 'array-method') return
65
+ const mapCall = asCallbackMethodCall(joinCall.object)
66
+ expect(mapCall).not.toBeNull()
67
+ expect(mapCall!.arrow.params).toEqual(['t'])
68
+ })
69
+
70
+ test('component-scope const shadows a module-scope const of the same name', () => {
71
+ const source = `
72
+ const format = (t: string) => 'module:' + t
73
+ function TagLine({ tags }: { tags: string[] }) {
74
+ const format = (t: string) => 'local:' + t
75
+ return <p>{tags.map(format).join(' ')}</p>
76
+ }
77
+ export { TagLine }
78
+ `
79
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
80
+ const ir = jsxToIR(ctx)
81
+ const expr = textExpr(ir)
82
+ const joinCall = expr.parsed!
83
+ if (joinCall.kind !== 'array-method') throw new Error('expected array-method')
84
+ const mapCall = asCallbackMethodCall(joinCall.object)!
85
+ // The resolved body must be the component-scope binding ('local:'), not
86
+ // the module-scope one ('module:').
87
+ expect(JSON.stringify(mapCall.arrow.body)).toContain('local:')
88
+ })
89
+
90
+ test('unresolvable reference (imported) stays refused with BF101', () => {
91
+ const source = `
92
+ import { format } from './format'
93
+ function TagLine({ tags }: { tags: string[] }) {
94
+ return <p>{tags.map(format).join(' ')}</p>
95
+ }
96
+ export { TagLine }
97
+ `
98
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
99
+ const ir = jsxToIR(ctx)
100
+ const expr = textExpr(ir)
101
+ const joinCall = expr.parsed!
102
+ if (joinCall.kind !== 'array-method') throw new Error('expected array-method')
103
+ // The identifier arg passes through untouched — asCallbackMethodCall
104
+ // still refuses it, so the adapter's UNSUPPORTED_METHODS gate fires.
105
+ expect(asCallbackMethodCall(joinCall.object)).toBeNull()
106
+ expect(isSupported(joinCall).supported).toBe(false)
107
+ })
108
+
109
+ test('non-function const (unresolvable) stays refused with BF101', () => {
110
+ const source = `
111
+ const format = 5
112
+ function TagLine({ tags }: { tags: string[] }) {
113
+ return <p>{tags.map(format).join(' ')}</p>
114
+ }
115
+ export { TagLine }
116
+ `
117
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
118
+ const ir = jsxToIR(ctx)
119
+ const expr = textExpr(ir)
120
+ const joinCall = expr.parsed!
121
+ if (joinCall.kind !== 'array-method') throw new Error('expected array-method')
122
+ expect(asCallbackMethodCall(joinCall.object)).toBeNull()
123
+ expect(isSupported(joinCall).supported).toBe(false)
124
+ })
125
+
126
+ test('/* @client */ still suppresses regardless of resolvability', () => {
127
+ const source = `
128
+ import { format } from './format'
129
+ function TagLine({ tags }: { tags: string[] }) {
130
+ return <p>{/* @client */ tags.map(format).join(' ')}</p>
131
+ }
132
+ export { TagLine }
133
+ `
134
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
135
+ const ir = jsxToIR(ctx)
136
+ const expr = textExpr(ir)
137
+ expect(expr.clientOnly).toBe(true)
138
+ })
139
+
140
+ test('inline arrow callback is unaffected (pre-existing #2073 shape)', () => {
141
+ const source = `
142
+ function TagLine({ tags }: { tags: string[] }) {
143
+ return <p>{tags.map(t => '#' + t).join(' ')}</p>
144
+ }
145
+ export { TagLine }
146
+ `
147
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
148
+ const ir = jsxToIR(ctx)
149
+ const expr = textExpr(ir)
150
+ const joinCall = expr.parsed!
151
+ if (joinCall.kind !== 'array-method') throw new Error('expected array-method')
152
+ expect(isSupported(joinCall).supported).toBe(true)
153
+ })
154
+
155
+ test('resolution generalizes to other CALLBACK_METHODS (e.g. value-position .sort)', () => {
156
+ const source = `
157
+ const byLen = (a: string, b: string) => a.length - b.length
158
+ function TagLine({ tags }: { tags: string[] }) {
159
+ return <p>{tags.sort(byLen).join(' ')}</p>
160
+ }
161
+ export { TagLine }
162
+ `
163
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
164
+ const ir = jsxToIR(ctx)
165
+ const expr = textExpr(ir)
166
+ const joinCall = expr.parsed!
167
+ if (joinCall.kind !== 'array-method') throw new Error('expected array-method')
168
+ const sortCall = asCallbackMethodCall(joinCall.object)
169
+ expect(sortCall).not.toBeNull()
170
+ expect(sortCall!.arrow.params).toEqual(['a', 'b'])
171
+ })
172
+
173
+ test('cross-kind ambiguity (const AND function of the same name) stays refused', () => {
174
+ const source = `
175
+ const format = (t: string) => 'const:' + t
176
+ function format2() {}
177
+ function TagLine({ tags }: { tags: string[] }) {
178
+ function format(t: string) { return 'fn:' + t }
179
+ return <p>{tags.map(format).join(' ')}</p>
180
+ }
181
+ export { TagLine }
182
+ `
183
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
184
+ const ir = jsxToIR(ctx)
185
+ const expr = textExpr(ir)
186
+ const joinCall = expr.parsed!
187
+ if (joinCall.kind !== 'array-method') throw new Error('expected array-method')
188
+ // Component scope has BOTH a const `format` (module scope) and a
189
+ // `function format` (component scope) in play once findLocalConst /
190
+ // findLocalFunction search across scopes — same cross-kind ambiguity
191
+ // `resolveSortComparatorIdentifier` refuses for `.sort(fnref)`.
192
+ expect(asCallbackMethodCall(joinCall.object)).toBeNull()
193
+ expect(isSupported(joinCall).supported).toBe(false)
194
+ })
195
+
196
+ test('.reduce(fnref, init) resolves the callback and preserves the init arg', () => {
197
+ const source = `
198
+ const sum = (acc: number, t: string) => acc + t.length
199
+ function TagLine({ tags }: { tags: string[] }) {
200
+ return <p>{tags.reduce(sum, 0)}</p>
201
+ }
202
+ export { TagLine }
203
+ `
204
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
205
+ const ir = jsxToIR(ctx)
206
+ const expr = textExpr(ir)
207
+ const parsed = expr.parsed!
208
+ const reduceCall = asCallbackMethodCall(parsed)
209
+ expect(reduceCall).not.toBeNull()
210
+ expect(reduceCall!.method).toBe('reduce')
211
+ expect(reduceCall!.arrow.params).toEqual(['acc', 't'])
212
+ // The trailing `0` init arg survives resolution untouched.
213
+ expect(reduceCall!.args).toEqual([{ kind: 'literal', value: 0, literalType: 'number', raw: '0' }])
214
+ })
215
+
216
+ // Fable review (#2214): a spliced-in resolved arrow's body is NOT
217
+ // re-walked, so a bare-identifier callback nested inside another
218
+ // resolved declaration stays refused — a deliberate one-hop limit (see
219
+ // resolveCallbackMethodFunctionReferences's docstring), pinned here so a
220
+ // future change to that behavior is a conscious decision, not a silent
221
+ // regression.
222
+ test('a bare-identifier callback nested inside a resolved declaration stays refused (one-hop limit)', () => {
223
+ const source = `
224
+ const inner = (t: string) => '#' + t
225
+ const outer = (xs: string[]) => xs.map(inner).join(',')
226
+ function TagLine({ rows }: { rows: string[][] }) {
227
+ return <p>{rows.map(outer).join(' ')}</p>
228
+ }
229
+ export { TagLine }
230
+ `
231
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
232
+ const ir = jsxToIR(ctx)
233
+ const expr = textExpr(ir)
234
+ const outerJoin = expr.parsed!
235
+ if (outerJoin.kind !== 'array-method') throw new Error('expected array-method')
236
+ const outerMap = asCallbackMethodCall(outerJoin.object)!
237
+ expect(outerMap).not.toBeNull()
238
+ // `outer`'s body resolves one hop (`xs.map(inner).join(',')`), but the
239
+ // nested `.map(inner)` inside that spliced body is never re-walked.
240
+ const innerJoin = outerMap.arrow.body
241
+ if (innerJoin.kind !== 'array-method') throw new Error('expected inner array-method')
242
+ if (innerJoin.object.kind !== 'call') throw new Error('expected inner call')
243
+ expect(innerJoin.object.args[0]).toEqual({ kind: 'identifier', name: 'inner' })
244
+ expect(asCallbackMethodCall(innerJoin.object)).toBeNull()
245
+ })
246
+
247
+ // Fable review (#2214): the resolver must respect lexical scoping — a
248
+ // bare identifier in callback position that's actually bound by an
249
+ // ENCLOSING arrow's own parameter (not a same-file const/function) must
250
+ // never be resolved against the const/function tables, or SSR silently
251
+ // renders the wrong function while CSR (which evaluates the raw,
252
+ // correctly-scoped expression) renders the right one.
253
+ test('an enclosing callback arrow param shadows a same-named module const', () => {
254
+ const source = `
255
+ const fn = (t: string) => 'WRONG:' + t
256
+ function TagLine({ rows }: { rows: { tags: string[] }[] }) {
257
+ return <p>{rows.map(fn => fn.tags.map(fn).join(',')).join(' ')}</p>
258
+ }
259
+ export { TagLine }
260
+ `
261
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
262
+ const ir = jsxToIR(ctx)
263
+ const expr = textExpr(ir)
264
+ const outerJoin = expr.parsed!
265
+ if (outerJoin.kind !== 'array-method') throw new Error('expected array-method')
266
+ const outerMap = asCallbackMethodCall(outerJoin.object)!
267
+ // The outer arrow's body is `fn.tags.map(fn).join(',')` — the inner
268
+ // `.map(fn)` refers to the outer arrow's OWN param `fn`, not the
269
+ // module-scope const, so it must stay unresolved (an identifier arg).
270
+ const innerJoin = outerMap.arrow.body
271
+ if (innerJoin.kind !== 'array-method') throw new Error('expected inner array-method')
272
+ if (innerJoin.object.kind !== 'call') throw new Error('expected inner call')
273
+ expect(innerJoin.object.args[0]).toEqual({ kind: 'identifier', name: 'fn' })
274
+ expect(asCallbackMethodCall(innerJoin.object)).toBeNull()
275
+ })
276
+
277
+ test('a loop item param shadows a same-named module const inside the loop body', () => {
278
+ const source = `
279
+ const format = (t: string) => 'WRONG:' + t
280
+ function TagLine({ fns, tags }: { fns: string[]; tags: string[] }) {
281
+ return <ul>{fns.map(format => <li>{tags.map(format).join(' ')}</li>)}</ul>
282
+ }
283
+ export { TagLine }
284
+ `
285
+ const ctx = analyzeComponent(source, 'TagLine.tsx')
286
+ const ir = jsxToIR(ctx)
287
+ if (!ir || ir.type !== 'element') throw new Error('expected a root element')
288
+ const loop = ir.children.find(c => c.type === 'loop')
289
+ if (!loop || loop.type !== 'loop') throw new Error('expected a loop child')
290
+ expect(loop.param).toBe('format')
291
+ const li = loop.children.find(c => c.type === 'element')
292
+ if (!li || li.type !== 'element') throw new Error('expected the <li> element')
293
+ const innerExpr = li.children.find(c => c.type === 'expression')
294
+ if (!innerExpr || innerExpr.type !== 'expression') throw new Error('expected an expression child')
295
+ const joinCall = innerExpr.parsed!
296
+ if (joinCall.kind !== 'array-method') throw new Error('expected array-method')
297
+ // `tags.map(format)` inside the loop body refers to the loop's own item
298
+ // variable `format`, not the module-scope const — must stay unresolved.
299
+ if (joinCall.object.kind !== 'call') throw new Error('expected inner call')
300
+ expect(joinCall.object.args[0]).toEqual({ kind: 'identifier', name: 'format' })
301
+ expect(asCallbackMethodCall(joinCall.object)).toBeNull()
302
+ })
303
+ })