@barefootjs/jsx 0.31.8 → 0.31.10
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/analyzer.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +20 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.js +226 -66
- 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/dist/ssr-defaults.d.ts +10 -0
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/ssr-seed-plan.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__/date-lowering.test.ts +1 -1
- 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__/props-destructuring.test.ts +40 -3
- package/src/__tests__/ssr-defaults.test.ts +230 -0
- package/src/__tests__/ssr-seed-plan.test.ts +72 -0
- package/src/__tests__/text-slot-escaping.test.ts +21 -12
- package/src/analyzer.ts +83 -26
- package/src/expression-parser.ts +10 -1
- 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
- package/src/ssr-defaults.ts +171 -6
- package/src/ssr-seed-plan.ts +82 -2
|
@@ -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')
|
|
@@ -101,7 +101,7 @@ describe('datePlugin matcher recognition (#2274)', () => {
|
|
|
101
101
|
// destructured path reads the type from propsType, not propsParams, so
|
|
102
102
|
// the widening isn't load-bearing for the matcher above, but propsParams
|
|
103
103
|
// is itself observable IR metadata this plugin's existence is meant to
|
|
104
|
-
// unlock (see analyzer.ts's docstring on `
|
|
104
|
+
// unlock (see analyzer.ts's docstring on `isResolvableMemberType`).
|
|
105
105
|
const md = metadata(`
|
|
106
106
|
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
107
107
|
return <div>{createdAt.toISOString()}</div>
|
|
@@ -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
|
+
})
|
|
@@ -426,7 +426,13 @@ describe('Destructured props keep their declared types (BF043 fixture #2150)', (
|
|
|
426
426
|
})
|
|
427
427
|
})
|
|
428
428
|
|
|
429
|
-
|
|
429
|
+
// #2677: a STRUCTURAL member (array/object) built entirely out of
|
|
430
|
+
// primitives resolves fully now — go-template's `emitSynthPropStructs`
|
|
431
|
+
// (#2674/#2676) can synthesize a real struct for either shape, so the
|
|
432
|
+
// #2150 "unchecked assertion" concern doesn't apply to them any more.
|
|
433
|
+
// `rows: number[][]` is a nested array of primitives; `meta: { id:
|
|
434
|
+
// string }` is an inline object literal with a primitive property.
|
|
435
|
+
test('resolves STRUCTURAL members (nested arrays / inline objects) built entirely out of primitives (#2677)', () => {
|
|
430
436
|
const source = `
|
|
431
437
|
type Props = { rows: number[][]; meta: { id: string } }
|
|
432
438
|
|
|
@@ -437,7 +443,38 @@ describe('Destructured props keep their declared types (BF043 fixture #2150)', (
|
|
|
437
443
|
|
|
438
444
|
const ctx = analyzeComponent(source, 'Component.tsx')
|
|
439
445
|
|
|
440
|
-
expect(typeOf(ctx, 'rows')).toEqual({
|
|
441
|
-
|
|
446
|
+
expect(typeOf(ctx, 'rows')).toEqual({
|
|
447
|
+
kind: 'array',
|
|
448
|
+
raw: 'number[][]',
|
|
449
|
+
elementType: { kind: 'array', raw: 'number[]', elementType: { kind: 'primitive', raw: 'number', primitive: 'number' } },
|
|
450
|
+
})
|
|
451
|
+
expect(typeOf(ctx, 'meta')).toEqual({
|
|
452
|
+
kind: 'object',
|
|
453
|
+
raw: '{ id: string }',
|
|
454
|
+
properties: [{ name: 'id', type: { kind: 'primitive', raw: 'string', primitive: 'string' }, optional: false, readonly: false }],
|
|
455
|
+
})
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
// A structural member declines WHOLLY (not per-leaf) when ANY reachable
|
|
459
|
+
// leaf is a union, a function, or an un-catalogued named type — the same
|
|
460
|
+
// three shapes `isResolvableMemberType` declines at the top level.
|
|
461
|
+
test('declines a structural member when a nested leaf is a union/function/un-catalogued named type (#2677)', () => {
|
|
462
|
+
const source = `
|
|
463
|
+
type Props = {
|
|
464
|
+
variants: { id: string; kind: 'a' | 'b' }[]
|
|
465
|
+
handlers: { onClick: () => void }
|
|
466
|
+
configs: { data: Map<string, string> }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function Component({ variants, handlers, configs }: Props) {
|
|
470
|
+
return <div>{variants.length}</div>
|
|
471
|
+
}
|
|
472
|
+
`
|
|
473
|
+
|
|
474
|
+
const ctx = analyzeComponent(source, 'Component.tsx')
|
|
475
|
+
|
|
476
|
+
expect(typeOf(ctx, 'variants')).toEqual({ kind: 'unknown', raw: 'unknown' })
|
|
477
|
+
expect(typeOf(ctx, 'handlers')).toEqual({ kind: 'unknown', raw: 'unknown' })
|
|
478
|
+
expect(typeOf(ctx, 'configs')).toEqual({ kind: 'unknown', raw: 'unknown' })
|
|
442
479
|
})
|
|
443
480
|
})
|