@barefootjs/jsx 0.31.7 → 0.31.9

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 (31) hide show
  1. package/dist/index.js +136 -59
  2. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  3. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  4. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  5. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +66 -38
  6. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/html-template.d.ts +15 -1
  10. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/imports.d.ts +2 -2
  12. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  13. package/package.json +2 -2
  14. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +210 -210
  15. package/src/__tests__/aliased-destructured-prop-csr.test.ts +8 -2
  16. package/src/__tests__/csr-template-loop-shadowing.test.ts +5 -2
  17. package/src/__tests__/env-signal-template-prelude.test.ts +89 -0
  18. package/src/__tests__/inner-loop-mathml-namespace.test.ts +135 -0
  19. package/src/__tests__/markup-prop-brand.test.ts +161 -0
  20. package/src/__tests__/mathml-mapArray-namespace.test.ts +230 -0
  21. package/src/__tests__/text-slot-escaping.test.ts +21 -12
  22. package/src/ir-to-client-js/collect-elements.ts +38 -5
  23. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +10 -10
  24. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +3 -4
  25. package/src/ir-to-client-js/control-flow/stringify/loop.ts +18 -14
  26. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +142 -78
  27. package/src/ir-to-client-js/csr-substitute.ts +3 -2
  28. package/src/ir-to-client-js/emit-registration.ts +56 -4
  29. package/src/ir-to-client-js/html-template.ts +103 -12
  30. package/src/ir-to-client-js/imports.ts +4 -0
  31. package/src/ir-to-client-js/index.ts +6 -1
@@ -43,7 +43,10 @@ describe('aliased destructured props reach client JS under the caller-facing key
43
43
  expect(clientJs!.content).not.toContain('_p.count')
44
44
  // The CSR `template:` lambda (module-scope SSR fallback) reads the
45
45
  // same caller-facing key.
46
- expect(clientJs!.content).toContain('escapeText(_p.n)')
46
+ // `escapeTextOrMarkup`, not bare `escapeText` — this slot is
47
+ // claim-plan `kind: 'markup'` (#2651), unrelated to the #2524 aliasing
48
+ // concern this test pins; only the escape call's name changed.
49
+ expect(clientJs!.content).toContain('escapeTextOrMarkup(_p.n)')
47
50
  })
48
51
 
49
52
  test('{ text, n } — un-aliased: byte-identical to the pre-fix `_p.n` extraction', () => {
@@ -66,7 +69,10 @@ describe('aliased destructured props reach client JS under the caller-facing key
66
69
  const clientJs = result.files.find((f) => f.type === 'clientJs')
67
70
  expect(clientJs).toBeDefined()
68
71
  expect(clientJs!.content).toContain('const n = _p.n')
69
- expect(clientJs!.content).toContain('escapeText(_p.n)')
72
+ // `escapeTextOrMarkup`, not bare `escapeText` — this slot is
73
+ // claim-plan `kind: 'markup'` (#2651), unrelated to the #2524 aliasing
74
+ // concern this test pins; only the escape call's name changed.
75
+ expect(clientJs!.content).toContain('escapeTextOrMarkup(_p.n)')
70
76
  })
71
77
 
72
78
  test('aliased controlled-signal prop — the accessor reads the caller-facing key', () => {
@@ -111,8 +111,11 @@ describe('hydrate template: loop param shadowing an outer name (#2222 bug 2)', (
111
111
  }
112
112
  `))
113
113
 
114
- // Outside the loop: the prop rewrite applies.
115
- expect(tpl).toContain('escapeText(_p.label)')
114
+ // Outside the loop: the prop rewrite applies. `escapeTextOrMarkup`, not
115
+ // bare `escapeText` — this top-level slot is claim-plan `kind: 'markup'`
116
+ // (#2651); the loop-body slot below stays plain-text (loops never
117
+ // reach `ctx.dynamicElements`), so only this one call name changed.
118
+ expect(tpl).toContain('escapeTextOrMarkup(_p.label)')
116
119
  // Inside the loop: the shadowing param must survive untouched.
117
120
  expect(tpl).toContain('escapeText(1 + label)')
118
121
  expect(tpl).not.toContain('1 + _p.label')
@@ -0,0 +1,89 @@
1
+ /**
2
+ * #2654 — the CSR `template:` lambda must declare its own env-signal
3
+ * getter (`createSearchParams()`, #2057) before referencing it, instead
4
+ * of relying on `init`'s `const [<getter>] = createSearchParams()`
5
+ * closure — the template lambda runs at module scope and has no access
6
+ * to that init-local binding, so the bare call ReferenceErrors at
7
+ * template-evaluation time.
8
+ *
9
+ * See `buildTemplateDefPart` in `../ir-to-client-js/emit-registration.ts`.
10
+ */
11
+
12
+ import { describe, test, expect } from 'bun:test'
13
+ import { compileJSX } from '../compiler'
14
+ import { TestAdapter } from '../adapters/test-adapter'
15
+
16
+ const adapter = new TestAdapter()
17
+
18
+ function compileClient(source: string, fileName: string): string {
19
+ const result = compileJSX(source, fileName, { adapter })
20
+ expect(result.errors).toHaveLength(0)
21
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
22
+ return clientJs?.content ?? ''
23
+ }
24
+
25
+ describe('#2654 — env-signal getter self-declared in the template lambda', () => {
26
+ test('default getter name (`searchParams`): template lambda declares its own copy', () => {
27
+ const source = `
28
+ import { createSearchParams } from '@barefootjs/client'
29
+ export function SortLabel() {
30
+ const [searchParams] = createSearchParams()
31
+ return <p>{searchParams().get('sort') ?? 'none'}</p>
32
+ }
33
+ `
34
+ const clientJs = compileClient(source, 'SortLabel.tsx')
35
+
36
+ // The template lambda gets a block body with its own prelude
37
+ // declaration ahead of the returned template literal.
38
+ expect(clientJs).toMatch(
39
+ /template:\s*\(_p\)\s*=>\s*\{\s*const \[searchParams\] = createSearchParams\(\);\s*return\s*`/,
40
+ )
41
+ // The getter call inside the template body is untouched (still a
42
+ // real call — csr-substitute.ts deliberately leaves env-signal
43
+ // getters as live calls, not baked initial values). `escapeTextOrMarkup`,
44
+ // not bare `escapeText` — this slot is claim-plan `kind: 'markup'`
45
+ // (#2651), unrelated to this test's #2654 prelude concern.
46
+ expect(clientJs).toMatch(/\$\{escapeTextOrMarkup\(searchParams\(\)\.get\('sort'\) \?\? 'none'\)\}/)
47
+ // `init` keeps its own independent destructure — unaffected by the
48
+ // template-lambda prelude.
49
+ expect(clientJs).toMatch(/export function initSortLabel[\s\S]*const \[searchParams\] = createSearchParams\(\)/)
50
+ })
51
+
52
+ test('aliased getter name (`sp`): prelude uses the destructured alias, not the canonical name', () => {
53
+ const source = `
54
+ 'use client'
55
+ import { createMemo, createSearchParams } from '@barefootjs/client'
56
+ export function SortStatus() {
57
+ const [sp] = createSearchParams()
58
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
59
+ return <p>sort: {sort()}</p>
60
+ }
61
+ `
62
+ const clientJs = compileClient(source, 'SortStatus.tsx')
63
+
64
+ expect(clientJs).toMatch(
65
+ /template:\s*\(_p\)\s*=>\s*\{\s*const \[sp\] = createSearchParams\(\);\s*return\s*`/,
66
+ )
67
+ // The memo body substitution inlines the memo's computation, still
68
+ // calling the self-declared `sp()` getter — no bare, undeclared
69
+ // reference reaches the module-scope lambda.
70
+ expect(clientJs).toMatch(/sp\(\)\.get\('sort'\) \?\? 'date'/)
71
+ })
72
+
73
+ test('component with no env signal keeps the plain expression-body template (no prelude leak)', () => {
74
+ const source = `
75
+ 'use client'
76
+ import { createSignal } from '@barefootjs/client'
77
+ export function Counter() {
78
+ const [count] = createSignal(0)
79
+ return <p>{count()}</p>
80
+ }
81
+ `
82
+ const clientJs = compileClient(source, 'Counter.tsx')
83
+
84
+ // Plain expression-body form — unconditional prelude emission must
85
+ // not leak into components that hold no env signal.
86
+ expect(clientJs).toMatch(/template:\s*\(_p\)\s*=>\s*`/)
87
+ expect(clientJs).not.toMatch(/template:\s*\(_p\)\s*=>\s*\{/)
88
+ })
89
+ })
@@ -0,0 +1,135 @@
1
+ /**
2
+ * MathML-rooted loop item templates cloned via `template.innerHTML` (#1096).
3
+ *
4
+ * MathML port of `inner-loop-svg-namespace.test.ts` (#2219, #135/#1088).
5
+ * `template.innerHTML` parses in the HTML namespace, so an inner reactive
6
+ * loop whose item root is a MathML element (`<mrow>`, `<mfrac>`, ...)
7
+ * clones as an `HTMLUnknownElement` — present in the DOM but never drawn
8
+ * by the MathML renderer, with no error. This mirrors the SVG fix: parse
9
+ * `<math>${template}</math>` and descend one extra level
10
+ * (`.firstElementChild.firstElementChild`). HTML-rooted templates keep
11
+ * byte-identical output.
12
+ */
13
+
14
+ import { describe, test, expect } from 'bun:test'
15
+ import { compileJSX } from '../compiler'
16
+ import { TestAdapter } from '../adapters/test-adapter'
17
+
18
+ const adapter = new TestAdapter()
19
+
20
+ function clientJsFor(source: string): string {
21
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
22
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
23
+ const clientJs = result.files.find(f => f.type === 'clientJs')
24
+ expect(clientJs).toBeDefined()
25
+ return clientJs!.content
26
+ }
27
+
28
+ describe('inner reactive loop with a MathML element root (#1096)', () => {
29
+ test('MathML-rooted inner item clones inside a synthetic <math> wrap', () => {
30
+ const content = clientJsFor(`
31
+ 'use client'
32
+ import { createSignal } from '@barefootjs/client'
33
+ interface Sheet { id: number; terms: number[] }
34
+ export function Repro() {
35
+ const [sheets] = createSignal<Sheet[]>([])
36
+ return (
37
+ <div>
38
+ {sheets().map((s) => (
39
+ <math key={s.id}>
40
+ {s.terms.map((n) => (
41
+ <mn key={n}>{String(n)}</mn>
42
+ ))}
43
+ </math>
44
+ ))}
45
+ </div>
46
+ )
47
+ }
48
+ `)
49
+
50
+ // The inner renderItem clone parses inside `<math>...</math>` and
51
+ // descends one extra level to reach the real `<mn>` root.
52
+ expect(content).toMatch(/__t\.innerHTML = `<math><mn /)
53
+ expect(content).toContain('return __t.content.firstElementChild.firstElementChild.cloneNode(true)')
54
+ })
55
+
56
+ test('conditional inner body whose branches are all MathML wraps too (#1088 shape)', () => {
57
+ const content = clientJsFor(`
58
+ 'use client'
59
+ import { createSignal } from '@barefootjs/client'
60
+ interface Sheet { id: number; terms: number[] }
61
+ export function Repro() {
62
+ const [sheets] = createSignal<Sheet[]>([])
63
+ return (
64
+ <div>
65
+ {sheets().map((s) => (
66
+ <math key={s.id}>
67
+ {s.terms.map((n) => (
68
+ n > 50
69
+ ? <mn key={n}>{String(n)}</mn>
70
+ : <mi key={n}>x</mi>
71
+ ))}
72
+ </math>
73
+ ))}
74
+ </div>
75
+ )
76
+ }
77
+ `)
78
+
79
+ expect(content).toMatch(/__t\.innerHTML = `<math>\$\{/)
80
+ expect(content).toContain('return __t.content.firstElementChild.firstElementChild.cloneNode(true)')
81
+ })
82
+ })
83
+
84
+ describe('reactive multi-root fragment with a <math>-container first root (#2233-shape review)', () => {
85
+ test('emitMultiRootTemplateCloneLines skips the wrap for <math>-first fragments', () => {
86
+ const content = clientJsFor(`
87
+ 'use client'
88
+ import { createSignal } from '@barefootjs/client'
89
+ interface Eq { id: number; n: number }
90
+ export function Repro() {
91
+ const [eqs] = createSignal<Eq[]>([])
92
+ return (
93
+ <div>
94
+ {eqs().map((e) => (
95
+ <>
96
+ <math key={e.id}><mn>{String(e.n)}</mn></math>
97
+ <span>{e.id}</span>
98
+ </>
99
+ ))}
100
+ </div>
101
+ )
102
+ }
103
+ `)
104
+
105
+ // The multi-root clone parses the fragment bare — no synthetic wrap —
106
+ // so the HTML <span> sibling stays in the HTML namespace.
107
+ expect(content).not.toContain('`<math><math')
108
+ expect(content).toContain('__tpl.content.firstElementChild.cloneNode(true)')
109
+ })
110
+ })
111
+
112
+ describe('static-loop CSR materialize with a MathML element root (#1096, #1247 path)', () => {
113
+ test('MathML-rooted materialize clone wraps and descends the extra level', () => {
114
+ const content = clientJsFor(`
115
+ 'use client'
116
+ type Props = { terms: Record<string, number> }
117
+ export function Repro(props: Props) {
118
+ const entries = Object.entries(props.terms ?? {}).filter(([, n]) => n > 0)
119
+ return (
120
+ <math>
121
+ {entries.map(([id, n]) => (
122
+ <mn key={id}>{String(n)}</mn>
123
+ ))}
124
+ </math>
125
+ )
126
+ }
127
+ `)
128
+
129
+ // Materialize branch present (unsafe prop-derived array, #1247) ...
130
+ expect(content).toMatch(/if \(!__iterEl\)/)
131
+ // ... and its template parse is namespace-aware.
132
+ expect(content).toMatch(/__tpl\.innerHTML = `<math><mn /)
133
+ expect(content).toContain('const __cloned = __tpl.content.firstElementChild.firstElementChild')
134
+ })
135
+ })
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `bfMarkup()` branding for JSX-element-as-non-children-component-prop
3
+ * values (#2651), sound-or-loud at the client-JS emit layer.
4
+ *
5
+ * Before the fix, a JSX element passed at a non-`children` component prop
6
+ * position (`header={<strong>Title</strong>}`) reached the CSR template as
7
+ * a bare HTML string; the receiving component's own `{props.header}`
8
+ * interpolation is a claim-plan `kind: 'markup'` slot (its value may be a
9
+ * live `Node`), but the STATIC template builder always called `escapeText`
10
+ * regardless of that classification — so the compiler-built markup came
11
+ * out HTML-escaped (`&lt;strong&gt;`) instead of raw, and the sentinel
12
+ * scope-placeholder embedded inside it (`bf-s="__BF_PARENT_SCOPE__"`) came
13
+ * out mangled (`&quot;`) and never got substituted (fixture
14
+ * `jsx-element-prop`, `packages/adapter-tests/fixtures/jsx-element-prop.ts`).
15
+ *
16
+ * The fix carries the compiler-built HTML as a `bfMarkup()`-branded value
17
+ * from every producer door to the two consumers (`escapeTextOrMarkup` for
18
+ * the static template, `escapeTextOrNode` for the reactive write) instead
19
+ * of a bare string — see `packages/client/__tests__/runtime/markup-brand.test.ts`
20
+ * for the runtime-helper contract itself. This file pins the COMPILER side:
21
+ * which producer emissions get branded, which stay untouched, and that the
22
+ * markup-classified slot's template evaluation switches escape functions.
23
+ */
24
+ import { describe, test, expect } from 'bun:test'
25
+ import { compileJSX } from '../compiler'
26
+ import { TestAdapter } from '../adapters/test-adapter'
27
+
28
+ const adapter = new TestAdapter()
29
+
30
+ function clientJsFor(source: string, filePath = 'Test.tsx'): string {
31
+ const result = compileJSX(source, filePath, { adapter })
32
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
33
+ const clientJs = result.files.find(f => f.type === 'clientJs')
34
+ expect(clientJs).toBeDefined()
35
+ return clientJs!.content
36
+ }
37
+
38
+ describe('markup-prop brand (#2651)', () => {
39
+ // The exact shape `jsx-element-prop` exercises: a single JSX element
40
+ // passed at a non-`children` prop position, alongside real children.
41
+ const singleElementSource = `
42
+ export function Card(props: { header?: any; children?: any }) {
43
+ return (
44
+ <section>
45
+ <header>{props.header}</header>
46
+ <div>{props.children}</div>
47
+ </section>
48
+ )
49
+ }
50
+ export function JsxElementProp() {
51
+ return (
52
+ <Card header={<strong>Title</strong>}>
53
+ <p>body text</p>
54
+ </Card>
55
+ )
56
+ }
57
+ `
58
+
59
+ test('(a) renderChild / static-template prop emission wraps the JSX-element prop in bfMarkup(...)', () => {
60
+ const clientJs = clientJsFor(singleElementSource)
61
+ // The parent's static template calls renderChild('Card', {header: bfMarkup(`...`), ...}, ...) —
62
+ // not a bare backtick string.
63
+ expect(clientJs).toMatch(/renderChild\('Card',\s*\{header:\s*bfMarkup\(`<strong[^`]*Title[^`]*<\/strong>`\)/)
64
+ })
65
+
66
+ test('(b) initChild getter prop emission wraps the JSX-element prop in bfMarkup(...)', () => {
67
+ const clientJs = clientJsFor(singleElementSource)
68
+ // initChild('Card', _s0, { get header() { return bfMarkup(`<strong>Title</strong>`) } })
69
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return bfMarkup\(`<strong>Title<\/strong>`\)\s*\}/)
70
+ })
71
+
72
+ test("(c) the receiving component's markup-classified slot uses escapeTextOrMarkup, not escapeText", () => {
73
+ const clientJs = clientJsFor(singleElementSource)
74
+ expect(clientJs).toContain('escapeTextOrMarkup(_p.header)')
75
+ expect(clientJs).not.toContain('escapeText(_p.header)')
76
+ // The reactive write for the same slot already went through
77
+ // escapeTextOrNode before this fix; still present, now brand-aware.
78
+ expect(clientJs).toContain('escapeTextOrNode(__val)')
79
+ })
80
+
81
+ test('(d) a plain string prop (no JSX literal) is never branded and keeps the plain expression form', () => {
82
+ const source = `
83
+ export function Card(props: { header?: any }) {
84
+ return <header>{props.header}</header>
85
+ }
86
+ export function PlainStringProp(props: { label: string }) {
87
+ return <Card header={props.label} />
88
+ }
89
+ `
90
+ const clientJs = clientJsFor(source)
91
+ expect(clientJs).not.toContain('bfMarkup(')
92
+ // The prop stays a bare expression reference — `expression` AttrValue
93
+ // kind, not `jsx-children` — routed straight through to `renderChild`.
94
+ expect(clientJs).toMatch(/renderChild\('Card',\s*\{header:\s*_p\.label\}/)
95
+ // The slot is still claim-plan 'markup' (any component prop can be
96
+ // dynamic) — escapeTextOrMarkup is still the right call, just never
97
+ // fed a branded value here.
98
+ expect(clientJs).toContain('escapeTextOrMarkup(_p.header)')
99
+ })
100
+
101
+ test('(e) an explicit `children={<jsx/>}` prop is excluded from branding (out of scope, byte-invariant)', () => {
102
+ const source = `
103
+ function Box({ children }: { children: any }) { return <div>{children}</div> }
104
+ export function ChildrenJsxExpression() {
105
+ return <Box children={<span>x</span>} />
106
+ }
107
+ `
108
+ const clientJs = clientJsFor(source)
109
+ expect(clientJs).not.toContain('bfMarkup(')
110
+ expect(clientJs).toContain('children: `<span bf-s="__BF_PARENT_SCOPE__">x</span>`')
111
+ expect(clientJs).toContain('get children() { return `<span>x</span>` }')
112
+ })
113
+
114
+ test('(f) between-tag JSX children stay byte-invariant (out of scope, unbranded)', () => {
115
+ const source = `
116
+ export function Card(props: { header?: any; children?: any }) {
117
+ return (
118
+ <section>
119
+ <header>{props.header}</header>
120
+ <div>{props.children}</div>
121
+ </section>
122
+ )
123
+ }
124
+ export function JsxElementProp() {
125
+ return (
126
+ <Card header={<strong>Title</strong>}>
127
+ <p>body text</p>
128
+ </Card>
129
+ )
130
+ }
131
+ `
132
+ const clientJs = clientJsFor(source)
133
+ // The between-tag `<p>body text</p>` children entry is a plain
134
+ // backtick string, never wrapped — only the non-`children` `header`
135
+ // prop next to it is branded.
136
+ expect(clientJs).toContain('children: `<p>body text</p>`')
137
+ })
138
+
139
+ test('(g) a JSX element nested inside another component prop value (door via createComponent getters) is also branded', () => {
140
+ // `header` itself contains a component (`Wrapper`), which routes
141
+ // through the `__slot(...)` live-node mechanism (unaffected by this
142
+ // fix); `Wrapper`'s OWN `bar` prop is a lone JSX element and reaches
143
+ // the SAME brand through the nested `irNodeToJsExprs` door.
144
+ const source = `
145
+ export function Wrapper(props: { bar?: any }) {
146
+ return <em>{props.bar}</em>
147
+ }
148
+ export function Card(props: { header?: any }) {
149
+ return <header>{props.header}</header>
150
+ }
151
+ export function NestedJsxProp() {
152
+ return <Card header={<Wrapper bar={<b>Nested</b>} />} />
153
+ }
154
+ `
155
+ const clientJs = clientJsFor(source)
156
+ expect(clientJs).toMatch(/get bar\(\)\s*\{\s*return bfMarkup\(`<b>Nested<\/b>`\)\s*\}/)
157
+ // The outer `header` (contains a component) keeps the pre-existing
158
+ // `__slot(...)` live-node wrapping — not itself re-wrapped in bfMarkup.
159
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return __slot\(/)
160
+ })
161
+ })
@@ -0,0 +1,230 @@
1
+ /**
2
+ * BarefootJS Compiler - MathML mapArray namespace preservation (#1096).
3
+ *
4
+ * MathML port of `svg-mapArray-namespace.test.ts` (#135/#1088). When a
5
+ * `.map()` inside a `<math>` produces MathML elements (e.g. `<mrow>`,
6
+ * `<mfrac>`), the compiler-generated `renderItem` must parse its template
7
+ * under MathML context. The default `template.innerHTML = '<mrow/>'`
8
+ * produces an `HTMLUnknownElement` in xhtml namespace, so the MathML
9
+ * renderer ignores it — same parser quirk as the SVG case, just a smaller
10
+ * element vocabulary.
11
+ *
12
+ * Fix: wrap the `innerHTML` in `<math>...</math>` and descend one extra
13
+ * level when the loop body's root tag is a MathML element. This shares
14
+ * every byte of the wrap machinery with the SVG fix through
15
+ * `detectRootNamespaceWrapTag` / `namespaceWrapForTemplate` in
16
+ * `stringify/template-parse.ts` — only the wrap tag name differs.
17
+ */
18
+
19
+ import { describe, test, expect } from 'bun:test'
20
+ import { compileJSX } from '../compiler'
21
+ import { TestAdapter } from '../adapters/test-adapter'
22
+
23
+ const adapter = new TestAdapter()
24
+
25
+ describe('MathML mapArray namespace preservation (#1096)', () => {
26
+ test('MathML mrow/mfrac mapArray wraps innerHTML with <math> for correct namespace (issue repro shape)', () => {
27
+ const source = `
28
+ 'use client'
29
+ import { createSignal } from '@barefootjs/client'
30
+
31
+ interface Term { key: string; n: string; d: string }
32
+
33
+ export function Equation() {
34
+ const [terms] = createSignal<Term[]>([])
35
+ return (
36
+ <math>
37
+ {terms().map((t) => (
38
+ <mrow key={t.key}>
39
+ <mfrac>
40
+ <mn>{t.n}</mn>
41
+ <mn>{t.d}</mn>
42
+ </mfrac>
43
+ </mrow>
44
+ ))}
45
+ </math>
46
+ )
47
+ }
48
+ `
49
+ const result = compileJSX(source, 'Equation.tsx', { adapter })
50
+ expect(result.errors).toHaveLength(0)
51
+
52
+ const clientJs = result.files.find(f => f.type === 'clientJs')
53
+ const content = clientJs!.content
54
+
55
+ // Must wrap with <math>...</math> for foreign-content parsing
56
+ expect(content).toContain('<math>')
57
+ expect(content).toContain('</math>')
58
+ // Descend one extra level
59
+ expect(content).toContain('.firstElementChild.firstElementChild.cloneNode(true)')
60
+ // The plain HTML clone path must NOT be used for MathML roots
61
+ expect(content).not.toMatch(/__tpl\.innerHTML = `<mrow[^`]*`/)
62
+ })
63
+
64
+ test('HTML li mapArray is unchanged (no MathML wrapping)', () => {
65
+ const source = `
66
+ 'use client'
67
+ import { createSignal } from '@barefootjs/client'
68
+
69
+ interface Item { id: string; label: string }
70
+
71
+ export function L() {
72
+ const [items, setItems] = createSignal<Item[]>([])
73
+ return (
74
+ <ul>
75
+ {items().map((it) => (
76
+ <li key={it.id}>{it.label}</li>
77
+ ))}
78
+ </ul>
79
+ )
80
+ }
81
+ `
82
+ const result = compileJSX(source, 'L.tsx', { adapter })
83
+ expect(result.errors).toHaveLength(0)
84
+
85
+ const clientJs = result.files.find(f => f.type === 'clientJs')
86
+ const content = clientJs!.content
87
+
88
+ expect(content).toContain('.firstElementChild.cloneNode(true)')
89
+ expect(content).not.toContain('.firstElementChild.firstElementChild.cloneNode(true)')
90
+ expect(content).not.toContain('<math>')
91
+ })
92
+
93
+ /**
94
+ * #1088-shape port: when a `.map()` body is a ternary whose branches are
95
+ * all MathML tags, the wrap heuristic must still apply.
96
+ */
97
+ test('MathML ternary body wraps when both branches are MathML-rooted', () => {
98
+ const source = `
99
+ 'use client'
100
+ import { createSignal } from '@barefootjs/client'
101
+
102
+ type Term = { key: string; kind: 'frac' | 'sup'; value: string }
103
+
104
+ export function CondMap() {
105
+ const [terms] = createSignal<Term[]>([])
106
+ return (
107
+ <math>
108
+ {terms().map((t) =>
109
+ t.kind === 'frac'
110
+ ? <mfrac key={t.key}><mn>1</mn><mn>{t.value}</mn></mfrac>
111
+ : <msup key={t.key}><mn>{t.value}</mn><mn>2</mn></msup>
112
+ )}
113
+ </math>
114
+ )
115
+ }
116
+ `
117
+ const result = compileJSX(source, 'CondMap.tsx', { adapter })
118
+ expect(result.errors).toHaveLength(0)
119
+
120
+ const clientJs = result.files.find(f => f.type === 'clientJs')
121
+ const content = clientJs!.content
122
+
123
+ // Wrap with <math>...</math> for foreign-content parsing
124
+ expect(content).toMatch(/__tpl\.innerHTML = `<math>\$\{/)
125
+ expect(content).toMatch(/\}<\/math>`/)
126
+ // Descend one extra level
127
+ expect(content).toContain('.firstElementChild.firstElementChild.cloneNode(true)')
128
+ })
129
+
130
+ test('MathML 3-way ternary wraps recursively', () => {
131
+ // Mirrors the SVG "PolarGrid" 3-way-ternary regression pin: the
132
+ // compiler nests the inner conditional inside
133
+ // `<!--bf-cond-start:sX-->` markers, so the wrap heuristic must skip
134
+ // those and recurse into the inner `${...}`.
135
+ const source = `
136
+ 'use client'
137
+ import { createSignal } from '@barefootjs/client'
138
+
139
+ type Term = { key: string; kind: 'frac' | 'sup' | 'sub' }
140
+
141
+ export function TermList() {
142
+ const [terms] = createSignal<Term[]>([])
143
+ return (
144
+ <math>
145
+ {terms().map((t) =>
146
+ t.kind === 'frac'
147
+ ? <mfrac key={t.key}><mn>1</mn><mn>2</mn></mfrac>
148
+ : t.kind === 'sup'
149
+ ? <msup key={t.key}><mn>1</mn><mn>2</mn></msup>
150
+ : <msub key={t.key}><mn>1</mn><mn>2</mn></msub>
151
+ )}
152
+ </math>
153
+ )
154
+ }
155
+ `
156
+ const result = compileJSX(source, 'TermList.tsx', { adapter })
157
+ expect(result.errors).toHaveLength(0)
158
+
159
+ const clientJs = result.files.find(f => f.type === 'clientJs')
160
+ const content = clientJs!.content
161
+
162
+ expect(content).toMatch(/__tpl\.innerHTML = `<math>\$\{/)
163
+ expect(content).toContain('.firstElementChild.firstElementChild.cloneNode(true)')
164
+ })
165
+
166
+ test('mixed HTML/MathML ternary body falls through to no-wrap', () => {
167
+ const source = `
168
+ 'use client'
169
+ import { createSignal } from '@barefootjs/client'
170
+
171
+ type Item = { key: string; useMath: boolean }
172
+
173
+ export function Mixed() {
174
+ const [items] = createSignal<Item[]>([])
175
+ return (
176
+ <div>
177
+ {items().map((i) =>
178
+ i.useMath
179
+ ? <mn key={i.key}>1</mn>
180
+ : <span key={i.key}>x</span>
181
+ )}
182
+ </div>
183
+ )
184
+ }
185
+ `
186
+ const result = compileJSX(source, 'Mixed.tsx', { adapter })
187
+ expect(result.errors).toHaveLength(0)
188
+
189
+ const clientJs = result.files.find(f => f.type === 'clientJs')
190
+ const content = clientJs!.content
191
+
192
+ expect(content).toContain('.firstElementChild.cloneNode(true)')
193
+ expect(content).not.toContain('.firstElementChild.firstElementChild.cloneNode(true)')
194
+ })
195
+
196
+ test('mixed SVG/MathML ternary body falls through to no-wrap', () => {
197
+ // Neither namespace should silently win: an SVG branch and a MathML
198
+ // branch in the same ternary is out of scope for the wrap heuristic,
199
+ // same as the pre-existing mixed HTML/SVG case.
200
+ const source = `
201
+ 'use client'
202
+ import { createSignal } from '@barefootjs/client'
203
+
204
+ type Item = { key: string; useSvg: boolean }
205
+
206
+ export function MixedNs() {
207
+ const [items] = createSignal<Item[]>([])
208
+ return (
209
+ <div>
210
+ {items().map((i) =>
211
+ i.useSvg
212
+ ? <circle key={i.key} cx="0" cy="0" r="5" />
213
+ : <mn key={i.key}>1</mn>
214
+ )}
215
+ </div>
216
+ )
217
+ }
218
+ `
219
+ const result = compileJSX(source, 'MixedNs.tsx', { adapter })
220
+ expect(result.errors).toHaveLength(0)
221
+
222
+ const clientJs = result.files.find(f => f.type === 'clientJs')
223
+ const content = clientJs!.content
224
+
225
+ expect(content).toContain('.firstElementChild.cloneNode(true)')
226
+ expect(content).not.toContain('.firstElementChild.firstElementChild.cloneNode(true)')
227
+ expect(content).not.toContain('<svg>')
228
+ expect(content).not.toContain('<math>')
229
+ })
230
+ })