@barefootjs/jsx 0.17.0 → 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/env-signal.d.ts +42 -7
- package/dist/adapters/env-signal.d.ts.map +1 -1
- 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 +48 -2
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +24 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1728 -969
- 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/ssr-seed-plan.d.ts +84 -0
- package/dist/ssr-seed-plan.d.ts.map +1 -0
- package/dist/types.d.ts +79 -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 +74 -3
- package/src/__tests__/free-identifiers.test.ts +55 -0
- 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__/materialize-getter-calls.test.ts +58 -0
- package/src/__tests__/props-destructuring.test.ts +110 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +88 -2
- package/src/__tests__/ssr-defaults.test.ts +20 -0
- package/src/__tests__/ssr-seed-plan.test.ts +212 -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/env-signal.ts +57 -9
- 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 +10 -1
- package/src/expression-parser.ts +421 -50
- package/src/index.ts +30 -3
- 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/ssr-seed-plan.ts +146 -0
- package/src/types.ts +76 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { materializeGetterCalls, parseExpression, stringifyParsedExpr } from '../expression-parser'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `materializeGetterCalls` rewrites a zero-arg getter call (`tag()`) into the
|
|
6
|
+
* bare identifier (`tag`) wherever the callee name is in the caller-supplied
|
|
7
|
+
* set — the SSR seed/constructor-context reduction described on the function
|
|
8
|
+
* (a getter call reads the already-seeded value, so `tag()` IS `tag` there).
|
|
9
|
+
* These tests pin: the rewrite itself, the two guards that must NOT rewrite
|
|
10
|
+
* (a call with args; a name outside the set), and that it recurses into a
|
|
11
|
+
* realistic `.filter` predicate without touching the unrelated `.includes`
|
|
12
|
+
* receiver path. (#2076 follow-up — Package A)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
describe('materializeGetterCalls', () => {
|
|
16
|
+
test('rewrites a zero-arg call whose callee is in `names` to a bare identifier', () => {
|
|
17
|
+
const rewritten = materializeGetterCalls(parseExpression('tag()'), new Set(['tag']))
|
|
18
|
+
expect(rewritten).toEqual({ kind: 'identifier', name: 'tag' })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('leaves a call WITH args untouched, even when the callee is in `names`', () => {
|
|
22
|
+
const rewritten = materializeGetterCalls(parseExpression('tag(1)'), new Set(['tag']))
|
|
23
|
+
expect(stringifyParsedExpr(rewritten)).toBe('tag(1)')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('leaves a call untouched when the callee name is not in `names`', () => {
|
|
27
|
+
const rewritten = materializeGetterCalls(parseExpression('tag()'), new Set(['other']))
|
|
28
|
+
expect(stringifyParsedExpr(rewritten)).toBe('tag()')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('recurses into a filter predicate: both `tag()` occurrences rewrite, `p.tags.includes(...)` receiver is untouched', () => {
|
|
32
|
+
const body = parseExpression('!tag() || p.tags.includes(tag())')
|
|
33
|
+
const rewritten = materializeGetterCalls(body, new Set(['tag']))
|
|
34
|
+
expect(rewritten).toEqual({
|
|
35
|
+
kind: 'logical',
|
|
36
|
+
op: '||',
|
|
37
|
+
left: { kind: 'unary', op: '!', argument: { kind: 'identifier', name: 'tag' } },
|
|
38
|
+
right: {
|
|
39
|
+
kind: 'array-method',
|
|
40
|
+
method: 'includes',
|
|
41
|
+
object: {
|
|
42
|
+
kind: 'member',
|
|
43
|
+
object: { kind: 'identifier', name: 'p' },
|
|
44
|
+
property: 'tags',
|
|
45
|
+
computed: false,
|
|
46
|
+
},
|
|
47
|
+
args: [{ kind: 'identifier', name: 'tag' }],
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('does not mutate the input tree', () => {
|
|
53
|
+
const original = parseExpression('tag()')
|
|
54
|
+
const snapshot = JSON.stringify(original)
|
|
55
|
+
materializeGetterCalls(original, new Set(['tag']))
|
|
56
|
+
expect(JSON.stringify(original)).toBe(snapshot)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -121,8 +121,10 @@ describe('serializeParsedExpr', () => {
|
|
|
121
121
|
test('purity gate: a folded method call in the body → null', () => {
|
|
122
122
|
// `.toUpperCase()` folds to `array-method`, outside the evaluator surface.
|
|
123
123
|
expect(serializeParsedExpr(parseExpression('item.name.toUpperCase()'))).toBeNull()
|
|
124
|
-
// A higher-order call
|
|
125
|
-
|
|
124
|
+
// A higher-order call OTHER than `.map`/`.filter` (#2094 widens only
|
|
125
|
+
// those two) still folds — `.some` returns a boolean-from-search, not a
|
|
126
|
+
// per-element transform, so it has no evaluator lowering.
|
|
127
|
+
expect(serializeParsedExpr(parseExpression('item.tags.some(t => t)'))).toBeNull()
|
|
126
128
|
// An unsupported shape.
|
|
127
129
|
expect(serializeParsedExpr(parseExpression('a instanceof B'))).toBeNull()
|
|
128
130
|
})
|
|
@@ -149,6 +151,85 @@ describe('serializeParsedExpr', () => {
|
|
|
149
151
|
expect(serializeParsedExpr(parseExpression('Boolean(item.s)'))).not.toBeNull()
|
|
150
152
|
})
|
|
151
153
|
|
|
154
|
+
test('`.includes(x)` array-method serializes (the one array-method the evaluator executes)', () => {
|
|
155
|
+
expect(evalJSON("item.tags.includes('go')")).toEqual({
|
|
156
|
+
kind: 'array-method',
|
|
157
|
+
method: 'includes',
|
|
158
|
+
object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
|
|
159
|
+
args: [{ kind: 'literal', value: 'go' }],
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('every other array-method still folds outside the evaluator surface', () => {
|
|
164
|
+
// `.join` is now serializable (#2094) — see the `.join` describe block
|
|
165
|
+
// below. `.slice` / `.flat` are not.
|
|
166
|
+
expect(serializeParsedExpr(parseExpression('item.tags.slice(0, 1)'))).toBeNull()
|
|
167
|
+
expect(serializeParsedExpr(parseExpression('item.tags.flat()'))).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ----- #2094: nested `.map` / `.filter` / `.join` inside a callback body --
|
|
171
|
+
test('`.join(sep?)` array-method serializes', () => {
|
|
172
|
+
expect(evalJSON('item.tags.join(",")')).toEqual({
|
|
173
|
+
kind: 'array-method',
|
|
174
|
+
method: 'join',
|
|
175
|
+
object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
|
|
176
|
+
args: [{ kind: 'literal', value: ',' }],
|
|
177
|
+
})
|
|
178
|
+
// No-arg form keeps an empty `args` (the evaluator defaults the separator).
|
|
179
|
+
expect(evalJSON('item.tags.join()')).toEqual({
|
|
180
|
+
kind: 'array-method',
|
|
181
|
+
method: 'join',
|
|
182
|
+
object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
|
|
183
|
+
args: [],
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('nested `.map(cb)` serializes as an ordinary call/member/arrow — NOT a bespoke wrapper', () => {
|
|
188
|
+
// Deliberately the SAME shape a top-level `.filter(t => t)` parses to —
|
|
189
|
+
// this is what lets the `eval-vectors.json` corpus (which carries the
|
|
190
|
+
// genuine `ParsedExpr`, unfiltered by `toEvalNode`) exercise the exact
|
|
191
|
+
// encoding the compiled-template embedded body uses.
|
|
192
|
+
expect(evalJSON("item.tags.map(t => '#' + t)")).toEqual({
|
|
193
|
+
kind: 'call',
|
|
194
|
+
callee: {
|
|
195
|
+
kind: 'member',
|
|
196
|
+
object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
|
|
197
|
+
property: 'map',
|
|
198
|
+
computed: false,
|
|
199
|
+
},
|
|
200
|
+
args: [
|
|
201
|
+
{
|
|
202
|
+
kind: 'arrow',
|
|
203
|
+
params: ['t'],
|
|
204
|
+
body: {
|
|
205
|
+
kind: 'binary',
|
|
206
|
+
op: '+',
|
|
207
|
+
left: { kind: 'literal', value: '#' },
|
|
208
|
+
right: { kind: 'identifier', name: 't' },
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('nested `.filter(cb)` composed with `.length` and a comparison (the doc/#2038 shape) serializes', () => {
|
|
216
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => t.active).length > 0'))).not.toBeNull()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('a 2-param nested arrow `(item, index)` serializes', () => {
|
|
220
|
+
expect(serializeParsedExpr(parseExpression('item.tags.map((t, i) => t.n + i)'))).not.toBeNull()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('nested `.some`/`.find`/`.every`/`.sort`/`.reduce`/`.flat`/`.flatMap` still fold to null', () => {
|
|
224
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.some(p => p === t))'))).toBeNull()
|
|
225
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.find(p => p === t))'))).toBeNull()
|
|
226
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.every(p => p === t))'))).toBeNull()
|
|
227
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.sort((a, b) => a - b).length > 0)'))).toBeNull()
|
|
228
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.reduce((a, b) => a + b, 0) > 0)'))).toBeNull()
|
|
229
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.flat().length > 0)'))).toBeNull()
|
|
230
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.flatMap(p => p).length > 0)'))).toBeNull()
|
|
231
|
+
})
|
|
232
|
+
|
|
152
233
|
test('a computed member value carries `computed: true` (plain access omits it)', () => {
|
|
153
234
|
// `row['price']` folds to a computed `member`; the flag is preserved so a
|
|
154
235
|
// computed member stays distinguishable. (`row.price` carries no `computed`.)
|
|
@@ -201,4 +282,9 @@ describe('freeVarsInBody', () => {
|
|
|
201
282
|
const body = parseExpression('Math.max(a, factor) + Number(label) + String(x) + Boolean(flag)')
|
|
202
283
|
expect(freeVarsInBody(body, new Set(['a']))).toEqual(['factor', 'flag', 'label', 'x'])
|
|
203
284
|
})
|
|
285
|
+
|
|
286
|
+
test('`.includes(x)` array-method: both the receiver and the needle are free vars', () => {
|
|
287
|
+
const body = parseExpression('!tag || p.tags.includes(tag)')
|
|
288
|
+
expect(freeVarsInBody(body, new Set())).toEqual(['p', 'tag'])
|
|
289
|
+
})
|
|
204
290
|
})
|
|
@@ -82,6 +82,26 @@ describe('extractSsrDefaults', () => {
|
|
|
82
82
|
expect(defaults?.initial).toEqual({ propName: 'initial', value: null })
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
test('seeds every declared bare-props prop, including direct template reads (#2126)', () => {
|
|
86
|
+
// Template-stash adapters flatten `props.label` to the bare scalar
|
|
87
|
+
// `$label` even when no signal/memo references it (`<%= $label %>` in
|
|
88
|
+
// the body), so a prop the caller forgets to pass must still exist in
|
|
89
|
+
// the stash. Every prop declared on the props type gets an entry —
|
|
90
|
+
// not just the ones a signal / memo initializer reads.
|
|
91
|
+
const metadata = metadataFor(`
|
|
92
|
+
'use client'
|
|
93
|
+
import { createSignal } from '@barefootjs/client'
|
|
94
|
+
function Greeting(props: { label?: string }) {
|
|
95
|
+
const [n, setN] = createSignal(0)
|
|
96
|
+
return <div><p>{props.label}</p><button onClick={() => setN(n() + 1)}>{n()}</button></div>
|
|
97
|
+
}
|
|
98
|
+
`)
|
|
99
|
+
|
|
100
|
+
const defaults = extractSsrDefaults(metadata)
|
|
101
|
+
expect(defaults?.label).toEqual({ propName: 'label', value: null })
|
|
102
|
+
expect(defaults?.n).toEqual({ value: 0 })
|
|
103
|
+
})
|
|
104
|
+
|
|
85
105
|
test('memo derived from a signal evaluates through the chain', () => {
|
|
86
106
|
const metadata = metadataFor(`
|
|
87
107
|
'use client'
|