@barefootjs/jsx 0.17.1 → 0.18.0
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/interface.d.ts +20 -4
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/augment-inherited-props.d.ts +19 -0
- package/dist/augment-inherited-props.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +1 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +815 -251
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.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 +19 -0
- 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 +4 -2
- package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +41 -0
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +22 -1
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/loop-destructure.d.ts +55 -18
- package/dist/loop-destructure.d.ts.map +1 -1
- package/dist/lowering-registry.d.ts +13 -0
- package/dist/lowering-registry.d.ts.map +1 -1
- package/dist/relocate.d.ts +28 -0
- package/dist/relocate.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +68 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
- package/src/__tests__/augment-inherited-props.test.ts +96 -0
- package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
- package/src/__tests__/compiler-stress-1244.test.ts +13 -4
- package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
- package/src/__tests__/destructured-map-params.test.ts +11 -1
- package/src/__tests__/expression-parser.test.ts +63 -2
- package/src/__tests__/ir-sort-comparator.test.ts +261 -0
- package/src/__tests__/loop-destructure.test.ts +313 -0
- package/src/__tests__/loop-hoisted-template.test.ts +235 -0
- package/src/__tests__/props-destructuring.test.ts +110 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +70 -3
- package/src/__tests__/ssr-defaults.test.ts +20 -0
- package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
- package/src/__tests__/tagged-template-interleave.test.ts +268 -0
- package/src/__tests__/unsupported-expression.test.ts +194 -7
- package/src/adapters/interface.ts +20 -4
- package/src/adapters/parsed-expr-emitter.ts +19 -2
- package/src/analyzer-context.ts +20 -0
- package/src/analyzer.ts +74 -1
- package/src/augment-inherited-props.ts +139 -9
- package/src/compiler.ts +4 -0
- package/src/expression-parser.ts +237 -56
- package/src/index.ts +23 -1
- package/src/ir-to-client-js/collect-elements.ts +15 -1
- package/src/ir-to-client-js/compute-inlinability.ts +6 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
- package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
- package/src/ir-to-client-js/emit-registration.ts +4 -2
- package/src/ir-to-client-js/html-template.ts +198 -1
- package/src/ir-to-client-js/index.ts +1 -0
- package/src/ir-to-client-js/types.ts +22 -0
- package/src/jsx-to-ir.ts +409 -24
- package/src/loop-destructure.ts +89 -36
- package/src/lowering-registry.ts +16 -0
- package/src/relocate.ts +201 -14
- package/src/ssr-defaults.ts +34 -32
- package/src/types.ts +65 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for #2087 Phase A: `isLowerableLoopDestructure` (née
|
|
3
|
+
* `isLowerableObjectRestDestructure`) — the gate template adapters use to
|
|
4
|
+
* decide whether a `.map()` callback's destructure param can be lowered
|
|
5
|
+
* natively, versus falling back to the BF104 diagnostic.
|
|
6
|
+
*
|
|
7
|
+
* Compiles a small component to IR (`analyzeComponent` + `jsxToIR`, the same
|
|
8
|
+
* helper pattern as `ir-sort-comparator.test.ts`), locates the `IRLoop`
|
|
9
|
+
* node, and asserts the gate's verdict plus (where relevant) the structured
|
|
10
|
+
* `segments` path each binding carries.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, test, expect } from 'bun:test'
|
|
14
|
+
import { analyzeComponent } from '../analyzer'
|
|
15
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
16
|
+
import { isLowerableLoopDestructure, isLowerableObjectRestDestructure } from '../loop-destructure'
|
|
17
|
+
import { ErrorCodes } from '../errors'
|
|
18
|
+
import type { IRLoop, IRNode } from '../types'
|
|
19
|
+
|
|
20
|
+
function findFirstLoop(node: IRNode): IRLoop {
|
|
21
|
+
if (node.type === 'loop') return node
|
|
22
|
+
if (node.type === 'element' || node.type === 'fragment') {
|
|
23
|
+
for (const child of node.children) {
|
|
24
|
+
if (child.type === 'loop') return child
|
|
25
|
+
if (child.type === 'element' || child.type === 'fragment') {
|
|
26
|
+
try {
|
|
27
|
+
return findFirstLoop(child)
|
|
28
|
+
} catch {
|
|
29
|
+
// keep scanning siblings
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error('no loop node found in IR')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function compileLoop(
|
|
38
|
+
source: string,
|
|
39
|
+
targetComponentName?: string,
|
|
40
|
+
filename = 'Test.tsx',
|
|
41
|
+
): { loop: IRLoop; errors: ReturnType<typeof analyzeComponent>['errors'] } {
|
|
42
|
+
const ctx = analyzeComponent(source, filename, targetComponentName)
|
|
43
|
+
const ir = jsxToIR(ctx)
|
|
44
|
+
expect(ir).not.toBeNull()
|
|
45
|
+
return { loop: findFirstLoop(ir!), errors: ctx.errors }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('isLowerableLoopDestructure (#2087)', () => {
|
|
49
|
+
test('simple .field bindings + object-rest read via member access → true (existing behavior)', () => {
|
|
50
|
+
const { loop } = compileLoop(`
|
|
51
|
+
'use client'
|
|
52
|
+
import { createSignal } from '@barefootjs/client'
|
|
53
|
+
|
|
54
|
+
type Task = { id: string; title: string; flag: string }
|
|
55
|
+
export function RestObject() {
|
|
56
|
+
const [tasks, setTasks] = createSignal<Task[]>([])
|
|
57
|
+
return (
|
|
58
|
+
<ul onClick={() => setTasks(t => t)}>
|
|
59
|
+
{tasks().map(({ id, title, ...rest }) => (
|
|
60
|
+
<li key={id}>{title}:{rest.flag}</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
`)
|
|
66
|
+
expect(isLowerableLoopDestructure(loop)).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('array-index tuple destructure ([k, v]) → true (NEW)', () => {
|
|
70
|
+
const { loop } = compileLoop(`
|
|
71
|
+
'use client'
|
|
72
|
+
import { createSignal } from '@barefootjs/client'
|
|
73
|
+
|
|
74
|
+
type Row = readonly [string, string]
|
|
75
|
+
export function IndexPairs() {
|
|
76
|
+
const [rows, setRows] = createSignal<Row[]>([])
|
|
77
|
+
return (
|
|
78
|
+
<ul onClick={() => setRows(r => r)}>
|
|
79
|
+
{rows().map(([k, v]) => (
|
|
80
|
+
<li key={k}>{k}:{v}</li>
|
|
81
|
+
))}
|
|
82
|
+
</ul>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
`)
|
|
86
|
+
expect(isLowerableLoopDestructure(loop)).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('nested { id, cells: [head] } → true (NEW)', () => {
|
|
90
|
+
const { loop } = compileLoop(`
|
|
91
|
+
'use client'
|
|
92
|
+
import { createSignal } from '@barefootjs/client'
|
|
93
|
+
|
|
94
|
+
type Row = { id: string; cells: readonly string[] }
|
|
95
|
+
export function NestedHead() {
|
|
96
|
+
const [rows, setRows] = createSignal<Row[]>([])
|
|
97
|
+
return (
|
|
98
|
+
<ul onClick={() => setRows(r => r)}>
|
|
99
|
+
{rows().map(({ id, cells: [head] }) => (
|
|
100
|
+
<li key={id}>{head}</li>
|
|
101
|
+
))}
|
|
102
|
+
</ul>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
`)
|
|
106
|
+
expect(isLowerableLoopDestructure(loop)).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('array-rest [first, ...tail] with tail.length read → true (NEW)', () => {
|
|
110
|
+
const { loop } = compileLoop(`
|
|
111
|
+
'use client'
|
|
112
|
+
import { createSignal } from '@barefootjs/client'
|
|
113
|
+
|
|
114
|
+
export function RestTuple() {
|
|
115
|
+
const [rows, setRows] = createSignal<string[][]>([])
|
|
116
|
+
return (
|
|
117
|
+
<ul onClick={() => setRows(r => r)}>
|
|
118
|
+
{rows().map(([first, ...tail]) => (
|
|
119
|
+
<li key={first}>{first} (+{tail.length})</li>
|
|
120
|
+
))}
|
|
121
|
+
</ul>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
`)
|
|
125
|
+
expect(isLowerableLoopDestructure(loop)).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('object-rest {...rest} spread onto the loop-item root <li> → true (NEW)', () => {
|
|
129
|
+
const { loop } = compileLoop(`
|
|
130
|
+
'use client'
|
|
131
|
+
import { createSignal } from '@barefootjs/client'
|
|
132
|
+
|
|
133
|
+
type Task = { id: string; title: string; flag: string }
|
|
134
|
+
export function RestSpread() {
|
|
135
|
+
const [tasks, setTasks] = createSignal<Task[]>([])
|
|
136
|
+
return (
|
|
137
|
+
<ul onClick={() => setTasks(t => t)}>
|
|
138
|
+
{tasks().map(({ id, title, ...rest }) => (
|
|
139
|
+
<li key={id} {...rest}>{title}</li>
|
|
140
|
+
))}
|
|
141
|
+
</ul>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
`)
|
|
145
|
+
expect(isLowerableLoopDestructure(loop)).toBe(true)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('object-rest spread on a COMPONENT (<Child {...rest} />) → false', () => {
|
|
149
|
+
const { loop } = compileLoop(`
|
|
150
|
+
'use client'
|
|
151
|
+
import { createSignal } from '@barefootjs/client'
|
|
152
|
+
|
|
153
|
+
type Task = { id: string; title: string; flag: string }
|
|
154
|
+
function Child(props: { title: string; flag: string }) {
|
|
155
|
+
return <span>{props.title}</span>
|
|
156
|
+
}
|
|
157
|
+
export function RestOntoComponent() {
|
|
158
|
+
const [tasks, setTasks] = createSignal<Task[]>([])
|
|
159
|
+
return (
|
|
160
|
+
<ul onClick={() => setTasks(t => t)}>
|
|
161
|
+
{tasks().map(({ id, ...rest }) => (
|
|
162
|
+
<li key={id}><Child {...rest} /></li>
|
|
163
|
+
))}
|
|
164
|
+
</ul>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
`, 'RestOntoComponent')
|
|
168
|
+
expect(isLowerableLoopDestructure(loop)).toBe(false)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('object-rest bare value use in a text expression ({String(rest)}) → false', () => {
|
|
172
|
+
const { loop } = compileLoop(`
|
|
173
|
+
'use client'
|
|
174
|
+
import { createSignal } from '@barefootjs/client'
|
|
175
|
+
|
|
176
|
+
type Task = { id: string; title: string; flag: string }
|
|
177
|
+
export function RestBareText() {
|
|
178
|
+
const [tasks, setTasks] = createSignal<Task[]>([])
|
|
179
|
+
return (
|
|
180
|
+
<ul onClick={() => setTasks(t => t)}>
|
|
181
|
+
{tasks().map(({ id, ...rest }) => (
|
|
182
|
+
<li key={id}>{String(rest)}</li>
|
|
183
|
+
))}
|
|
184
|
+
</ul>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
`)
|
|
188
|
+
expect(isLowerableLoopDestructure(loop)).toBe(false)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('object-rest bare value use in an event handler (onClick={() => fn(rest)}) → false', () => {
|
|
192
|
+
const { loop } = compileLoop(`
|
|
193
|
+
'use client'
|
|
194
|
+
import { createSignal } from '@barefootjs/client'
|
|
195
|
+
|
|
196
|
+
type Task = { id: string; title: string; flag: string }
|
|
197
|
+
export function RestBareHandler() {
|
|
198
|
+
const [tasks, setTasks] = createSignal<Task[]>([])
|
|
199
|
+
const fn = (t: unknown) => {}
|
|
200
|
+
return (
|
|
201
|
+
<ul>
|
|
202
|
+
{tasks().map(({ id, ...rest }) => (
|
|
203
|
+
<li key={id} onClick={() => fn(rest)}>{id}</li>
|
|
204
|
+
))}
|
|
205
|
+
</ul>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
`)
|
|
209
|
+
expect(isLowerableLoopDestructure(loop)).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('.filter(...).map(({a}) => ...) chain → false', () => {
|
|
213
|
+
const { loop } = compileLoop(`
|
|
214
|
+
'use client'
|
|
215
|
+
import { createSignal } from '@barefootjs/client'
|
|
216
|
+
|
|
217
|
+
type Task = { id: string; done: boolean }
|
|
218
|
+
export function FilterChain() {
|
|
219
|
+
const [tasks, setTasks] = createSignal<Task[]>([])
|
|
220
|
+
return (
|
|
221
|
+
<ul onClick={() => setTasks(t => t)}>
|
|
222
|
+
{tasks().filter(t => !t.done).map(({ id }) => (
|
|
223
|
+
<li key={id}>{id}</li>
|
|
224
|
+
))}
|
|
225
|
+
</ul>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
`)
|
|
229
|
+
expect(isLowerableLoopDestructure(loop)).toBe(false)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('binding named __bf_item → false', () => {
|
|
233
|
+
const { loop } = compileLoop(`
|
|
234
|
+
'use client'
|
|
235
|
+
import { createSignal } from '@barefootjs/client'
|
|
236
|
+
|
|
237
|
+
export function ReservedName() {
|
|
238
|
+
const [rows, setRows] = createSignal<{ __bf_item: string }[]>([])
|
|
239
|
+
return (
|
|
240
|
+
<ul onClick={() => setRows(r => r)}>
|
|
241
|
+
{rows().map(({ __bf_item }) => (
|
|
242
|
+
<li key={__bf_item}>{__bf_item}</li>
|
|
243
|
+
))}
|
|
244
|
+
</ul>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
`)
|
|
248
|
+
expect(isLowerableLoopDestructure(loop)).toBe(false)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('computed property key still raises BF025 and produces no paramBindings (unchanged)', () => {
|
|
252
|
+
const { loop, errors } = compileLoop(`
|
|
253
|
+
'use client'
|
|
254
|
+
import { createSignal } from '@barefootjs/client'
|
|
255
|
+
|
|
256
|
+
const KEY = 'a' as const
|
|
257
|
+
export function Computed() {
|
|
258
|
+
const [items, setItems] = createSignal<Record<string, number>[]>([])
|
|
259
|
+
return (
|
|
260
|
+
<ul onClick={() => setItems(i => i)}>
|
|
261
|
+
{items().map(({ [KEY]: v }) => <li key={v}>{v}</li>)}
|
|
262
|
+
</ul>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
`)
|
|
266
|
+
const bf025 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_DESTRUCTURE_REST)
|
|
267
|
+
expect(bf025.length).toBe(1)
|
|
268
|
+
expect(bf025[0].severity).toBe('error')
|
|
269
|
+
expect(loop.paramBindings).toBeUndefined()
|
|
270
|
+
expect(isLowerableLoopDestructure(loop)).toBe(false)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('deprecated alias isLowerableObjectRestDestructure is the same function', () => {
|
|
274
|
+
expect(isLowerableObjectRestDestructure).toBe(isLowerableLoopDestructure)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('segments: nested { id, cells: [head, ...rest] } pins field/index/non-ident classification', () => {
|
|
278
|
+
const { loop } = compileLoop(`
|
|
279
|
+
'use client'
|
|
280
|
+
import { createSignal } from '@barefootjs/client'
|
|
281
|
+
|
|
282
|
+
type Row = { id: string; cells: readonly string[]; 'data-priority': string }
|
|
283
|
+
export function NestedSegments() {
|
|
284
|
+
const [rows, setRows] = createSignal<Row[]>([])
|
|
285
|
+
return (
|
|
286
|
+
<ul onClick={() => setRows(r => r)}>
|
|
287
|
+
{rows().map(({ id, cells: [head, ...rest], 'data-priority': prio }) => (
|
|
288
|
+
<li key={id}>{head}:{String(rest.length)}:{prio}</li>
|
|
289
|
+
))}
|
|
290
|
+
</ul>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
`)
|
|
294
|
+
const bindings = loop.paramBindings
|
|
295
|
+
expect(bindings).toBeDefined()
|
|
296
|
+
|
|
297
|
+
const idBinding = bindings!.find(b => b.name === 'id')
|
|
298
|
+
expect(idBinding?.segments).toEqual([{ kind: 'field', key: 'id', isIdent: true }])
|
|
299
|
+
|
|
300
|
+
const headBinding = bindings!.find(b => b.name === 'head')
|
|
301
|
+
expect(headBinding?.segments).toEqual([
|
|
302
|
+
{ kind: 'field', key: 'cells', isIdent: true },
|
|
303
|
+
{ kind: 'index', index: 0 },
|
|
304
|
+
])
|
|
305
|
+
|
|
306
|
+
const restBinding = bindings!.find(b => b.name === 'rest')
|
|
307
|
+
expect(restBinding?.rest?.kind).toBe('array')
|
|
308
|
+
expect(restBinding?.segments).toEqual([{ kind: 'field', key: 'cells', isIdent: true }])
|
|
309
|
+
|
|
310
|
+
const prioBinding = bindings!.find(b => b.name === 'prio')
|
|
311
|
+
expect(prioBinding?.segments).toEqual([{ kind: 'field', key: 'data-priority', isIdent: false }])
|
|
312
|
+
})
|
|
313
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hoisted shared-template fast path for `mapArray` loop bodies (perf).
|
|
3
|
+
*
|
|
4
|
+
* Background: the legacy emission builds a fresh `document.createElement
|
|
5
|
+
* ('template')` + interpolated `innerHTML` per row inside `renderItem`,
|
|
6
|
+
* re-parsing the same HTML shape on every iteration and re-computing
|
|
7
|
+
* `escapeText`/`escapeAttr` for values a loop-child `createEffect` is about
|
|
8
|
+
* to overwrite anyway on its eager first run (double work). When the loop
|
|
9
|
+
* body is a statically-analyzable single-root element tree whose dynamic
|
|
10
|
+
* parts are text/attribute slots already covered by a loop-child effect,
|
|
11
|
+
* `buildLoopSkeletonTemplate` (html-template.ts) builds a STATIC-ONLY
|
|
12
|
+
* skeleton once, and the stringifier (`control-flow/stringify/loop.ts`)
|
|
13
|
+
* declares it before the `mapArray` call and clones from it per row instead.
|
|
14
|
+
*
|
|
15
|
+
* These tests pin:
|
|
16
|
+
* - the fast path fires for the classic js-framework-benchmark row shape
|
|
17
|
+
* (reactive class attr + two reactive text slots + static cells)
|
|
18
|
+
* - the hoisted template contains no `${...}` interpolations and no
|
|
19
|
+
* escapeText/escapeAttr calls
|
|
20
|
+
* - the fast path refuses (falls back to the legacy per-row emission) for
|
|
21
|
+
* an inline ternary/conditional body
|
|
22
|
+
* - the fast path refuses for a loop body carrying a `{...spread}` attr
|
|
23
|
+
* - an SVG loop body either takes the fast path with the correct
|
|
24
|
+
* `<svg>`-wrapped clone, or falls back — never silently mis-namespaces
|
|
25
|
+
* - the SSR-mirror `template:` lambda is unaffected either way (still
|
|
26
|
+
* carries the fully interpolated per-row template with escaping)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, test, expect } from 'bun:test'
|
|
30
|
+
import { compileJSX } from '../compiler'
|
|
31
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
32
|
+
|
|
33
|
+
const adapter = new TestAdapter()
|
|
34
|
+
|
|
35
|
+
function compile(source: string, filename: string): { clientJs: string; template: string } {
|
|
36
|
+
const result = compileJSX(source, filename, { adapter })
|
|
37
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
38
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
39
|
+
const template = result.files.find(f => f.type === 'markedTemplate')
|
|
40
|
+
expect(clientJs).toBeDefined()
|
|
41
|
+
expect(template).toBeDefined()
|
|
42
|
+
return { clientJs: clientJs!.content, template: template!.content }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('hoisted loop-body skeleton template (perf)', () => {
|
|
46
|
+
test('fast path applies to the classic benchmark row shape', () => {
|
|
47
|
+
const source = `
|
|
48
|
+
'use client'
|
|
49
|
+
import { createSignal } from '@barefootjs/client'
|
|
50
|
+
|
|
51
|
+
interface RowData { id: number; label: string }
|
|
52
|
+
|
|
53
|
+
export function Bench() {
|
|
54
|
+
const [rows, setRows] = createSignal<RowData[]>([])
|
|
55
|
+
const [selected, setSelected] = createSignal<number>(0)
|
|
56
|
+
return (
|
|
57
|
+
<table>
|
|
58
|
+
<tbody>
|
|
59
|
+
{rows().map(row => (
|
|
60
|
+
<tr key={row.id} className={selected() === row.id ? 'danger' : ''}>
|
|
61
|
+
<td className="col-md-1">{row.id}</td>
|
|
62
|
+
<td className="col-md-4">
|
|
63
|
+
<a className="lbl" onClick={() => setSelected(row.id)}>{row.label}</a>
|
|
64
|
+
</td>
|
|
65
|
+
<td className="col-md-6"></td>
|
|
66
|
+
</tr>
|
|
67
|
+
))}
|
|
68
|
+
</tbody>
|
|
69
|
+
</table>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
`
|
|
73
|
+
const { clientJs, template } = compile(source, 'Bench.tsx')
|
|
74
|
+
|
|
75
|
+
// Hoisted declaration present, before the mapArray call.
|
|
76
|
+
const declIdx = clientJs.indexOf('.innerHTML = `<tr')
|
|
77
|
+
const mapArrayIdx = clientJs.indexOf('mapArray(')
|
|
78
|
+
expect(declIdx).toBeGreaterThan(-1)
|
|
79
|
+
expect(mapArrayIdx).toBeGreaterThan(-1)
|
|
80
|
+
expect(declIdx).toBeLessThan(mapArrayIdx)
|
|
81
|
+
|
|
82
|
+
// renderItem clones from the hoisted template instead of re-parsing
|
|
83
|
+
// innerHTML per row.
|
|
84
|
+
expect(clientJs).toMatch(/const __el = __existing \?\? __tpl_\w+\.content\.firstElementChild\.cloneNode\(true\)/)
|
|
85
|
+
|
|
86
|
+
// The hoisted template itself carries no interpolations and no dynamic
|
|
87
|
+
// `class` attribute (that's covered by the reactive-attr createEffect).
|
|
88
|
+
const declMatch = clientJs.match(/\.innerHTML = `(<tr[^\n]*<\/tr>)`/)
|
|
89
|
+
expect(declMatch).not.toBeNull()
|
|
90
|
+
const skeleton = declMatch![1]
|
|
91
|
+
expect(skeleton).not.toContain('${')
|
|
92
|
+
// The `<tr>` root's own reactive `class` attr is omitted entirely
|
|
93
|
+
// (covered by the reactive-attr createEffect) — only its static
|
|
94
|
+
// descendants (`<td class="col-md-1">`, `<a class="lbl">`) keep theirs.
|
|
95
|
+
expect(skeleton).toMatch(/^<tr data-key="" bf="\w+">/)
|
|
96
|
+
expect(skeleton).not.toContain('danger')
|
|
97
|
+
expect(skeleton).toContain('data-key=""')
|
|
98
|
+
expect(skeleton).toContain('<!--bf:')
|
|
99
|
+
// Text slots are empty between their markers.
|
|
100
|
+
expect(skeleton).toMatch(/<!--bf:s\d+--><!--\/-->/)
|
|
101
|
+
|
|
102
|
+
// Static content (the empty col-md-6 cell, the "x"-less <a> shell) is
|
|
103
|
+
// still present verbatim.
|
|
104
|
+
expect(skeleton).toContain('col-md-6')
|
|
105
|
+
|
|
106
|
+
// No escaping helpers in the `init`/renderItem body now that the per-row
|
|
107
|
+
// interpolation is gone (only the reactive createEffects remain, which
|
|
108
|
+
// use textContent/setAttribute — inherently safe). The CSR-mount
|
|
109
|
+
// `template:` lambda passed to `hydrate(...)` is a SEPARATE SSR-mirror
|
|
110
|
+
// string (unchanged by this optimization — see below), so scope the
|
|
111
|
+
// assertion to the code before that lambda.
|
|
112
|
+
const initBody = clientJs.slice(0, clientJs.indexOf('hydrate('))
|
|
113
|
+
expect(initBody).not.toContain('escapeAttr(')
|
|
114
|
+
expect(initBody).not.toContain('escapeText(')
|
|
115
|
+
|
|
116
|
+
// SSR/adapter markedTemplate output is untouched by this optimization —
|
|
117
|
+
// still the reactive `className={...}` expression verbatim (the
|
|
118
|
+
// TestAdapter emits JSX, not a string template, so there's no
|
|
119
|
+
// escapeAttr/escapeText call here — that's a CSR-string-template-only
|
|
120
|
+
// concern, checked below).
|
|
121
|
+
expect(template).toContain("className={`${selected() === row.id ? 'danger' : ''}`}")
|
|
122
|
+
|
|
123
|
+
// The `template:` lambda embedded in the CSR bundle (used for
|
|
124
|
+
// from-scratch, no-existing-DOM mounts) is a separate SSR-mirror string
|
|
125
|
+
// and stays fully interpolated + escaped, unaffected by the hoisted
|
|
126
|
+
// renderItem clone path above.
|
|
127
|
+
const csrMirror = clientJs.slice(clientJs.indexOf('hydrate('))
|
|
128
|
+
expect(csrMirror).toContain('escapeAttr(')
|
|
129
|
+
expect(csrMirror).toContain('escapeText(')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('falls back to per-row emission for an inline conditional/ternary body', () => {
|
|
133
|
+
const source = `
|
|
134
|
+
'use client'
|
|
135
|
+
import { createSignal } from '@barefootjs/client'
|
|
136
|
+
|
|
137
|
+
export function List() {
|
|
138
|
+
const [items, setItems] = createSignal<string[]>([])
|
|
139
|
+
return (
|
|
140
|
+
<ul onClick={() => setItems(prev => [...prev, 'x'])}>
|
|
141
|
+
{items().map(item => (
|
|
142
|
+
<li key={item}>
|
|
143
|
+
{item.length > 3 ? <b>{item}</b> : <i>{item}</i>}
|
|
144
|
+
</li>
|
|
145
|
+
))}
|
|
146
|
+
</ul>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
`
|
|
150
|
+
const { clientJs } = compile(source, 'List.tsx')
|
|
151
|
+
expect(clientJs).toContain('mapArray(')
|
|
152
|
+
// No hoisted-template declaration — the fast path refused.
|
|
153
|
+
expect(clientJs).not.toMatch(/__tpl_\w+ = document\.createElement\('template'\)/)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('falls back to per-row emission for a spread attribute in the loop body', () => {
|
|
157
|
+
const source = `
|
|
158
|
+
'use client'
|
|
159
|
+
import { createSignal } from '@barefootjs/client'
|
|
160
|
+
|
|
161
|
+
interface Item { id: string; extra: Record<string, string> }
|
|
162
|
+
|
|
163
|
+
export function List() {
|
|
164
|
+
const [items, setItems] = createSignal<Item[]>([])
|
|
165
|
+
return (
|
|
166
|
+
<ul onClick={() => setItems([])}>
|
|
167
|
+
{items().map(item => (
|
|
168
|
+
<li key={item.id} {...item.extra}>{item.id}</li>
|
|
169
|
+
))}
|
|
170
|
+
</ul>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
`
|
|
174
|
+
const { clientJs } = compile(source, 'SpreadList.tsx')
|
|
175
|
+
expect(clientJs).toContain('mapArray(')
|
|
176
|
+
expect(clientJs).not.toMatch(/__tpl_\w+ = document\.createElement\('template'\)/)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('SVG loop body either hoists with the correct <svg> wrap, or falls back — never mis-namespaced', () => {
|
|
180
|
+
const source = `
|
|
181
|
+
'use client'
|
|
182
|
+
import { createSignal } from '@barefootjs/client'
|
|
183
|
+
|
|
184
|
+
interface Point { id: string; x: number; y: number }
|
|
185
|
+
|
|
186
|
+
export function Dots() {
|
|
187
|
+
const [points, setPoints] = createSignal<Point[]>([])
|
|
188
|
+
return (
|
|
189
|
+
<svg onClick={() => setPoints([])}>
|
|
190
|
+
{points().map(p => (
|
|
191
|
+
<circle key={p.id} cx={p.x} cy={p.y} r="4" />
|
|
192
|
+
))}
|
|
193
|
+
</svg>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
`
|
|
197
|
+
const { clientJs } = compile(source, 'Dots.tsx')
|
|
198
|
+
expect(clientJs).toContain('mapArray(')
|
|
199
|
+
// A `<circle>` root with only dynamic attrs (all covered by reactive-attr
|
|
200
|
+
// createEffects) and a literal `r="4"` is exactly the shape the fast path
|
|
201
|
+
// proves safe — this DOES hoist, and it must wrap in a synthetic `<svg>`
|
|
202
|
+
// (mirroring `templateRootIsSvg`, #135/#1088) so the cloned node keeps the
|
|
203
|
+
// SVG namespace instead of becoming an inert `HTMLUnknownElement`.
|
|
204
|
+
const hoisted = /__tpl_\w+\.innerHTML = `<svg>(.*?)<\/svg>`/.exec(clientJs)
|
|
205
|
+
expect(hoisted).not.toBeNull()
|
|
206
|
+
expect(hoisted![1]).not.toContain('${')
|
|
207
|
+
expect(hoisted![1]).toContain('<circle')
|
|
208
|
+
expect(clientJs).toMatch(/\.content\.firstElementChild\.firstElementChild\.cloneNode\(true\)/)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('fast path refuses (conservative fallback) whenever it cannot prove the wrap safe', () => {
|
|
212
|
+
// Defensive net: whatever shape a future fixture exercises, the compiler
|
|
213
|
+
// must never emit an un-wrapped hoisted template for an SVG root (that
|
|
214
|
+
// would silently mis-namespace the clone) — either it hoists WITH the
|
|
215
|
+
// `<svg>` wrap, or it doesn't hoist at all.
|
|
216
|
+
const source = `
|
|
217
|
+
'use client'
|
|
218
|
+
import { createSignal } from '@barefootjs/client'
|
|
219
|
+
interface Seg { id: string; d: string }
|
|
220
|
+
export function Paths() {
|
|
221
|
+
const [segs, setSegs] = createSignal<Seg[]>([])
|
|
222
|
+
return (
|
|
223
|
+
<svg onClick={() => setSegs([])}>
|
|
224
|
+
{segs().map(s => (
|
|
225
|
+
<path key={s.id} d={s.d} />
|
|
226
|
+
))}
|
|
227
|
+
</svg>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
`
|
|
231
|
+
const { clientJs } = compile(source, 'Paths.tsx')
|
|
232
|
+
const unwrappedHoist = /__tpl_\w+\.innerHTML = `<path/.test(clientJs)
|
|
233
|
+
expect(unwrappedHoist).toBe(false)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -278,3 +278,113 @@ describe('Props Destructuring Warning (BF043)', () => {
|
|
|
278
278
|
expect(propsWarnings).toHaveLength(0)
|
|
279
279
|
})
|
|
280
280
|
})
|
|
281
|
+
|
|
282
|
+
describe('Destructured props keep their declared types (BF043 fixture #2150)', () => {
|
|
283
|
+
const typeOf = (ctx: ReturnType<typeof analyzeComponent>, name: string) =>
|
|
284
|
+
ctx.propsParams.find((p) => p.name === name)?.type
|
|
285
|
+
|
|
286
|
+
test('resolves scalar member types from a type-alias annotation', () => {
|
|
287
|
+
const source = `
|
|
288
|
+
type Props = {
|
|
289
|
+
value: number
|
|
290
|
+
label: string
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function Component({ value, label }: Props) {
|
|
294
|
+
return <div>{label}: {value}</div>
|
|
295
|
+
}
|
|
296
|
+
`
|
|
297
|
+
|
|
298
|
+
const ctx = analyzeComponent(source, 'Component.tsx')
|
|
299
|
+
|
|
300
|
+
expect(typeOf(ctx, 'value')).toEqual({ kind: 'primitive', primitive: 'number', raw: 'number' })
|
|
301
|
+
expect(typeOf(ctx, 'label')).toEqual({ kind: 'primitive', primitive: 'string', raw: 'string' })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('resolves member types from an inline type literal', () => {
|
|
305
|
+
const source = `
|
|
306
|
+
export function Component({ value }: { value: number }) {
|
|
307
|
+
return <div>{value}</div>
|
|
308
|
+
}
|
|
309
|
+
`
|
|
310
|
+
|
|
311
|
+
const ctx = analyzeComponent(source, 'Component.tsx')
|
|
312
|
+
|
|
313
|
+
expect(typeOf(ctx, 'value')).toEqual({ kind: 'primitive', primitive: 'number', raw: 'number' })
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('resolves member types from an interface annotation', () => {
|
|
317
|
+
const source = `
|
|
318
|
+
interface Props {
|
|
319
|
+
value: number
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function Component({ value }: Props) {
|
|
323
|
+
return <div>{value}</div>
|
|
324
|
+
}
|
|
325
|
+
`
|
|
326
|
+
|
|
327
|
+
const ctx = analyzeComponent(source, 'Component.tsx')
|
|
328
|
+
|
|
329
|
+
expect(typeOf(ctx, 'value')).toEqual({ kind: 'primitive', primitive: 'number', raw: 'number' })
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('keys the lookup on the source property name for aliased bindings', () => {
|
|
333
|
+
const source = `
|
|
334
|
+
type Props = { value: number }
|
|
335
|
+
|
|
336
|
+
export function Component({ value: v }: Props) {
|
|
337
|
+
return <div>{v}</div>
|
|
338
|
+
}
|
|
339
|
+
`
|
|
340
|
+
|
|
341
|
+
const ctx = analyzeComponent(source, 'Component.tsx')
|
|
342
|
+
|
|
343
|
+
// The local binding is 'v', but its type comes from the 'value' member.
|
|
344
|
+
expect(typeOf(ctx, 'v')).toEqual({ kind: 'primitive', primitive: 'number', raw: 'number' })
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('falls back to unknown when the param has no type annotation', () => {
|
|
348
|
+
const source = `
|
|
349
|
+
export function Component({ value }) {
|
|
350
|
+
return <div>{value}</div>
|
|
351
|
+
}
|
|
352
|
+
`
|
|
353
|
+
|
|
354
|
+
const ctx = analyzeComponent(source, 'Component.tsx')
|
|
355
|
+
|
|
356
|
+
expect(typeOf(ctx, 'value')).toEqual({ kind: 'unknown', raw: 'unknown' })
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// Deliberate scope: only required primitives are resolved. Optional and
|
|
360
|
+
// non-primitive members stay `unknown` so typed adapters keep their existing
|
|
361
|
+
// interface{}-based lowering (attribute omission, bf_flat/spread/bf_json).
|
|
362
|
+
test('leaves OPTIONAL members as unknown (preserves nillable omission)', () => {
|
|
363
|
+
const source = `
|
|
364
|
+
type Props = { value?: number }
|
|
365
|
+
|
|
366
|
+
export function Component({ value }: Props) {
|
|
367
|
+
return <div>{value}</div>
|
|
368
|
+
}
|
|
369
|
+
`
|
|
370
|
+
|
|
371
|
+
const ctx = analyzeComponent(source, 'Component.tsx')
|
|
372
|
+
|
|
373
|
+
expect(typeOf(ctx, 'value')).toEqual({ kind: 'unknown', raw: 'unknown' })
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('leaves NON-primitive members (arrays/objects) as unknown', () => {
|
|
377
|
+
const source = `
|
|
378
|
+
type Props = { rows: number[][]; meta: { id: string } }
|
|
379
|
+
|
|
380
|
+
export function Component({ rows, meta }: Props) {
|
|
381
|
+
return <div>{rows.length}{meta.id}</div>
|
|
382
|
+
}
|
|
383
|
+
`
|
|
384
|
+
|
|
385
|
+
const ctx = analyzeComponent(source, 'Component.tsx')
|
|
386
|
+
|
|
387
|
+
expect(typeOf(ctx, 'rows')).toEqual({ kind: 'unknown', raw: 'unknown' })
|
|
388
|
+
expect(typeOf(ctx, 'meta')).toEqual({ kind: 'unknown', raw: 'unknown' })
|
|
389
|
+
})
|
|
390
|
+
})
|