@barefootjs/jsx 0.29.0 → 0.30.2
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/adapters/child-scope.d.ts +17 -0
- package/dist/adapters/child-scope.d.ts.map +1 -0
- package/dist/adapters/loop-bound-names.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +990 -367
- package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +2 -3
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +1 -10
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +12 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +24 -7
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/rich-type-evidence.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts.map +1 -1
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
- package/src/__tests__/branch-loop-plain.test.ts +28 -45
- package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
- package/src/__tests__/csr-template-scope-soundness.test.ts +167 -0
- package/src/__tests__/early-return-branch-switch.test.ts +96 -0
- package/src/__tests__/form-control-value-ssr.test.ts +102 -0
- package/src/__tests__/lazy-conditional.test.ts +190 -0
- package/src/__tests__/lazy-preamble.test.ts +297 -0
- package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
- package/src/__tests__/loop-item-root-scope.test.ts +213 -0
- package/src/__tests__/map-multi-return-body.test.ts +30 -3
- package/src/__tests__/nested-loop-conditional.test.ts +27 -6
- package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
- package/src/__tests__/preamble-declarations.test.ts +207 -0
- package/src/__tests__/ssr-defaults.test.ts +44 -0
- package/src/adapters/child-scope.ts +22 -0
- package/src/adapters/loop-bound-names.ts +6 -0
- package/src/analyzer-context.ts +43 -0
- package/src/analyzer.ts +33 -3
- package/src/index.ts +1 -0
- package/src/ir-to-client-js/collect-elements.ts +51 -10
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
- package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
- package/src/ir-to-client-js/csr-substitute.ts +6 -1
- package/src/ir-to-client-js/emit-registration.ts +1 -1
- package/src/ir-to-client-js/html-template.ts +51 -40
- package/src/ir-to-client-js/index.ts +1 -1
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
- package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
- package/src/ir-to-client-js/reactivity.ts +25 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
- package/src/ir-to-client-js/types.ts +12 -0
- package/src/jsx-to-ir.ts +409 -24
- package/src/prop-rewrite.ts +149 -19
- package/src/rich-type-evidence.ts +6 -7
- package/src/ssr-defaults.ts +7 -2
- package/src/to-locale-date-lowering.ts +8 -2
- package/src/types.ts +80 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `IRComponent.loopItemRoot` / `derivesScopeFromSlot()` (#2444): a
|
|
3
|
+
* component that is the DIRECT root of a loop row gets its own randomized
|
|
4
|
+
* scope id; a component nested BELOW the row root derives its scope id
|
|
5
|
+
* from parent scope + slot, like any other slotted child. Mirrors Hono's
|
|
6
|
+
* `renderChildrenInLoop` / `renderConditional` ctx-forwarding exactly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from 'bun:test'
|
|
10
|
+
import { analyzeComponent } from '../analyzer'
|
|
11
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
12
|
+
import { compileJSX } from '../compiler'
|
|
13
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
14
|
+
import { derivesScopeFromSlot } from '../adapters/child-scope'
|
|
15
|
+
import type { IRComponent, IRElement, IRLoop, IRNode } from '../types'
|
|
16
|
+
|
|
17
|
+
const adapter = new TestAdapter()
|
|
18
|
+
|
|
19
|
+
function findLoop(node: IRNode): IRLoop {
|
|
20
|
+
if (node.type === 'loop') return node
|
|
21
|
+
if (node.type === 'element') {
|
|
22
|
+
for (const child of node.children) {
|
|
23
|
+
if (child.type === 'loop') return child
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
throw new Error('no loop found in IR')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('IRComponent.loopItemRoot (#2444)', () => {
|
|
30
|
+
test('a component nested below a plain-element loop row root is NOT a loop item root', () => {
|
|
31
|
+
const source = `
|
|
32
|
+
'use client'
|
|
33
|
+
import { createSignal } from '@barefootjs/client'
|
|
34
|
+
function Badge(props: { text: string }) {
|
|
35
|
+
return <span>{props.text}</span>
|
|
36
|
+
}
|
|
37
|
+
export function Rows(props: { items: { id: number; label: string }[] }) {
|
|
38
|
+
const [rows] = createSignal(props.items)
|
|
39
|
+
return (
|
|
40
|
+
<ul>
|
|
41
|
+
{rows().map(row => (
|
|
42
|
+
<li key={row.id}>
|
|
43
|
+
<Badge text={row.label} />
|
|
44
|
+
</li>
|
|
45
|
+
))}
|
|
46
|
+
</ul>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
`
|
|
50
|
+
const ctx = analyzeComponent(source, 'Rows.tsx', 'Rows')
|
|
51
|
+
const ir = jsxToIR(ctx)
|
|
52
|
+
expect(ir).not.toBeNull()
|
|
53
|
+
|
|
54
|
+
const loop = findLoop(ir!)
|
|
55
|
+
expect(loop.children).toHaveLength(1)
|
|
56
|
+
const li = loop.children[0] as IRElement
|
|
57
|
+
expect(li.type).toBe('element')
|
|
58
|
+
const badge = li.children.find(c => c.type === 'component') as IRComponent
|
|
59
|
+
expect(badge).toBeDefined()
|
|
60
|
+
expect(badge.loopItemRoot).not.toBe(true)
|
|
61
|
+
expect(derivesScopeFromSlot(badge)).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('a component that IS the loop row root IS a loop item root', () => {
|
|
65
|
+
const source = `
|
|
66
|
+
'use client'
|
|
67
|
+
import { createSignal } from '@barefootjs/client'
|
|
68
|
+
function Row(props: { label: string }) {
|
|
69
|
+
return <li>{props.label}</li>
|
|
70
|
+
}
|
|
71
|
+
export function Rows(props: { items: { id: number; label: string }[] }) {
|
|
72
|
+
const [rows] = createSignal(props.items)
|
|
73
|
+
return (
|
|
74
|
+
<ul>
|
|
75
|
+
{rows().map(row => (
|
|
76
|
+
<Row key={row.id} label={row.label} />
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
`
|
|
82
|
+
const ctx = analyzeComponent(source, 'Rows.tsx', 'Rows')
|
|
83
|
+
const ir = jsxToIR(ctx)
|
|
84
|
+
expect(ir).not.toBeNull()
|
|
85
|
+
|
|
86
|
+
const loop = findLoop(ir!)
|
|
87
|
+
expect(loop.children).toHaveLength(1)
|
|
88
|
+
const row = loop.children[0] as IRComponent
|
|
89
|
+
expect(row.type).toBe('component')
|
|
90
|
+
expect(row.loopItemRoot).toBe(true)
|
|
91
|
+
expect(derivesScopeFromSlot(row)).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('a component in a whole-item conditional loop body IS a loop item root (matches Hono ctx-forwarding)', () => {
|
|
95
|
+
const source = `
|
|
96
|
+
'use client'
|
|
97
|
+
import { createSignal } from '@barefootjs/client'
|
|
98
|
+
function Row(props: { label: string }) {
|
|
99
|
+
return <li>{props.label}</li>
|
|
100
|
+
}
|
|
101
|
+
export function Rows(props: { items: { id: number; label: string; show: boolean }[] }) {
|
|
102
|
+
const [rows] = createSignal(props.items)
|
|
103
|
+
return (
|
|
104
|
+
<ul>
|
|
105
|
+
{rows().map(row => row.show && <Row key={row.id} label={row.label} />)}
|
|
106
|
+
</ul>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
`
|
|
110
|
+
const ctx = analyzeComponent(source, 'Rows.tsx', 'Rows')
|
|
111
|
+
const ir = jsxToIR(ctx)
|
|
112
|
+
expect(ir).not.toBeNull()
|
|
113
|
+
|
|
114
|
+
const loop = findLoop(ir!)
|
|
115
|
+
expect(loop.children).toHaveLength(1)
|
|
116
|
+
const cond = loop.children[0]
|
|
117
|
+
expect(cond.type).toBe('conditional')
|
|
118
|
+
if (cond.type !== 'conditional') throw new Error('unreachable')
|
|
119
|
+
const row = cond.whenTrue as IRComponent
|
|
120
|
+
expect(row.type).toBe('component')
|
|
121
|
+
expect(row.loopItemRoot).toBe(true)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('a component nested inside a fragment loop body is NOT a loop item root (Hono does not forward through fragments)', () => {
|
|
125
|
+
const source = `
|
|
126
|
+
'use client'
|
|
127
|
+
import { createSignal } from '@barefootjs/client'
|
|
128
|
+
function Badge(props: { text: string }) {
|
|
129
|
+
return <span>{props.text}</span>
|
|
130
|
+
}
|
|
131
|
+
export function Rows(props: { items: { id: number; label: string }[] }) {
|
|
132
|
+
const [rows] = createSignal(props.items)
|
|
133
|
+
return (
|
|
134
|
+
<ul>
|
|
135
|
+
{rows().map(row => (
|
|
136
|
+
<>
|
|
137
|
+
<Badge key={row.id} text={row.label} />
|
|
138
|
+
</>
|
|
139
|
+
))}
|
|
140
|
+
</ul>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
`
|
|
144
|
+
const ctx = analyzeComponent(source, 'Rows.tsx', 'Rows')
|
|
145
|
+
const ir = jsxToIR(ctx)
|
|
146
|
+
expect(ir).not.toBeNull()
|
|
147
|
+
|
|
148
|
+
const loop = findLoop(ir!)
|
|
149
|
+
expect(loop.children).toHaveLength(1)
|
|
150
|
+
const fragment = loop.children[0]
|
|
151
|
+
expect(fragment.type).toBe('fragment')
|
|
152
|
+
if (fragment.type !== 'fragment') throw new Error('unreachable')
|
|
153
|
+
const badge = fragment.children.find(c => c.type === 'component') as IRComponent
|
|
154
|
+
expect(badge).toBeDefined()
|
|
155
|
+
expect(badge.loopItemRoot).not.toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('CSR template: a component nested below the row root passes its slot suffix to renderChild', () => {
|
|
159
|
+
const source = `
|
|
160
|
+
'use client'
|
|
161
|
+
import { createSignal } from '@barefootjs/client'
|
|
162
|
+
function Badge(props: { text: string }) {
|
|
163
|
+
return <span>{props.text}</span>
|
|
164
|
+
}
|
|
165
|
+
export function Rows(props: { items: { id: number; label: string }[] }) {
|
|
166
|
+
const [rows] = createSignal(props.items)
|
|
167
|
+
return (
|
|
168
|
+
<ul>
|
|
169
|
+
{rows().map(row => (
|
|
170
|
+
<li key={row.id}>
|
|
171
|
+
<Badge text={row.label} />
|
|
172
|
+
</li>
|
|
173
|
+
))}
|
|
174
|
+
</ul>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
`
|
|
178
|
+
const result = compileJSX(source, 'Rows.tsx', { adapter })
|
|
179
|
+
expect(result.errors).toHaveLength(0)
|
|
180
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
181
|
+
expect(clientJs).toBeDefined()
|
|
182
|
+
// The nested Badge must receive a slot suffix (4th renderChild arg) so
|
|
183
|
+
// it derives its scope id — a bare `renderChild('Badge', {...})` with
|
|
184
|
+
// no suffix would mean the fix regressed.
|
|
185
|
+
expect(clientJs!.content).toMatch(/renderChild\('Badge[^']*',\s*\{text:\s*row\.label\},\s*undefined,\s*'s\d+'\)/)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('CSR template: a component that IS the loop row root gets no slot suffix', () => {
|
|
189
|
+
const source = `
|
|
190
|
+
'use client'
|
|
191
|
+
import { createSignal } from '@barefootjs/client'
|
|
192
|
+
function Row(props: { label: string }) {
|
|
193
|
+
return <li>{props.label}</li>
|
|
194
|
+
}
|
|
195
|
+
export function Rows(props: { items: { id: number; label: string }[] }) {
|
|
196
|
+
const [rows] = createSignal(props.items)
|
|
197
|
+
return (
|
|
198
|
+
<ul>
|
|
199
|
+
{rows().map(row => (
|
|
200
|
+
<Row key={row.id} label={row.label} />
|
|
201
|
+
))}
|
|
202
|
+
</ul>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
`
|
|
206
|
+
const result = compileJSX(source, 'Rows.tsx', { adapter })
|
|
207
|
+
expect(result.errors).toHaveLength(0)
|
|
208
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
209
|
+
expect(clientJs).toBeDefined()
|
|
210
|
+
// Static-array loop item root: no slot suffix — it owns its own random id.
|
|
211
|
+
expect(clientJs!.content).not.toMatch(/renderChild\('row',[^)]*'s\d+'\)/)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -133,13 +133,23 @@ describe('.map() multi-return body fold (Stage 2)', () => {
|
|
|
133
133
|
})
|
|
134
134
|
})
|
|
135
135
|
|
|
136
|
-
describe('leading-const preamble
|
|
136
|
+
describe('leading-const preamble — folded on JS, lowered on DSL, gated on shape', () => {
|
|
137
137
|
const preambleBody = `{
|
|
138
138
|
const label = it.kind.toUpperCase()
|
|
139
139
|
if (it.on) return <b key={it.id}>{label}</b>
|
|
140
140
|
return <span key={it.id}>{label}</span>
|
|
141
141
|
}`
|
|
142
142
|
|
|
143
|
+
// The same fold, but with a preamble no template language can declare: a
|
|
144
|
+
// destructuring binding takes several names off one initializer, and no
|
|
145
|
+
// adapter has a single per-row local form for that. Nothing lowers, so
|
|
146
|
+
// both branches would read an unassigned name.
|
|
147
|
+
const undeclarableBody = `{
|
|
148
|
+
const { kind } = it
|
|
149
|
+
if (it.on) return <b key={it.id}>{kind}</b>
|
|
150
|
+
return <span key={it.id}>{kind}</span>
|
|
151
|
+
}`
|
|
152
|
+
|
|
143
153
|
function compileWith(source: string, dsl: boolean) {
|
|
144
154
|
const a = new TestAdapter()
|
|
145
155
|
// Model a DSL adapter: its template runtime can't run a callback body
|
|
@@ -156,15 +166,32 @@ describe('.map() multi-return body fold (Stage 2)', () => {
|
|
|
156
166
|
expect(cj.content).not.toMatch(/return <(b|span)/)
|
|
157
167
|
})
|
|
158
168
|
|
|
159
|
-
test('a DSL adapter
|
|
169
|
+
test('a DSL adapter accepts it too — the preamble lowers to a per-row local (#2447)', () => {
|
|
170
|
+
// This used to be a BF021 refusal, on the premise that a loop-local
|
|
171
|
+
// can't reach a conditional branch template. The fold puts the
|
|
172
|
+
// conditional INSIDE the loop body, so once the preamble lowers to a
|
|
173
|
+
// per-row declaration the local is in scope in both arms — see the
|
|
174
|
+
// `map-preamble-branch-body` fixture, which now renders on all nine
|
|
175
|
+
// adapters.
|
|
160
176
|
const r = compileWith(wrap(preambleBody), true)
|
|
177
|
+
expect(r.errors.filter(e => e.severity !== 'warning')).toHaveLength(0)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('a preamble that is NOT declarable still refuses with BF021 + /* @client */', () => {
|
|
181
|
+
const r = compileWith(wrap(undeclarableBody), true)
|
|
161
182
|
const bf021 = r.errors.filter(e => e.code === 'BF021')
|
|
162
183
|
expect(bf021).toHaveLength(1)
|
|
184
|
+
expect(bf021[0].message).toMatch(/preamble is not a sequence of value declarations/)
|
|
163
185
|
expect(bf021[0].suggestion?.message).toContain('@client')
|
|
164
186
|
})
|
|
165
187
|
|
|
188
|
+
test('a JS runtime runs the undeclarable preamble verbatim — no refusal', () => {
|
|
189
|
+
const r = compileWith(wrap(undeclarableBody), false)
|
|
190
|
+
expect(r.errors.filter(e => e.severity !== 'warning')).toHaveLength(0)
|
|
191
|
+
})
|
|
192
|
+
|
|
166
193
|
test('/* @client */ suppresses the DSL refusal', () => {
|
|
167
|
-
const clientSource = wrap(
|
|
194
|
+
const clientSource = wrap(undeclarableBody).replace('items.map', '/* @client */ items.map')
|
|
168
195
|
const r = compileWith(clientSource, true)
|
|
169
196
|
expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
|
|
170
197
|
})
|
|
@@ -342,14 +342,35 @@ describe('nested loops/conditionals inside mapArray (#830, #839)', () => {
|
|
|
342
342
|
// Locate the nested mapArray callback body — the inner one is the
|
|
343
343
|
// `node.children.map(child => ...)` that uses `child.id` as its key.
|
|
344
344
|
// Note: pre-O-1 the inner mapArray was emitted twice (once per
|
|
345
|
-
// SSR/CSR side of the outer renderItem) so this
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
345
|
+
// SSR/CSR side of the outer renderItem) so this used to find 2+
|
|
346
|
+
// matches. After the O-1 dedup we only need at least 1.
|
|
347
|
+
//
|
|
348
|
+
// Extracted via a balanced-PAREN scan from each `mapArray(` call site
|
|
349
|
+
// (a call is itself a single balanced-paren expression) — NOT a lazy
|
|
350
|
+
// `[\s\S]*?...\}\)\s*\}` regex, which coincidentally matched the old
|
|
351
|
+
// output only because the FIRST `}) }` byte sequence anywhere in the
|
|
352
|
+
// file happened to fall right after the inner mapArray call. Any
|
|
353
|
+
// unrelated text elsewhere containing that same 3-character sequence
|
|
354
|
+
// (e.g. the SSR template's `renderChild(..., 's1')` slot argument,
|
|
355
|
+
// #2444) silently shifted or erased the match with no relation to
|
|
356
|
+
// mapArray's actual extent — a real balanced scan can't have that failure mode.
|
|
357
|
+
const mapCalls: string[] = []
|
|
358
|
+
const mapArrayCallRe = /mapArray\(/g
|
|
359
|
+
let callMatch: RegExpExecArray | null
|
|
360
|
+
while ((callMatch = mapArrayCallRe.exec(content)) !== null) {
|
|
361
|
+
let depth = 1
|
|
362
|
+
let i = callMatch.index + callMatch[0].length
|
|
363
|
+
while (i < content.length && depth > 0) {
|
|
364
|
+
if (content[i] === '(') depth++
|
|
365
|
+
else if (content[i] === ')') depth--
|
|
366
|
+
i++
|
|
367
|
+
}
|
|
368
|
+
mapCalls.push(content.slice(callMatch.index, i))
|
|
369
|
+
}
|
|
370
|
+
expect(mapCalls.length).toBeGreaterThanOrEqual(1)
|
|
350
371
|
|
|
351
372
|
// Pick the inner mapArray. It references `child.id` as the key function.
|
|
352
|
-
const innerMapArray = mapCalls
|
|
373
|
+
const innerMapArray = mapCalls.find(m => /\(child\)\s*=>\s*String\(child\.id\)/.test(m))
|
|
353
374
|
expect(innerMapArray).toBeDefined()
|
|
354
375
|
|
|
355
376
|
// Count initChild('Checkbox', ...) occurrences inside the inner callback,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An attribute reading a `.map()` callback preamble local is reactive — the
|
|
3
|
+
* #2447 follow-up.
|
|
4
|
+
*
|
|
5
|
+
* ## What was broken
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* {rows().map(row => {
|
|
9
|
+
* const cls = row.done ? 'done' : 'open'
|
|
10
|
+
* return <li key={row.id} class={cls}>{row.label}</li>
|
|
11
|
+
* })}
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* `mapArray` reuses a row's DOM node on a same-key item update and re-runs
|
|
15
|
+
* only the wired slots. `class={cls}` was not one: `classifyReactivity` sees a
|
|
16
|
+
* bare local, not a signal or loop-param read, so it scored 'none' and the
|
|
17
|
+
* value was interpolated into the row template instead. Row 1's class stayed
|
|
18
|
+
* `open` after its item turned `done: true`, while the sibling `{row.label}`
|
|
19
|
+
* text updated normally — the child-position twin of this had already been
|
|
20
|
+
* fixed as a `preambleRegion` (#2389); the attribute position had not.
|
|
21
|
+
*
|
|
22
|
+
* ## The two halves, and why they are in different passes
|
|
23
|
+
*
|
|
24
|
+
* 1. **Phase 1 grants the slot** (`markPreambleAttrSlots`). An attribute is
|
|
25
|
+
* only wirable if its element carries a `bf` slot id, and that is decided
|
|
26
|
+
* when the element is BUILT — before the enclosing loop's preamble exists.
|
|
27
|
+
* The client-JS pass cannot grant one retroactively: the SSR template is
|
|
28
|
+
* rendered from this same IR and would not carry the marker.
|
|
29
|
+
* 2. **The client-JS pass wires it** (`collectLoopChildReactiveAttrs` +
|
|
30
|
+
* `stringifyReactiveEffects`), re-running the preamble at the TOP of the
|
|
31
|
+
* row effect so the declarations exist before the first write.
|
|
32
|
+
*
|
|
33
|
+
* ## The lazy row graph keeps the shape
|
|
34
|
+
*
|
|
35
|
+
* `lazyRowEligibility` used to refuse a binding that reads a preamble local,
|
|
36
|
+
* on the grounds that `applyOuter` could not prime a dependency the local
|
|
37
|
+
* hides. That refusal is gone: `analyzeLazyPreamble` now reports the
|
|
38
|
+
* preamble's own free identifiers and `classifyLazyBinding` substitutes them,
|
|
39
|
+
* so the binding's dependencies are what the INITIALIZER read (`selected`),
|
|
40
|
+
* and the apply bodies re-run the preamble on demand. Without that, this fix
|
|
41
|
+
* would have de-optimised the exact row the §9.5 widening was built for. The
|
|
42
|
+
* DOM behaviour is pinned in
|
|
43
|
+
* `packages/client/__tests__/runtime/lazy-row-preamble.test.ts`.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { describe, test, expect, beforeAll } from 'bun:test'
|
|
47
|
+
import { compileJSX } from '../compiler'
|
|
48
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
49
|
+
import { analyzeComponent } from '../analyzer'
|
|
50
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
51
|
+
import type { IRElement, IRLoop, IRNode } from '../types'
|
|
52
|
+
|
|
53
|
+
const ROWS = (row: string, preamble = `const cls = row.done ? 'done' : 'open'`) => `
|
|
54
|
+
'use client'
|
|
55
|
+
import { createSignal } from '@barefootjs/client'
|
|
56
|
+
type Row = { id: number; label: string; done: boolean }
|
|
57
|
+
export function Rows(props: { rows: Row[] }) {
|
|
58
|
+
const [rows, setRows] = createSignal<Row[]>(props.rows)
|
|
59
|
+
return (
|
|
60
|
+
<ul>
|
|
61
|
+
{rows().map(row => {
|
|
62
|
+
${preamble}
|
|
63
|
+
return ${row}
|
|
64
|
+
})}
|
|
65
|
+
</ul>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
`
|
|
69
|
+
|
|
70
|
+
function clientJs(source: string, file = 'Rows.tsx'): string {
|
|
71
|
+
const r = compileJSX(source, file, { adapter: new TestAdapter() })
|
|
72
|
+
const errors = r.errors.filter(e => e.severity !== 'warning')
|
|
73
|
+
if (errors.length > 0) throw new Error(errors.map(e => `${e.code}: ${e.message}`).join('\n'))
|
|
74
|
+
const js = r.files.find(f => f.type === 'clientJs')?.content
|
|
75
|
+
if (!js) throw new Error('no client JS emitted')
|
|
76
|
+
return js
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findLoop(node: IRNode | null): IRLoop | null {
|
|
80
|
+
if (!node) return null
|
|
81
|
+
if (node.type === 'loop') return node
|
|
82
|
+
const kids = (node as IRElement).children
|
|
83
|
+
if (!Array.isArray(kids)) return null
|
|
84
|
+
for (const c of kids) {
|
|
85
|
+
const hit = findLoop(c)
|
|
86
|
+
if (hit) return hit
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The `<li>` row root of the compiled loop. */
|
|
92
|
+
function rowRoot(source: string): IRElement {
|
|
93
|
+
const loop = findLoop(jsxToIR(analyzeComponent(source, 'Rows.tsx')))
|
|
94
|
+
if (!loop) throw new Error('no loop in IR')
|
|
95
|
+
return loop.children[0] as IRElement
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
beforeAll(() => {
|
|
99
|
+
// The first analyzeComponent in a process pays TS program setup on its own.
|
|
100
|
+
analyzeComponent(ROWS(`<li key={row.id}>{row.label}</li>`), 'Warmup.tsx')
|
|
101
|
+
}, 60_000)
|
|
102
|
+
|
|
103
|
+
describe('Phase 1 — the slot the attribute needs', () => {
|
|
104
|
+
test('an element whose attribute reads a preamble local gets a slot id', () => {
|
|
105
|
+
const li = rowRoot(ROWS(`<li key={row.id} class={cls}>{row.label}</li>`))
|
|
106
|
+
expect(li.slotId).not.toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('a row with no preamble-reading attribute is untouched', () => {
|
|
110
|
+
// The guard against granting slots (and shifting every later slot id) to
|
|
111
|
+
// rows that do not need one.
|
|
112
|
+
const li = rowRoot(ROWS(`<li key={row.id} class="static">{row.label}</li>`))
|
|
113
|
+
expect(li.slotId).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('`key` never counts — it is renamed to data-key and driven by keyFn', () => {
|
|
117
|
+
const li = rowRoot(ROWS(`<li key={cls}>{row.label}</li>`))
|
|
118
|
+
expect(li.slotId).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a nested element gets its own slot, not the row root', () => {
|
|
122
|
+
const li = rowRoot(ROWS(`<li key={row.id}><span class={cls}>{row.label}</span></li>`))
|
|
123
|
+
expect(li.slotId).toBeNull()
|
|
124
|
+
expect((li.children[0] as IRElement).slotId).not.toBeNull()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('client JS — the attribute is wired, and the preamble precedes it', () => {
|
|
129
|
+
const js = clientJs(ROWS(`<li key={row.id} class={cls}>{row.label}</li>`))
|
|
130
|
+
const plan = js.slice(js.indexOf('mapArrayLazy('), js.indexOf(`}, 'l0')`))
|
|
131
|
+
|
|
132
|
+
test('the attribute is written by a binding, not baked into the row template', () => {
|
|
133
|
+
const tpl = js.slice(js.indexOf('__tpl_l0.innerHTML'), js.indexOf('mapArrayLazy('))
|
|
134
|
+
expect(tpl).not.toContain('class=')
|
|
135
|
+
expect(plan).toContain(`setAttribute('class', String(__v))`)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('applyItem re-runs the preamble ahead of the write', () => {
|
|
139
|
+
const applyItem = plan.slice(plan.indexOf('applyItem:'))
|
|
140
|
+
const preambleAt = applyItem.indexOf("const cls = row().done ? 'done' : 'open'")
|
|
141
|
+
const writeAt = applyItem.indexOf(`setAttribute('class'`)
|
|
142
|
+
expect(preambleAt).toBeGreaterThanOrEqual(0)
|
|
143
|
+
expect(writeAt).toBeGreaterThan(preambleAt)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('the row stays on the lazy graph — no per-row reactive resource', () => {
|
|
147
|
+
expect(js).toContain('mapArrayLazy(')
|
|
148
|
+
expect(js).not.toMatch(/\bmapArray\(/)
|
|
149
|
+
expect(plan).not.toContain('createEffect')
|
|
150
|
+
expect(plan).not.toContain('createRoot')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('this row is item-driven only, so no applyOuter is emitted', () => {
|
|
154
|
+
// `row.done` is the only dependency. The contrast — a preamble reading an
|
|
155
|
+
// outer signal, which must reach the prime list — is
|
|
156
|
+
// `lazy-preamble.test.ts`'s `applyOuter` pair.
|
|
157
|
+
expect(plan).not.toContain('applyOuter:')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('a STATIC-ARRAY loop must NOT be wired', () => {
|
|
162
|
+
// Regression: the first cut of this change classified the attribute here
|
|
163
|
+
// too, and the emitted `static-array-child-init` `forEach` — which has no
|
|
164
|
+
// per-row body and so no preamble in scope — wrote
|
|
165
|
+
// `setAttribute('data-active', active ? …)` against a FREE VARIABLE. The
|
|
166
|
+
// ReferenceError killed the whole component's init, taking any nested child
|
|
167
|
+
// component's hydration with it. Caught by the gallery-nav e2e (the shells
|
|
168
|
+
// are exactly this shape and the badges mounted inside them vanished), and
|
|
169
|
+
// pinned here so the compiler catches it next time.
|
|
170
|
+
const NAV = `
|
|
171
|
+
'use client'
|
|
172
|
+
const NAV_ITEMS = [
|
|
173
|
+
{ key: 'a', href: '/a', label: 'A' },
|
|
174
|
+
{ key: 'b', href: '/b', label: 'B' },
|
|
175
|
+
]
|
|
176
|
+
export function Nav(props: { current: string }) {
|
|
177
|
+
return (
|
|
178
|
+
<nav>
|
|
179
|
+
{NAV_ITEMS.map(item => {
|
|
180
|
+
const active = item.key === props.current
|
|
181
|
+
return <a key={item.key} href={item.href} data-active={active ? 'true' : 'false'}>{item.label}</a>
|
|
182
|
+
})}
|
|
183
|
+
</nav>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
`
|
|
187
|
+
const js = clientJs(NAV, 'Nav.tsx')
|
|
188
|
+
|
|
189
|
+
test('no attribute effect reads the preamble local', () => {
|
|
190
|
+
// The `data-active` write must not be emitted into the init body at all:
|
|
191
|
+
// there is no scope there that declares `active`. The sibling `href`
|
|
192
|
+
// effect IS emitted, which is what makes this a targeted decline rather
|
|
193
|
+
// than the whole loop going unwired.
|
|
194
|
+
const init = js.slice(0, js.indexOf(`hydrate('Nav'`))
|
|
195
|
+
expect(init).not.toContain(`setAttribute('data-active'`)
|
|
196
|
+
expect(init).toContain(`setAttribute('href'`)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('the value stays baked into the SSR template', () => {
|
|
200
|
+
expect(js).toContain('data-active=')
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('unaffected shapes keep their emission', () => {
|
|
205
|
+
test('a loop with no preamble emits no preamble statements', () => {
|
|
206
|
+
const js = clientJs(`
|
|
207
|
+
'use client'
|
|
208
|
+
import { createSignal } from '@barefootjs/client'
|
|
209
|
+
type Row = { id: number; label: string; done: boolean }
|
|
210
|
+
export function Plain(props: { rows: Row[] }) {
|
|
211
|
+
const [rows, setRows] = createSignal<Row[]>(props.rows)
|
|
212
|
+
return <ul>{rows().map(row => <li key={row.id} class={row.done ? 'done' : 'open'}>{row.label}</li>)}</ul>
|
|
213
|
+
}
|
|
214
|
+
`, 'Plain.tsx')
|
|
215
|
+
expect(js).not.toContain('const cls =')
|
|
216
|
+
})
|
|
217
|
+
})
|