@barefootjs/jsx 0.18.5 → 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 (88) 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/index.d.ts +14 -4
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +977 -273
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
  15. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
  27. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  28. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/csr-substitute.d.ts +23 -0
  30. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/html-template.d.ts +42 -0
  32. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/imports.d.ts +2 -2
  34. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
  36. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
  39. package/dist/ir-to-client-js/types.d.ts +21 -0
  40. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  41. package/dist/jsx-to-ir.d.ts.map +1 -1
  42. package/dist/signal-init-eval.d.ts +82 -0
  43. package/dist/signal-init-eval.d.ts.map +1 -0
  44. package/dist/static-literal.d.ts +45 -0
  45. package/dist/static-literal.d.ts.map +1 -0
  46. package/package.json +2 -2
  47. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
  48. package/src/__tests__/create-selector.test.ts +110 -0
  49. package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
  50. package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
  51. package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
  52. package/src/__tests__/loop-bound-names.test.ts +102 -0
  53. package/src/__tests__/loop-plan-classification.test.ts +2 -0
  54. package/src/__tests__/map-function-reference.test.ts +303 -0
  55. package/src/__tests__/nested-loop-index-param.test.ts +285 -0
  56. package/src/__tests__/signal-init-eval.test.ts +138 -0
  57. package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
  58. package/src/__tests__/static-literal.test.ts +126 -0
  59. package/src/__tests__/string-concat-identifier.test.ts +55 -0
  60. package/src/adapters/dangerous-inner-html.ts +186 -0
  61. package/src/adapters/loop-bound-names.ts +80 -0
  62. package/src/adapters/parsed-expr-emitter.ts +9 -0
  63. package/src/analyzer.ts +6 -0
  64. package/src/index.ts +19 -3
  65. package/src/ir-to-client-js/collect-elements.ts +15 -3
  66. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
  67. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
  68. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  69. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
  70. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  71. package/src/ir-to-client-js/control-flow/shared.ts +103 -6
  72. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
  73. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
  74. package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
  75. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
  76. package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
  77. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
  78. package/src/ir-to-client-js/csr-substitute.ts +53 -0
  79. package/src/ir-to-client-js/html-template.ts +235 -2
  80. package/src/ir-to-client-js/imports.ts +1 -0
  81. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
  82. package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
  83. package/src/ir-to-client-js/reactivity.ts +6 -0
  84. package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
  85. package/src/ir-to-client-js/types.ts +21 -0
  86. package/src/jsx-to-ir.ts +230 -39
  87. package/src/signal-init-eval.ts +165 -0
  88. package/src/static-literal.ts +128 -0
@@ -0,0 +1,197 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { analyzeComponent } from '../analyzer'
3
+ import { jsxToIR } from '../jsx-to-ir'
4
+ import {
5
+ isDangerousInnerHtmlAttr,
6
+ resolveDangerousInnerHtml,
7
+ dangerousInnerHtmlMetacharViolation,
8
+ dangerousInnerHtmlDiagnostic,
9
+ } from '../adapters/dangerous-inner-html'
10
+
11
+ // #2207: the shared `dangerouslySetInnerHTML` resolver — recognition,
12
+ // static-literal extraction, and per-adapter template-metacharacter
13
+ // guarding all live in one place (packages/jsx/src/adapters/dangerous-inner-html.ts)
14
+ // so every template adapter shares identical, single-source-of-truth policy.
15
+ describe('resolveDangerousInnerHtml (#2207)', () => {
16
+ function rootElement(source: string) {
17
+ const ctx = analyzeComponent(source, 'Test.tsx')
18
+ const ir = jsxToIR(ctx)
19
+ if (!ir || ir.type !== 'element') throw new Error('expected a root element')
20
+ return ir
21
+ }
22
+
23
+ test('absent attribute resolves to null', () => {
24
+ const el = rootElement(`
25
+ function Test() { return <div>hi</div> }
26
+ export { Test }
27
+ `)
28
+ expect(resolveDangerousInnerHtml(el)).toBeNull()
29
+ })
30
+
31
+ test('inline string literal resolves as static', () => {
32
+ const el = rootElement(`
33
+ function Test() { return <div dangerouslySetInnerHTML={{ __html: '<b>bold</b> &amp; safe' }} /> }
34
+ export { Test }
35
+ `)
36
+ const resolution = resolveDangerousInnerHtml(el)
37
+ expect(resolution).toEqual({ kind: 'static', html: '<b>bold</b> &amp; safe' })
38
+ })
39
+
40
+ test('string-key form ({ "__html": ... }) resolves identically to the identifier-key form', () => {
41
+ const el = rootElement(`
42
+ function Test() { return <div dangerouslySetInnerHTML={{ '__html': '<i>x</i>' }} /> }
43
+ export { Test }
44
+ `)
45
+ expect(resolveDangerousInnerHtml(el)).toEqual({ kind: 'static', html: '<i>x</i>' })
46
+ })
47
+
48
+ test('signal/prop-derived value resolves as dynamic', () => {
49
+ const el = rootElement(`
50
+ function Test({ html }: { html: string }) { return <div dangerouslySetInnerHTML={{ __html: html }} /> }
51
+ export { Test }
52
+ `)
53
+ const resolution = resolveDangerousInnerHtml(el)
54
+ expect(resolution?.kind).toBe('dynamic')
55
+ })
56
+
57
+ test('a no-substitution template literal resolves as static (identical to a string literal)', () => {
58
+ const el = rootElement(`
59
+ function Test() { return <div dangerouslySetInnerHTML={{ __html: \`<b>bold</b>\` }} /> }
60
+ export { Test }
61
+ `)
62
+ expect(resolveDangerousInnerHtml(el)).toEqual({ kind: 'static', html: '<b>bold</b>' })
63
+ })
64
+
65
+ test('a template literal WITH a substitution resolves as dynamic — no const-folding in v1', () => {
66
+ const el = rootElement(`
67
+ function Test({ tag }: { tag: string }) { return <div dangerouslySetInnerHTML={{ __html: \`<\${tag}>bold</\${tag}>\` }} /> }
68
+ export { Test }
69
+ `)
70
+ expect(resolveDangerousInnerHtml(el)?.kind).toBe('dynamic')
71
+ })
72
+
73
+ test('shorthand { __html } resolves as dynamic, not the literal named __html', () => {
74
+ const el = rootElement(`
75
+ function Test({ __html }: { __html: string }) { return <div dangerouslySetInnerHTML={{ __html }} /> }
76
+ export { Test }
77
+ `)
78
+ expect(resolveDangerousInnerHtml(el)?.kind).toBe('dynamic')
79
+ })
80
+
81
+ test('local const reference resolves as dynamic — v1 requires an INLINE literal', () => {
82
+ const el = rootElement(`
83
+ const HTML = '<b>bold</b>'
84
+ function Test() { return <div dangerouslySetInnerHTML={{ __html: HTML }} /> }
85
+ export { Test }
86
+ `)
87
+ expect(resolveDangerousInnerHtml(el)?.kind).toBe('dynamic')
88
+ })
89
+
90
+ test('extra property alongside __html resolves as dynamic', () => {
91
+ const el = rootElement(`
92
+ function Test() { return <div dangerouslySetInnerHTML={{ __html: '<b>x</b>', extra: 1 }} /> }
93
+ export { Test }
94
+ `)
95
+ expect(resolveDangerousInnerHtml(el)?.kind).toBe('dynamic')
96
+ })
97
+
98
+ test('isDangerousInnerHtmlAttr only matches the exact prop name', () => {
99
+ const el = rootElement(`
100
+ function Test() { return <div data-foo="bar" dangerouslySetInnerHTML={{ __html: 'x' }} /> }
101
+ export { Test }
102
+ `)
103
+ const names = el.attrs.map(a => a.name)
104
+ expect(names.filter(n => isDangerousInnerHtmlAttr({ name: n } as never))).toEqual(['dangerouslySetInnerHTML'])
105
+ })
106
+
107
+ // Fable review (#2217): a `/* @client */`-deferred value is already a
108
+ // working escape hatch — every adapter's own renderAttributes skips a
109
+ // clientOnly attr, and the client's createEffect-driven innerHTML
110
+ // assignment (emit-reactive.ts) runs regardless of SSR — so this module
111
+ // must treat it as "render normally" (null), not refuse with BF101.
112
+ // Refusing here was a real regression the review caught: it broke the
113
+ // only path that made this module's own suggestion text achievable.
114
+ test('/* @client */-deferred dangerouslySetInnerHTML resolves to null (render normally, defer to hydrate)', () => {
115
+ const el = rootElement(`
116
+ 'use client'
117
+ import { createSignal } from '@barefootjs/client'
118
+ function Test() {
119
+ const [html, setHtml] = createSignal('<b>hi</b>')
120
+ return <div dangerouslySetInnerHTML={/* @client */ { __html: html() }} />
121
+ }
122
+ export { Test }
123
+ `)
124
+ expect(resolveDangerousInnerHtml(el)).toBeNull()
125
+ })
126
+
127
+ test('empty-string literal resolves as static with an empty html value', () => {
128
+ const el = rootElement(`
129
+ function Test() { return <div dangerouslySetInnerHTML={{ __html: '' }} /> }
130
+ export { Test }
131
+ `)
132
+ expect(resolveDangerousInnerHtml(el)).toEqual({ kind: 'static', html: '' })
133
+ })
134
+ })
135
+
136
+ describe('dangerousInnerHtmlMetacharViolation (#2207)', () => {
137
+ test('plain HTML is safe on every adapter', () => {
138
+ for (const id of ['blade', 'erb', 'go-template', 'jinja', 'minijinja', 'mojolicious', 'twig', 'xslate']) {
139
+ expect(dangerousInnerHtmlMetacharViolation('<b>bold</b> &amp; safe', id)).toBeNull()
140
+ }
141
+ })
142
+
143
+ // Fable review (#2217): a 9th template adapter with no entry in the guard
144
+ // table must fail CLOSED (refuse) rather than fail open (splice
145
+ // unguarded) — the safe default when nobody has verified that adapter's
146
+ // template syntax yet.
147
+ test('unknown adapter id fails CLOSED (refuses) rather than open', () => {
148
+ expect(dangerousInnerHtmlMetacharViolation('<b>plain html, no metachars</b>', 'not-a-real-adapter')).not.toBeNull()
149
+ })
150
+
151
+ const cases: Array<[adapterId: string, html: string]> = [
152
+ ['blade', '<b>{{ $evil }}</b>'],
153
+ ['blade', '<b>{!! $evil !!}</b>'],
154
+ ['blade', '<?php echo 1; ?>'],
155
+ ['blade', '@if(true)x@endif'],
156
+ // Laravel's BladeCompiler compiles component tags by default — <x-foo>
157
+ // resolves and renders a live Blade component, not template
158
+ // substitution. The sharpest "not inert text" case in the table.
159
+ ['blade', '<x-alert>evil</x-alert>'],
160
+ ['blade', '</x-alert>'],
161
+ ['erb', '<b><%= evil %></b>'],
162
+ ['go-template', '<b>{{ .Evil }}</b>'],
163
+ ['jinja', '<b>{{ evil }}</b>'],
164
+ ['jinja', '<b>{% if x %}y{% endif %}</b>'],
165
+ ['jinja', '<b>{# comment #}</b>'],
166
+ ['minijinja', '<b>{{ evil }}</b>'],
167
+ ['twig', '<b>{{ evil }}</b>'],
168
+ ['mojolicious', '<b><%= evil %></b>'],
169
+ ['mojolicious', '% my $x = 1;\n<b>hi</b>'],
170
+ // Leading whitespace (not just a bare line start) before the line-code
171
+ // marker, and the marker as the very first character of the string.
172
+ ['mojolicious', ' % my $x = 1;\n<b>hi</b>'],
173
+ ['mojolicious', '% my $x = 1;'],
174
+ ['xslate', '<b><: $evil :></b>'],
175
+ ['xslate', ': my $x = 1;\n<b>hi</b>'],
176
+ ['xslate', ' : my $x = 1;\n<b>hi</b>'],
177
+ ['xslate', ': my $x = 1;'],
178
+ // A literal that IS only the metachar sequence, nothing else.
179
+ ['blade', '{{'],
180
+ ['go-template', '{{'],
181
+ ]
182
+ for (const [adapterId, html] of cases) {
183
+ test(`${adapterId} refuses ${JSON.stringify(html)}`, () => {
184
+ expect(dangerousInnerHtmlMetacharViolation(html, adapterId)).not.toBeNull()
185
+ })
186
+ }
187
+ })
188
+
189
+ describe('dangerousInnerHtmlDiagnostic (#2207)', () => {
190
+ test('produces a BF101 with a purpose-built message', () => {
191
+ const diag = dangerousInnerHtmlDiagnostic('html', { file: 'Test.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } })
192
+ expect(diag.code).toBe('BF101')
193
+ expect(diag.severity).toBe('error')
194
+ expect(diag.message).toContain('dangerouslySetInnerHTML requires a compile-time string literal')
195
+ expect(diag.suggestion?.message).toContain('2215')
196
+ })
197
+ })
@@ -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
+ })
@@ -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>',