@barefootjs/jsx 0.31.8 → 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.
- package/dist/index.js +127 -57
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +66 -38
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +15 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +210 -210
- package/src/__tests__/aliased-destructured-prop-csr.test.ts +8 -2
- package/src/__tests__/csr-template-loop-shadowing.test.ts +5 -2
- package/src/__tests__/env-signal-template-prelude.test.ts +4 -2
- package/src/__tests__/inner-loop-mathml-namespace.test.ts +135 -0
- package/src/__tests__/markup-prop-brand.test.ts +161 -0
- package/src/__tests__/mathml-mapArray-namespace.test.ts +230 -0
- package/src/__tests__/text-slot-escaping.test.ts +21 -12
- package/src/ir-to-client-js/collect-elements.ts +38 -5
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +10 -10
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +3 -4
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +18 -14
- package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +142 -78
- package/src/ir-to-client-js/emit-registration.ts +5 -1
- package/src/ir-to-client-js/html-template.ts +103 -12
- package/src/ir-to-client-js/imports.ts +4 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
@@ -40,8 +40,10 @@ describe('#2654 — env-signal getter self-declared in the template lambda', ()
|
|
|
40
40
|
)
|
|
41
41
|
// The getter call inside the template body is untouched (still a
|
|
42
42
|
// real call — csr-substitute.ts deliberately leaves env-signal
|
|
43
|
-
// getters as live calls, not baked initial values).
|
|
44
|
-
|
|
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'\)\}/)
|
|
45
47
|
// `init` keeps its own independent destructure — unaffected by the
|
|
46
48
|
// template-lambda prelude.
|
|
47
49
|
expect(clientJs).toMatch(/export function initSortLabel[\s\S]*const \[searchParams\] = createSearchParams\(\)/)
|
|
@@ -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 (`<strong>`) instead of raw, and the sentinel
|
|
12
|
+
* scope-placeholder embedded inside it (`bf-s="__BF_PARENT_SCOPE__"`) came
|
|
13
|
+
* out mangled (`"`) 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
|
+
})
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Text-slot HTML-escaping emit shape (#1694 + follow-up).
|
|
2
|
+
* Text-slot HTML-escaping emit shape (#1694 + follow-up, #2651).
|
|
3
3
|
*
|
|
4
|
-
* Pins which interpolations the client template wraps in
|
|
5
|
-
* - a plain text slot (`{stringValue}`) IS
|
|
6
|
-
* slot's text content under `innerHTML
|
|
4
|
+
* Pins which interpolations the client template wraps in an escape call:
|
|
5
|
+
* - a plain, non-conditional dynamic text slot (`{stringValue}`) IS
|
|
6
|
+
* escaped — it becomes the slot's text content under `innerHTML`. Since
|
|
7
|
+
* #2651 this goes through `escapeTextOrMarkup`, not bare `escapeText`:
|
|
8
|
+
* the slot is claim-plan `kind: 'markup'` (the value may be a live
|
|
9
|
+
* `Node`, or a `bfMarkup()`-branded JSX-element-prop value), and
|
|
10
|
+
* `escapeTextOrMarkup` is a strict superset of `escapeText` for every
|
|
11
|
+
* non-branded value — this pin's actual escaping behaviour for a plain
|
|
12
|
+
* string is unchanged, only the call name changed to match the
|
|
13
|
+
* REACTIVE side's existing `escapeTextOrNode` classification;
|
|
7
14
|
* - a branch-slot expression (Child-position value inside a conditional
|
|
8
15
|
* `template()` arrow) is routed through `__bfSlot` and must NOT be
|
|
9
|
-
* wrapped in
|
|
10
|
-
* markers for live nodes; escaping the whole call
|
|
11
|
-
* drops slotted content (the regression that broke
|
|
12
|
-
* `__bfSlot` escapes its own plain-string path
|
|
16
|
+
* wrapped in either escape call. `__bfSlot` returns raw
|
|
17
|
+
* `<!--bf-slot:N-->` markers for live nodes; escaping the whole call
|
|
18
|
+
* corrupts them and drops slotted content (the regression that broke
|
|
19
|
+
* `e2e-site-ui`). `__bfSlot` escapes its own plain-string path
|
|
20
|
+
* internally instead.
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
23
|
import { describe, test, expect } from 'bun:test'
|
|
@@ -27,7 +35,7 @@ function getClientJs(source: string, filename: string): string {
|
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
describe('text-slot escaping', () => {
|
|
30
|
-
test('a plain text slot is wrapped in
|
|
38
|
+
test('a plain text slot is wrapped in escapeTextOrMarkup (#2651)', () => {
|
|
31
39
|
const clientJs = getClientJs(
|
|
32
40
|
`'use client'
|
|
33
41
|
export function Label({ text }: { text: string }) {
|
|
@@ -35,10 +43,10 @@ describe('text-slot escaping', () => {
|
|
|
35
43
|
}`,
|
|
36
44
|
'Label.tsx',
|
|
37
45
|
)
|
|
38
|
-
expect(clientJs).toMatch(/<!--bf:\w+-->\$\{
|
|
46
|
+
expect(clientJs).toMatch(/<!--bf:\w+-->\$\{escapeTextOrMarkup\(_p\.text\)\}<!--\/-->/)
|
|
39
47
|
})
|
|
40
48
|
|
|
41
|
-
test('a branch-slot expression is NOT wrapped in escapeText', () => {
|
|
49
|
+
test('a branch-slot expression is NOT wrapped in escapeTextOrMarkup or escapeText', () => {
|
|
42
50
|
const clientJs = getClientJs(
|
|
43
51
|
`'use client'
|
|
44
52
|
import { createSignal } from '@barefootjs/client'
|
|
@@ -50,7 +58,8 @@ describe('text-slot escaping', () => {
|
|
|
50
58
|
)
|
|
51
59
|
// The branch value goes through __bfSlot (raw markers preserved)…
|
|
52
60
|
expect(clientJs).toMatch(/\$\{__bfSlot\(/)
|
|
53
|
-
// …and must never be double-wrapped by
|
|
61
|
+
// …and must never be double-wrapped by either text-escape call.
|
|
62
|
+
expect(clientJs).not.toMatch(/escapeTextOrMarkup\(\s*__bfSlot/)
|
|
54
63
|
expect(clientJs).not.toMatch(/escapeText\(\s*__bfSlot/)
|
|
55
64
|
})
|
|
56
65
|
})
|