@barefootjs/jsx 0.33.2 → 0.33.4
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 +17 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts +21 -5
- package/dist/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +804 -445
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +2 -4
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/component-loop.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.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +60 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +26 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/props-binding.d.ts +35 -0
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/types.d.ts +63 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
- package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
- package/src/__tests__/fragment-body-loop-key.test.ts +95 -0
- package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
- package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
- package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
- package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
- package/src/__tests__/merge-template-imports.test.ts +41 -1
- package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
- package/src/__tests__/multi-root-loop-body.test.ts +7 -3
- package/src/__tests__/preamble-declarations.test.ts +42 -0
- package/src/__tests__/root-key-relay.test.ts +170 -0
- package/src/__tests__/signal-getter-not-called.test.ts +149 -0
- package/src/__tests__/state-only-file-default-import.test.ts +47 -0
- package/src/analyzer.ts +36 -0
- package/src/compiler.ts +94 -104
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/build-references.ts +7 -0
- package/src/ir-to-client-js/collect-elements.ts +27 -5
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +19 -1
- package/src/ir-to-client-js/control-flow/plan/loop.ts +2 -4
- package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +7 -0
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
- package/src/ir-to-client-js/control-flow.ts +12 -0
- package/src/ir-to-client-js/html-template.ts +174 -23
- package/src/ir-to-client-js/imports.ts +178 -5
- package/src/ir-to-client-js/index.ts +5 -0
- package/src/ir-to-client-js/prop-handling.ts +6 -17
- package/src/ir-to-client-js/utils.ts +30 -2
- package/src/jsx-to-ir.ts +592 -58
- package/src/props-binding.ts +51 -0
- package/src/types.ts +60 -13
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2756 — a client-built row/branch must carry the SAME attributes a
|
|
3
|
+
* hydration-reused (SSR-origin) row carries.
|
|
4
|
+
*
|
|
5
|
+
* `lowerFormControlValueSsr` already lowers a controlled `<textarea>` /
|
|
6
|
+
* `<select>` `value` in the SHARED IR: the attr becomes `clientOnly` and
|
|
7
|
+
* the value is re-expressed as element content / per-option `selected`,
|
|
8
|
+
* so every SSR adapter omits the attribute. `irToHtmlTemplate` — the
|
|
9
|
+
* builder for keyed-loop rows and conditional branches — ignored that
|
|
10
|
+
* flag and baked `value="…"` back in, so a rebuilt row and a reused row
|
|
11
|
+
* disagreed the moment a row-count change made both coexist in one list.
|
|
12
|
+
*
|
|
13
|
+
* Each assertion here is paired with the effect that OWNS the value, so
|
|
14
|
+
* "the attribute is gone" can never be satisfied by dropping the binding.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, test, expect } from 'bun:test'
|
|
17
|
+
import { compileJSX } from '../compiler'
|
|
18
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
19
|
+
|
|
20
|
+
const adapter = new TestAdapter()
|
|
21
|
+
|
|
22
|
+
function clientJs(source: string): string {
|
|
23
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
24
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
25
|
+
return result.files.find(f => f.type === 'clientJs')!.content
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The `__tpl.innerHTML = \`…\`` / `insert(... html: \`…\`)` builder strings. */
|
|
29
|
+
function builderTemplates(content: string): string[] {
|
|
30
|
+
return [...content.matchAll(/innerHTML = `([^`]*)`/g)].map(m => m[1])
|
|
31
|
+
.concat([...content.matchAll(/html: `([^`]*)`/g)].map(m => m[1]))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('#2756 — client-built rows honour clientOnly', () => {
|
|
35
|
+
test('a keyed-loop row builder omits the controlled textarea `value` attribute and keeps the child text', () => {
|
|
36
|
+
const content = clientJs(`
|
|
37
|
+
"use client";
|
|
38
|
+
import { createSignal } from '@barefootjs/client'
|
|
39
|
+
export function LoopTextarea() {
|
|
40
|
+
const [val, setVal] = createSignal(0)
|
|
41
|
+
const [items] = createSignal([1, 2, 3])
|
|
42
|
+
return (
|
|
43
|
+
<ul>
|
|
44
|
+
{items().filter(i => i > 0).map(i => (
|
|
45
|
+
<li key={i}>
|
|
46
|
+
<textarea value={val()} onInput={() => setVal(1)} />
|
|
47
|
+
</li>
|
|
48
|
+
))}
|
|
49
|
+
</ul>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
`)
|
|
53
|
+
const rowTemplates = builderTemplates(content).filter(t => t.includes('<textarea'))
|
|
54
|
+
expect(rowTemplates.length).toBeGreaterThan(0)
|
|
55
|
+
for (const tpl of rowTemplates) {
|
|
56
|
+
expect(tpl).not.toContain('value=')
|
|
57
|
+
// The SSR projection of the same value — element content — must stay.
|
|
58
|
+
expect(tpl).toMatch(/<textarea[^>]*>\$\{/)
|
|
59
|
+
}
|
|
60
|
+
// The effect that owns the value is still emitted, so the attribute is
|
|
61
|
+
// absent because the effect applies it, not because nothing does.
|
|
62
|
+
expect(content).toContain(`'value' in`)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a conditional-branch builder omits it too, and still binds the value effect', () => {
|
|
66
|
+
const content = clientJs(`
|
|
67
|
+
"use client";
|
|
68
|
+
import { createSignal } from '@barefootjs/client'
|
|
69
|
+
export function CondTextarea() {
|
|
70
|
+
const [on, setOn] = createSignal(true)
|
|
71
|
+
const [v, setV] = createSignal('x')
|
|
72
|
+
return <div>{on() ? <textarea value={v()} onInput={() => setV('y')} /> : <p>off</p>}</div>
|
|
73
|
+
}
|
|
74
|
+
`)
|
|
75
|
+
const branchTemplates = builderTemplates(content).filter(t => t.includes('<textarea'))
|
|
76
|
+
expect(branchTemplates.length).toBeGreaterThan(0)
|
|
77
|
+
for (const tpl of branchTemplates) expect(tpl).not.toContain('value=')
|
|
78
|
+
expect(content).toContain('createDisposableEffect')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('a loop row keeps per-option `selected` — the select value projection is not collateral', () => {
|
|
82
|
+
const content = clientJs(`
|
|
83
|
+
"use client";
|
|
84
|
+
import { createSignal } from '@barefootjs/client'
|
|
85
|
+
export function LoopSelect() {
|
|
86
|
+
const [val, setVal] = createSignal('a')
|
|
87
|
+
const [rows] = createSignal([1, 2])
|
|
88
|
+
return (
|
|
89
|
+
<ul>
|
|
90
|
+
{rows().filter(r => r > 0).map(r => (
|
|
91
|
+
<li key={r}>
|
|
92
|
+
<select value={val()}>
|
|
93
|
+
<option value="a">A</option>
|
|
94
|
+
<option value="b">B</option>
|
|
95
|
+
</select>
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
`)
|
|
102
|
+
const rowTemplates = builderTemplates(content).filter(t => t.includes('<select'))
|
|
103
|
+
expect(rowTemplates.length).toBeGreaterThan(0)
|
|
104
|
+
for (const tpl of rowTemplates) {
|
|
105
|
+
// The `<select …>` opening tag only — `<option value="a">` is the
|
|
106
|
+
// literal option value and must survive.
|
|
107
|
+
const selectTag = tpl.slice(tpl.indexOf('<select'), tpl.indexOf('<option'))
|
|
108
|
+
expect(selectTag).not.toMatch(/value=/)
|
|
109
|
+
expect(tpl).toContain("'selected'")
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('the composite-row placeholder builder omits it too', () => {
|
|
114
|
+
// `irToPlaceholderTemplate` is the twin builder used when a row also
|
|
115
|
+
// hosts a child component (components become `data-bf-ph` placeholders).
|
|
116
|
+
// Same contract, separate function — it had the same gap.
|
|
117
|
+
const content = clientJs(`
|
|
118
|
+
"use client";
|
|
119
|
+
import { createSignal } from '@barefootjs/client'
|
|
120
|
+
function Badge({ label }: { label: string }) { return <em>{label}</em> }
|
|
121
|
+
export function CompositeRows() {
|
|
122
|
+
const [val, setVal] = createSignal('a')
|
|
123
|
+
const [rows] = createSignal([{ id: 1, label: 'x' }])
|
|
124
|
+
return (
|
|
125
|
+
<ul>
|
|
126
|
+
{rows().map(row => (
|
|
127
|
+
<li key={row.id}>
|
|
128
|
+
<Badge label={row.label} />
|
|
129
|
+
<textarea value={val()} onInput={() => setVal('b')} />
|
|
130
|
+
</li>
|
|
131
|
+
))}
|
|
132
|
+
</ul>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
`)
|
|
136
|
+
const rowTemplates = builderTemplates(content).filter(t => t.includes('data-bf-ph'))
|
|
137
|
+
expect(rowTemplates.length).toBeGreaterThan(0)
|
|
138
|
+
for (const tpl of rowTemplates) {
|
|
139
|
+
expect(tpl).toContain('<textarea')
|
|
140
|
+
expect(tpl).not.toMatch(/\bvalue=/)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('an ordinary reactive attribute on the same row IS still emitted by the builder', () => {
|
|
145
|
+
// Reverse direction, on one row so both halves are read off the same
|
|
146
|
+
// builder string: `clientOnly` is the ONLY thing now deferred. The
|
|
147
|
+
// row's own `title` / `data-n` must keep their inline emission.
|
|
148
|
+
const content = clientJs(`
|
|
149
|
+
"use client";
|
|
150
|
+
import { createSignal } from '@barefootjs/client'
|
|
151
|
+
export function MixedRow() {
|
|
152
|
+
const [val, setVal] = createSignal('a')
|
|
153
|
+
const [rows] = createSignal([1, 2])
|
|
154
|
+
return (
|
|
155
|
+
<ul>
|
|
156
|
+
{rows().filter(r => r > 0).map(r => (
|
|
157
|
+
<li key={r} title={String(r)} data-n={r}>
|
|
158
|
+
<textarea value={val()} onInput={() => setVal('b')} />
|
|
159
|
+
</li>
|
|
160
|
+
))}
|
|
161
|
+
</ul>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
`)
|
|
165
|
+
const rowTemplates = builderTemplates(content).filter(t => t.includes('<textarea'))
|
|
166
|
+
expect(rowTemplates.length).toBeGreaterThan(0)
|
|
167
|
+
for (const tpl of rowTemplates) {
|
|
168
|
+
expect(tpl).toMatch(/title=/)
|
|
169
|
+
expect(tpl).toMatch(/data-n=/)
|
|
170
|
+
expect(tpl).not.toMatch(/\bvalue=/)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
})
|
|
@@ -56,7 +56,7 @@ describe('mergeTemplateImports', () => {
|
|
|
56
56
|
expect(out).toBe("import { Foo, Baz } from 'x'\nimport type { Bar } from 'x'")
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
test('passes through and dedupes side-effect
|
|
59
|
+
test('passes through and dedupes side-effect imports, and a lone default import, by line', () => {
|
|
60
60
|
const out = mergeTemplateImports([
|
|
61
61
|
"import './a.css'",
|
|
62
62
|
"import Foo from 'foo'",
|
|
@@ -65,4 +65,44 @@ describe('mergeTemplateImports', () => {
|
|
|
65
65
|
])
|
|
66
66
|
expect(out).toBe("import './a.css'\nimport Foo from 'foo'\nimport { x } from 'm'")
|
|
67
67
|
})
|
|
68
|
+
|
|
69
|
+
// #2767 follow-up: two sibling components in a multi-component file both
|
|
70
|
+
// compile from the SAME module-scope `import cfg from 'lib'` declaration,
|
|
71
|
+
// but each component's own compiled output only lists the specifiers IT
|
|
72
|
+
// uses — so one component's output can carry `import cfg from 'lib'` and
|
|
73
|
+
// another's `import cfg, { helper } from 'lib'`. Exact-line dedup keeps
|
|
74
|
+
// BOTH (they're different strings), redeclaring `cfg` — a hard
|
|
75
|
+
// `SyntaxError`. Folding by source must collapse them into one line.
|
|
76
|
+
test('folds a default import shared across sibling components instead of redeclaring the binding', () => {
|
|
77
|
+
const out = mergeTemplateImports([
|
|
78
|
+
"import cfg from 'lib'",
|
|
79
|
+
"import cfg, { helper } from 'lib'",
|
|
80
|
+
])
|
|
81
|
+
expect(out).toBe("import cfg, { helper } from 'lib'")
|
|
82
|
+
expect((out.match(/\bcfg\b/g) ?? []).length).toBe(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('folds a default import with named specifiers arriving in the opposite order', () => {
|
|
86
|
+
const out = mergeTemplateImports([
|
|
87
|
+
"import cfg, { helperA } from 'lib'",
|
|
88
|
+
"import cfg, { helperB } from 'lib'",
|
|
89
|
+
])
|
|
90
|
+
expect(out).toBe("import cfg, { helperA, helperB } from 'lib'")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('keeps a default import separate from a differently-sourced named import', () => {
|
|
94
|
+
const out = mergeTemplateImports([
|
|
95
|
+
"import cfg from 'lib'",
|
|
96
|
+
"import { helper } from 'other-lib'",
|
|
97
|
+
])
|
|
98
|
+
expect(out).toBe("import cfg from 'lib'\nimport { helper } from 'other-lib'")
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('dedupes an identical namespace import shared across sibling components', () => {
|
|
102
|
+
const out = mergeTemplateImports([
|
|
103
|
+
"import * as NS from 'lib'",
|
|
104
|
+
"import * as NS from 'lib'",
|
|
105
|
+
])
|
|
106
|
+
expect(out).toBe("import * as NS from 'lib'")
|
|
107
|
+
})
|
|
68
108
|
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2767 follow-up: two sibling components in the SAME multi-component file
|
|
3
|
+
* compile independently from the same module-scope default import — each
|
|
4
|
+
* component's compiled client JS only lists the specifiers IT actually
|
|
5
|
+
* uses, so one component can emit `import cfg from './config'` while
|
|
6
|
+
* another emits `import cfg, { helper } from './config'` for the exact
|
|
7
|
+
* same source declaration. `compileMultipleComponents`'s client-JS merge
|
|
8
|
+
* used to dedupe import lines by EXACT STRING, which kept both — a hard
|
|
9
|
+
* `SyntaxError: Identifier 'cfg' has already been declared` in the merged
|
|
10
|
+
* `.client.js`, with zero compile diagnostics. This is an end-to-end test
|
|
11
|
+
* through the real `compileJSX` multi-component path (not the unit-level
|
|
12
|
+
* `mergeTemplateImports`/`collectExternalImports` tests, which are correct
|
|
13
|
+
* per-component but can't see this cross-component merge on their own).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, test, expect } from 'bun:test'
|
|
16
|
+
import { compileJSX } from '../compiler'
|
|
17
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
18
|
+
|
|
19
|
+
const adapter = new TestAdapter()
|
|
20
|
+
|
|
21
|
+
describe('multi-component file sharing a default import (#2767 follow-up)', () => {
|
|
22
|
+
test('merges into one import line instead of redeclaring the default binding', () => {
|
|
23
|
+
const source = `
|
|
24
|
+
'use client'
|
|
25
|
+
import cfg, { helper } from './config'
|
|
26
|
+
import { createSignal } from '@barefootjs/client'
|
|
27
|
+
|
|
28
|
+
export function CompA() {
|
|
29
|
+
const [n, setN] = createSignal(cfg.start)
|
|
30
|
+
return <button onClick={() => setN(n() + 1)}>{n()}</button>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function CompB() {
|
|
34
|
+
const [m, setM] = createSignal(cfg.start + helper())
|
|
35
|
+
return <button onClick={() => setM(m() + 1)}>{m()}</button>
|
|
36
|
+
}
|
|
37
|
+
`
|
|
38
|
+
const result = compileJSX(source, 'shared-default.tsx', { adapter })
|
|
39
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
40
|
+
expect(errors).toEqual([])
|
|
41
|
+
|
|
42
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
43
|
+
expect(clientJs).toBeDefined()
|
|
44
|
+
|
|
45
|
+
const importLines = clientJs!.content
|
|
46
|
+
.split('\n')
|
|
47
|
+
.filter(l => l.includes("from './config'"))
|
|
48
|
+
// Exactly one declaration for './config' — not one per component.
|
|
49
|
+
expect(importLines).toEqual(["import cfg, { helper } from './config'"])
|
|
50
|
+
|
|
51
|
+
// The binding is declared exactly once, not once per component.
|
|
52
|
+
const declarationCount = (clientJs!.content.match(/\bimport\s+cfg\b/g) ?? []).length
|
|
53
|
+
expect(declarationCount).toBe(1)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -48,7 +48,11 @@ export function Edges() {
|
|
|
48
48
|
const { markedTemplate, clientJs } = compile(source, 'Edges.tsx')
|
|
49
49
|
|
|
50
50
|
// SSR: per-item start marker is emitted inside the .map() body.
|
|
51
|
-
|
|
51
|
+
// `bfComment(k)` itself prepends `bf-`, so the call argument is
|
|
52
|
+
// `'loop-i'`, yielding `<!--bf-loop-i-->` (#2763 fixed a stray extra
|
|
53
|
+
// `bf-` prefix here that had gone unexercised until that issue's
|
|
54
|
+
// fixture was the first `bodyIsMultiRoot` case rendered on this adapter).
|
|
55
|
+
expect(markedTemplate.content).toContain(`bfComment('loop-i')`)
|
|
52
56
|
// Loop boundary markers also still present.
|
|
53
57
|
expect(markedTemplate.content).toMatch(/bfComment\('loop:[^']+'\)/)
|
|
54
58
|
expect(markedTemplate.content).toMatch(/bfComment\('\/loop:[^']+'\)/)
|
|
@@ -79,7 +83,7 @@ export function Items() {
|
|
|
79
83
|
const { markedTemplate, clientJs } = compile(source, 'Items.tsx')
|
|
80
84
|
|
|
81
85
|
// No per-item markers in the single-root case (zero-cost).
|
|
82
|
-
expect(markedTemplate.content).not.toContain(`bfComment('
|
|
86
|
+
expect(markedTemplate.content).not.toContain(`bfComment('loop-i')`)
|
|
83
87
|
expect(clientJs).toBeDefined()
|
|
84
88
|
expect(clientJs!.content).not.toContain('__bfExtras')
|
|
85
89
|
})
|
|
@@ -104,7 +108,7 @@ export function Items() {
|
|
|
104
108
|
}
|
|
105
109
|
`
|
|
106
110
|
const { markedTemplate, clientJs } = compile(source, 'NestedFrag.tsx')
|
|
107
|
-
expect(markedTemplate.content).not.toContain(`bfComment('
|
|
111
|
+
expect(markedTemplate.content).not.toContain(`bfComment('loop-i')`)
|
|
108
112
|
expect(clientJs).toBeDefined()
|
|
109
113
|
expect(clientJs!.content).not.toContain('__bfExtras')
|
|
110
114
|
})
|
|
@@ -205,3 +205,45 @@ export function Rows(props: { rows: Row[] }) {
|
|
|
205
205
|
expect(dslErrors(source)).toEqual([])
|
|
206
206
|
})
|
|
207
207
|
})
|
|
208
|
+
|
|
209
|
+
describe('preamble value declarations — function-literal elision (#2797)', () => {
|
|
210
|
+
// An event handler hoisted into the preamble (rather than written inline in
|
|
211
|
+
// the JSX attribute) has no template-expression form on any DSL backend —
|
|
212
|
+
// but every adapter's own SSR prop-builder already skips an event-handler
|
|
213
|
+
// prop outright, so a name read ONLY that way needs no SSR representation
|
|
214
|
+
// at all. Elided, not refused: `declarations` legally ends up `[]`.
|
|
215
|
+
test('a handler read only as an event-handler prop value elides clean, no refusal', () => {
|
|
216
|
+
const source = ROWS(`const handleClick = () => {}`, `<li key={r.id} onClick={handleClick}>{r.label}</li>`)
|
|
217
|
+
expect(loopOf(source).preamble?.declarations).toEqual([])
|
|
218
|
+
expect(dslErrors(source)).toEqual([])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('an elided handler coexists with a genuinely lowered declaration', () => {
|
|
222
|
+
const loop = loopOf(
|
|
223
|
+
ROWS(
|
|
224
|
+
`const cls = r.done ? 'done' : 'open'\n const handleClick = () => {}`,
|
|
225
|
+
`<li key={r.id} class={cls} onClick={handleClick}>{r.label}</li>`,
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
// Only the lowerable declaration survives — the elided one contributes no entry.
|
|
229
|
+
expect(loop.preamble?.declarations?.map(d => d.name)).toEqual(['cls'])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// Soundness: a function-valued local read ANYWHERE other than an
|
|
233
|
+
// event-handler prop value still refuses — elision only widens what's
|
|
234
|
+
// SAFE, it never lets an SSR-relevant function read through unassigned.
|
|
235
|
+
test('a handler also read in a non-event position still refuses', () => {
|
|
236
|
+
const errors = dslErrors(
|
|
237
|
+
ROWS(`const handleClick = () => {}`, `<li key={r.id}>{handleClick.name}</li>`),
|
|
238
|
+
)
|
|
239
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
240
|
+
expect(errors[0]).toMatch(/not a sequence of value declarations/)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('a handler passed to a NON-event child prop still refuses', () => {
|
|
244
|
+
const errors = dslErrors(
|
|
245
|
+
ROWS(`const handleClick = () => {}`, `<li key={r.id} data-fn={handleClick}>{r.label}</li>`),
|
|
246
|
+
)
|
|
247
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-root row-key relay (`IRElement.keyAttr` with no `value`) — #2753's
|
|
3
|
+
* "mechanism 2".
|
|
4
|
+
*
|
|
5
|
+
* A component's rendered root is whatever element carries `bf-s`
|
|
6
|
+
* (`needsScope`). When that component is used as a caller's keyed loop row,
|
|
7
|
+
* the caller's key has to land on THAT element: `mapArray` reconciles rows
|
|
8
|
+
* by reading the key attribute off the row's primary element, and the CSR
|
|
9
|
+
* half of the contract (`renderChild` / `materializeComponent` in
|
|
10
|
+
* `@barefootjs/client`) splices `data-key` onto the rendered markup's first
|
|
11
|
+
* element regardless of what wrapper nodes sit above it. `resolveRootKeyAttr`
|
|
12
|
+
* is the SSR half, and it must agree.
|
|
13
|
+
*
|
|
14
|
+
* The regression these tests exist for: resolving the relay by walking DOWN
|
|
15
|
+
* from the IR root and stopping at the first node that is not an
|
|
16
|
+
* element/fragment/if-statement. `<Ctx.Provider>` is neither, but
|
|
17
|
+
* `transformProviderElement` passes `ctx.isRoot` through to its children — so
|
|
18
|
+
* a provider-rooted component (select, popover, accordion, carousel,
|
|
19
|
+
* combobox, command, dropdown-menu, radio-group) has a `needsScope` element
|
|
20
|
+
* the walk never reaches, and every adapter silently stopped relaying its
|
|
21
|
+
* caller's key.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, test, expect } from 'bun:test'
|
|
24
|
+
import { analyzeComponent } from '../analyzer'
|
|
25
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
26
|
+
import type { IRElement, IRNode } from '../types'
|
|
27
|
+
|
|
28
|
+
function compile(source: string, name = 'Test'): IRNode {
|
|
29
|
+
const ir = jsxToIR(analyzeComponent(source, `${name}.tsx`))
|
|
30
|
+
expect(ir).not.toBeNull()
|
|
31
|
+
return ir!
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every `element` node in the tree, in document order. */
|
|
35
|
+
function allElements(node: IRNode): IRElement[] {
|
|
36
|
+
const out: IRElement[] = []
|
|
37
|
+
const visit = (n: IRNode | null | undefined): void => {
|
|
38
|
+
if (!n) return
|
|
39
|
+
if (n.type === 'element') out.push(n)
|
|
40
|
+
switch (n.type) {
|
|
41
|
+
case 'element':
|
|
42
|
+
case 'fragment':
|
|
43
|
+
case 'component':
|
|
44
|
+
case 'provider':
|
|
45
|
+
case 'loop':
|
|
46
|
+
for (const c of n.children) visit(c)
|
|
47
|
+
return
|
|
48
|
+
case 'async':
|
|
49
|
+
visit(n.fallback)
|
|
50
|
+
for (const c of n.children) visit(c)
|
|
51
|
+
return
|
|
52
|
+
case 'conditional':
|
|
53
|
+
visit(n.whenTrue)
|
|
54
|
+
visit(n.whenFalse)
|
|
55
|
+
return
|
|
56
|
+
case 'if-statement':
|
|
57
|
+
visit(n.consequent)
|
|
58
|
+
visit(n.alternate)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
visit(node)
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PROVIDER_ROOT = `
|
|
67
|
+
'use client'
|
|
68
|
+
import { createContext, createSignal } from '@barefootjs/client'
|
|
69
|
+
|
|
70
|
+
const SelectContext = createContext({ open: false })
|
|
71
|
+
|
|
72
|
+
export function Select(props: { children?: unknown }) {
|
|
73
|
+
const [open, setOpen] = createSignal(false)
|
|
74
|
+
return (
|
|
75
|
+
<SelectContext.Provider value={{ open: open(), setOpen }}>
|
|
76
|
+
<div data-slot="select">{props.children}</div>
|
|
77
|
+
</SelectContext.Provider>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
`
|
|
81
|
+
|
|
82
|
+
describe('render-root row-key relay (#2753)', () => {
|
|
83
|
+
test('a provider-rooted component relays the key on the element under the provider', () => {
|
|
84
|
+
const ir = compile(PROVIDER_ROOT, 'Select')
|
|
85
|
+
|
|
86
|
+
expect(ir.type).toBe('provider')
|
|
87
|
+
const div = allElements(ir).find(e => e.tag === 'div')
|
|
88
|
+
expect(div).toBeDefined()
|
|
89
|
+
// The element under the provider IS the rendered root: `ctx.isRoot`
|
|
90
|
+
// survives `transformProviderElement`.
|
|
91
|
+
expect(div!.needsScope).toBe(true)
|
|
92
|
+
// Relay marker: a name and no value — the value arrives at runtime from
|
|
93
|
+
// whoever renders this component as a keyed row.
|
|
94
|
+
expect(div!.keyAttr).toEqual({ name: 'data-key' })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test.each([
|
|
98
|
+
['plain element root', `
|
|
99
|
+
export function C() { return <div class="root"><span>x</span></div> }
|
|
100
|
+
`],
|
|
101
|
+
['early-return (if-statement) root — every branch top element', `
|
|
102
|
+
export function C(props: { on?: boolean }) {
|
|
103
|
+
if (props.on) return <section>on</section>
|
|
104
|
+
return <article>off</article>
|
|
105
|
+
}
|
|
106
|
+
`],
|
|
107
|
+
['provider root', PROVIDER_ROOT],
|
|
108
|
+
['provider wrapping an early-return root', `
|
|
109
|
+
'use client'
|
|
110
|
+
import { createContext } from '@barefootjs/client'
|
|
111
|
+
const Ctx = createContext(0)
|
|
112
|
+
export function C(props: { on?: boolean }) {
|
|
113
|
+
if (props.on) return <Ctx.Provider value={1}><section>on</section></Ctx.Provider>
|
|
114
|
+
return <Ctx.Provider value={0}><article>off</article></Ctx.Provider>
|
|
115
|
+
}
|
|
116
|
+
`],
|
|
117
|
+
['nested providers around the root element', `
|
|
118
|
+
'use client'
|
|
119
|
+
import { createContext } from '@barefootjs/client'
|
|
120
|
+
const A = createContext(0)
|
|
121
|
+
const B = createContext(0)
|
|
122
|
+
export function C() {
|
|
123
|
+
return <A.Provider value={1}><B.Provider value={2}><div>x</div></B.Provider></A.Provider>
|
|
124
|
+
}
|
|
125
|
+
`],
|
|
126
|
+
])('invariant: %s — every needsScope element carries a relay keyAttr', (_label, source) => {
|
|
127
|
+
const roots = allElements(compile(source)).filter(e => e.needsScope)
|
|
128
|
+
expect(roots.length).toBeGreaterThan(0)
|
|
129
|
+
for (const el of roots) {
|
|
130
|
+
expect({ tag: el.tag, keyAttr: el.keyAttr }).toEqual({
|
|
131
|
+
tag: el.tag,
|
|
132
|
+
keyAttr: { name: 'data-key' },
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('an inline .map() row root keeps its own resolved key expression', () => {
|
|
138
|
+
const ir = compile(`
|
|
139
|
+
export function List(props: { items: { id: number; name: string }[] }) {
|
|
140
|
+
return <ul>{props.items.map(i => <li key={i.id}>{i.name}</li>)}</ul>
|
|
141
|
+
}
|
|
142
|
+
`, 'List')
|
|
143
|
+
|
|
144
|
+
const li = allElements(ir).find(e => e.tag === 'li')
|
|
145
|
+
expect(li).toBeDefined()
|
|
146
|
+
// Mechanism 1 (a concretely-known local expression) wins over the relay
|
|
147
|
+
// marker; the relay pass must not overwrite it with a bare name.
|
|
148
|
+
expect(li!.needsScope).toBe(false)
|
|
149
|
+
expect(li!.keyAttr).toEqual({ name: 'data-key', value: 'i.id' })
|
|
150
|
+
|
|
151
|
+
const ul = allElements(ir).find(e => e.tag === 'ul')!
|
|
152
|
+
expect(ul.needsScope).toBe(true)
|
|
153
|
+
expect(ul.keyAttr).toEqual({ name: 'data-key' })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('a scope-comment fragment root marks exactly one carrier, not every child', () => {
|
|
157
|
+
const ir = compile(`
|
|
158
|
+
export function C() {
|
|
159
|
+
return <><h1>title</h1><p>body</p></>
|
|
160
|
+
}
|
|
161
|
+
`)
|
|
162
|
+
|
|
163
|
+
expect(ir.type).toBe('fragment')
|
|
164
|
+
const els = allElements(ir)
|
|
165
|
+
// #2732: hydration markers moved to the wrapping comment, so no child is
|
|
166
|
+
// a `needsScope` element and the relay pass adds nothing of its own.
|
|
167
|
+
expect(els.every(e => !e.needsScope)).toBe(true)
|
|
168
|
+
expect(els.filter(e => e.keyAttr !== undefined).map(e => e.tag)).toEqual(['h1'])
|
|
169
|
+
})
|
|
170
|
+
})
|