@barefootjs/jsx 0.28.1 → 0.30.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/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 +890 -357
- 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/composite-loop.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/control-flow/stringify/template-parse.d.ts +14 -0
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.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/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.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/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/dist/value-references.d.ts +17 -4
- package/dist/value-references.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 +96 -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__/ir-to-client-js/imports.test.ts +18 -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__/loop-row-creation-paths.test.ts +169 -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/__tests__/value-references.test.ts +88 -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/composite-loop.ts +4 -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/control-flow/stringify/template-parse.ts +23 -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/imports.ts +5 -0
- 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 +400 -24
- 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
- package/src/value-references.ts +26 -4
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.map()` callback preamble → backend-neutral value declarations (#2447).
|
|
3
|
+
*
|
|
4
|
+
* ## What was broken
|
|
5
|
+
*
|
|
6
|
+
* A block-body `.map()` whose preamble computes a value used in the row
|
|
7
|
+
* (`const cls = row.done ? 'done' : 'open'`) carried that preamble only as
|
|
8
|
+
* `segments` — JS text, which a template language cannot execute. Every DSL
|
|
9
|
+
* adapter emitted the row anyway, reading a name it never assigned: ERB read
|
|
10
|
+
* `v[:cls]` (an unseeded vars-Hash key), Go read `$.Cls` (a PARENT-struct
|
|
11
|
+
* field), the rest read a bare undefined local. The class rendered empty, on
|
|
12
|
+
* eight backends, with no diagnostic — the exact silent divergence the
|
|
13
|
+
* sound-or-loud invariant forbids.
|
|
14
|
+
*
|
|
15
|
+
* ## The two halves pinned here
|
|
16
|
+
*
|
|
17
|
+
* 1. **Lowerable → carried.** `MapCallbackPreamble.declarations` holds one
|
|
18
|
+
* `{ name, valueParsed }` per declaration, so each adapter emits it in its
|
|
19
|
+
* own per-row local syntax through the `ParsedExpr` door it already has.
|
|
20
|
+
* 2. **Not lowerable → loud.** Anything that is not a sequence of value
|
|
21
|
+
* declarations leaves `declarations` unset, and a DSL target refuses with
|
|
22
|
+
* the `/* @client *\/` escape rather than emitting a row that reads
|
|
23
|
+
* nothing. A JS runtime keeps running the preamble verbatim either way.
|
|
24
|
+
*
|
|
25
|
+
* All-or-nothing is the point of the shape assertions below: lowering the
|
|
26
|
+
* declarable prefix of a mixed preamble and dropping the rest would be a new
|
|
27
|
+
* silent divergence wearing the fix's clothes.
|
|
28
|
+
*
|
|
29
|
+
* The per-adapter emission is asserted end-to-end by the
|
|
30
|
+
* `loop-preamble-attr-value` conformance fixture (real ERB / Jinja / Twig /
|
|
31
|
+
* Blade / Kolon / Mojo / minijinja / Go renders vs. the JS reference), not
|
|
32
|
+
* here; this file pins the IR contract and the gate.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, test, expect, beforeAll } from 'bun:test'
|
|
36
|
+
import { analyzeComponent } from '../analyzer'
|
|
37
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
38
|
+
import { compileJSX } from '../compiler'
|
|
39
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
40
|
+
import type { IRElement, IRLoop, IRNode } from '../types'
|
|
41
|
+
|
|
42
|
+
const ROWS = (preamble: string, row = `<li key={r.id} class={cls}>{r.label}</li>`) => `
|
|
43
|
+
'use client'
|
|
44
|
+
import { createSignal } from '@barefootjs/client'
|
|
45
|
+
type Row = { id: number; label: string; done: boolean }
|
|
46
|
+
export function Rows(props: { rows: Row[] }) {
|
|
47
|
+
const [rows, setRows] = createSignal<Row[]>(props.rows)
|
|
48
|
+
return (
|
|
49
|
+
<ul>
|
|
50
|
+
{rows().map(r => {
|
|
51
|
+
${preamble}
|
|
52
|
+
return ${row}
|
|
53
|
+
})}
|
|
54
|
+
</ul>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
`
|
|
58
|
+
|
|
59
|
+
function findLoop(node: IRNode | null): IRLoop | null {
|
|
60
|
+
if (!node) return null
|
|
61
|
+
if (node.type === 'loop') return node
|
|
62
|
+
const kids = (node as IRElement).children
|
|
63
|
+
if (!Array.isArray(kids)) return null
|
|
64
|
+
for (const c of kids) {
|
|
65
|
+
const hit = findLoop(c)
|
|
66
|
+
if (hit) return hit
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function loopOf(source: string): IRLoop {
|
|
72
|
+
const loop = findLoop(jsxToIR(analyzeComponent(source, 'Rows.tsx')))
|
|
73
|
+
if (!loop) throw new Error('no loop in IR')
|
|
74
|
+
return loop
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Compile against a DSL-tier adapter (one that cannot run a callback body). */
|
|
78
|
+
function dslErrors(source: string): string[] {
|
|
79
|
+
const adapter = new TestAdapter()
|
|
80
|
+
adapter.acceptsCallbackBody = () => false
|
|
81
|
+
return compileJSX(source, 'Rows.tsx', { adapter })
|
|
82
|
+
.errors.filter(e => e.severity !== 'warning')
|
|
83
|
+
.map(e => `${e.code}: ${e.message}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The first `analyzeComponent` in a process pays the TS program setup, which
|
|
87
|
+
// alone exceeds the default per-test timeout. Pay it once, outside a test.
|
|
88
|
+
beforeAll(() => {
|
|
89
|
+
analyzeComponent(ROWS(`const warm = r.label`), 'Warmup.tsx')
|
|
90
|
+
}, 60_000)
|
|
91
|
+
|
|
92
|
+
describe('preamble value declarations — carried as neutral IR', () => {
|
|
93
|
+
test('a single ternary declaration', () => {
|
|
94
|
+
const loop = loopOf(ROWS(`const cls = r.done ? 'done' : 'open'`))
|
|
95
|
+
expect(loop.preamble?.declarations).toEqual([
|
|
96
|
+
expect.objectContaining({ name: 'cls', raw: `r.done ? 'done' : 'open'` }),
|
|
97
|
+
])
|
|
98
|
+
expect(loop.preamble?.declarations?.[0].valueParsed.kind).toBe('conditional')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('declarations keep SOURCE order, so a later one can read an earlier one', () => {
|
|
102
|
+
const loop = loopOf(`
|
|
103
|
+
'use client'
|
|
104
|
+
import { createSignal } from '@barefootjs/client'
|
|
105
|
+
type Row = { id: number; label: string; done: boolean }
|
|
106
|
+
export function Rows(props: { rows: Row[] }) {
|
|
107
|
+
const [rows, setRows] = createSignal<Row[]>(props.rows)
|
|
108
|
+
return (
|
|
109
|
+
<ul>
|
|
110
|
+
{rows().map(r => {
|
|
111
|
+
const base = r.done ? 'done' : 'open'
|
|
112
|
+
const cls = base + ' row'
|
|
113
|
+
return <li key={r.id} class={cls}>{r.label}</li>
|
|
114
|
+
})}
|
|
115
|
+
</ul>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
`)
|
|
119
|
+
expect(loop.preamble?.declarations?.map(d => d.name)).toEqual(['base', 'cls'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('several declarators in one statement each become a declaration', () => {
|
|
123
|
+
const loop = loopOf(ROWS(`const a = r.label, b = r.id`))
|
|
124
|
+
expect(loop.preamble?.declarations?.map(d => d.name)).toEqual(['a', 'b'])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('the names are loop-bound for the body, like a loop param', async () => {
|
|
128
|
+
// `collectLoopBoundNames` is what stops a same-named module const from
|
|
129
|
+
// inlining over the local the loop just declared.
|
|
130
|
+
const { collectLoopBoundNames } = await import('../adapters/loop-bound-names.ts')
|
|
131
|
+
const ctx = analyzeComponent(ROWS(`const cls = r.done ? 'done' : 'open'`), 'Rows.tsx')
|
|
132
|
+
const root = jsxToIR(ctx)
|
|
133
|
+
expect(root).not.toBeNull()
|
|
134
|
+
const names = collectLoopBoundNames({ ...ctx.componentIR!, root: root! })
|
|
135
|
+
expect(names.has('cls')).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('preamble value declarations — all-or-nothing', () => {
|
|
140
|
+
// Each of these has SOMETHING declarable in it. Carrying just that part and
|
|
141
|
+
// dropping the rest would put the missing statement's effect nowhere, with
|
|
142
|
+
// no diagnostic — so the whole preamble must decline.
|
|
143
|
+
const notDeclarable: Array<[string, string]> = [
|
|
144
|
+
['an assignment after a declaration', `let n = 0\n n = r.id`],
|
|
145
|
+
['an imperative loop', `const out = []\n for (const c of r.label) out.push(c)`],
|
|
146
|
+
['a destructuring binding', `const { label } = r`],
|
|
147
|
+
['a declaration with no initializer', `let cls\n const other = r.label`],
|
|
148
|
+
[
|
|
149
|
+
'an initializer the expression subset cannot model',
|
|
150
|
+
`const cls = (() => r.done)()`,
|
|
151
|
+
],
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
for (const [name, preamble] of notDeclarable) {
|
|
155
|
+
test(name, () => {
|
|
156
|
+
const loop = loopOf(ROWS(preamble, `<li key={r.id}>{r.label}</li>`))
|
|
157
|
+
expect(loop.preamble).toBeDefined()
|
|
158
|
+
expect(loop.preamble?.declarations).toBeUndefined()
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('the DSL gate — loud, not an empty attribute', () => {
|
|
164
|
+
test('a declarable preamble compiles clean on a DSL target', () => {
|
|
165
|
+
expect(dslErrors(ROWS(`const cls = r.done ? 'done' : 'open'`))).toEqual([])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('a non-declarable preamble refuses, with the /* @client */ escape', () => {
|
|
169
|
+
const errors = dslErrors(
|
|
170
|
+
ROWS(`let n = 0\n n = r.id`, `<li key={r.id}>{r.label}</li>`),
|
|
171
|
+
)
|
|
172
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
173
|
+
expect(errors[0]).toMatch(/not a sequence of value declarations/)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('a JS runtime runs the same preamble verbatim — no refusal', () => {
|
|
177
|
+
// The whole point of the per-backend fidelity model: the price is paid by
|
|
178
|
+
// the backend that genuinely cannot express the shape.
|
|
179
|
+
const r = compileJSX(
|
|
180
|
+
ROWS(`let n = 0\n n = r.id`, `<li key={r.id}>{r.label}</li>`),
|
|
181
|
+
'Rows.tsx',
|
|
182
|
+
{ adapter: new TestAdapter() },
|
|
183
|
+
)
|
|
184
|
+
expect(r.errors.filter(e => e.severity !== 'warning')).toEqual([])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('/* @client */ silences the DSL refusal', () => {
|
|
188
|
+
const source = `
|
|
189
|
+
'use client'
|
|
190
|
+
import { createSignal } from '@barefootjs/client'
|
|
191
|
+
type Row = { id: number; label: string; done: boolean }
|
|
192
|
+
export function Rows(props: { rows: Row[] }) {
|
|
193
|
+
const [rows, setRows] = createSignal<Row[]>(props.rows)
|
|
194
|
+
return (
|
|
195
|
+
<ul>
|
|
196
|
+
{/* @client */ rows().map(r => {
|
|
197
|
+
let n = 0
|
|
198
|
+
n = r.id
|
|
199
|
+
return <li key={r.id}>{r.label}</li>
|
|
200
|
+
})}
|
|
201
|
+
</ul>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
`
|
|
205
|
+
expect(dslErrors(source)).toEqual([])
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -102,6 +102,50 @@ describe('extractSsrDefaults', () => {
|
|
|
102
102
|
expect(defaults?.n).toEqual({ value: 0 })
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
test('aliased destructured prop (#2460): `propName` is the CALLER-facing key, not the local binding', () => {
|
|
106
|
+
// `{ n: count }` — the caller passes `n`, the template variable (and
|
|
107
|
+
// stash entry key) is the local binding `count`. The manifest
|
|
108
|
+
// consumer (`_derive_stash_from_defaults` in the Perl runtime) reads
|
|
109
|
+
// the caller's props by `propName`, so `propName` must be `n`, not
|
|
110
|
+
// `count` — using the local name here silently drops the caller's
|
|
111
|
+
// value (the stash entry falls back to the static `value`, `null`).
|
|
112
|
+
const metadata = metadataFor(`
|
|
113
|
+
function Badge({ text, n: count }: { text: string; n: number }) {
|
|
114
|
+
return <span>{text}:{count}</span>
|
|
115
|
+
}
|
|
116
|
+
`)
|
|
117
|
+
|
|
118
|
+
const defaults = extractSsrDefaults(metadata)
|
|
119
|
+
expect(defaults).toBeDefined()
|
|
120
|
+
// Keyed by the LOCAL binding (the template variable name)...
|
|
121
|
+
expect(defaults?.count).toEqual({ propName: 'n', value: null })
|
|
122
|
+
// ...but `propName` is the CALLER-facing key.
|
|
123
|
+
expect(defaults?.text).toEqual({ propName: 'text', value: null })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('aliased destructured prop with a default (#2460): `propName` still resolves to the source key', () => {
|
|
127
|
+
const metadata = metadataFor(`
|
|
128
|
+
function Badge({ n: count = 7 }: { n?: number }) {
|
|
129
|
+
return <span>{count}</span>
|
|
130
|
+
}
|
|
131
|
+
`)
|
|
132
|
+
|
|
133
|
+
const defaults = extractSsrDefaults(metadata)
|
|
134
|
+
expect(defaults?.count).toEqual({ propName: 'n', value: 7 })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('un-aliased destructured prop (regression): `propName` equals the local name', () => {
|
|
138
|
+
const metadata = metadataFor(`
|
|
139
|
+
function Badge({ text, n }: { text: string; n: number }) {
|
|
140
|
+
return <span>{text}:{n}</span>
|
|
141
|
+
}
|
|
142
|
+
`)
|
|
143
|
+
|
|
144
|
+
const defaults = extractSsrDefaults(metadata)
|
|
145
|
+
expect(defaults?.n).toEqual({ propName: 'n', value: null })
|
|
146
|
+
expect(defaults?.text).toEqual({ propName: 'text', value: null })
|
|
147
|
+
})
|
|
148
|
+
|
|
105
149
|
test('memo derived from a signal evaluates through the chain', () => {
|
|
106
150
|
const metadata = metadataFor(`
|
|
107
151
|
'use client'
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `packages/jsx/src/value-references.ts` — the single-door
|
|
3
|
+
* "is this identifier a VALUE reference" classifier shared by
|
|
4
|
+
* `collectExternalImports` (jsx) and `detectStrippedReferences` (cli).
|
|
5
|
+
*
|
|
6
|
+
* There was no direct test file for this module before this one. It was
|
|
7
|
+
* added as a follow-up to #2432: a Copilot review on the original PR
|
|
8
|
+
* flagged that `ts.PropertyDeclaration` (class field) names were not
|
|
9
|
+
* excluded, so a class field like `helper` in `class C { helper = 1 }`
|
|
10
|
+
* was misclassified as a value reference. Harmless for the jsx-side
|
|
11
|
+
* caller (an extra kept import), but a false BF053 build error for the
|
|
12
|
+
* cli-side caller when the same name is a stripped import binding.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, test, expect } from 'bun:test'
|
|
16
|
+
import { collectValueReferencedNames } from '../value-references'
|
|
17
|
+
|
|
18
|
+
describe('collectValueReferencedNames', () => {
|
|
19
|
+
test('class field name only is NOT a reference', () => {
|
|
20
|
+
const result = collectValueReferencedNames('class C { helper = 1 }')
|
|
21
|
+
expect(result).not.toBeNull()
|
|
22
|
+
expect(result?.has('helper')).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('static class field name only is NOT a reference', () => {
|
|
26
|
+
const result = collectValueReferencedNames('class C { static helper = 1 }')
|
|
27
|
+
expect(result).not.toBeNull()
|
|
28
|
+
expect(result?.has('helper')).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('computed class field name IS a reference (does not over-exclude)', () => {
|
|
32
|
+
const result = collectValueReferencedNames('class C { [helper] = 1 }')
|
|
33
|
+
expect(result).not.toBeNull()
|
|
34
|
+
expect(result?.has('helper')).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('class method name is NOT a reference', () => {
|
|
38
|
+
const result = collectValueReferencedNames('class C { helper() {} }')
|
|
39
|
+
expect(result).not.toBeNull()
|
|
40
|
+
expect(result?.has('helper')).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('object-literal key is NOT a reference', () => {
|
|
44
|
+
const result = collectValueReferencedNames('const o = { helper: 1 };')
|
|
45
|
+
expect(result).not.toBeNull()
|
|
46
|
+
expect(result?.has('helper')).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('property access name is NOT a reference', () => {
|
|
50
|
+
const result = collectValueReferencedNames('obj.helper();')
|
|
51
|
+
expect(result).not.toBeNull()
|
|
52
|
+
expect(result?.has('helper')).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('shorthand property IS a reference (reads the binding)', () => {
|
|
56
|
+
const result = collectValueReferencedNames('const o = { helper };')
|
|
57
|
+
expect(result).not.toBeNull()
|
|
58
|
+
expect(result?.has('helper')).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('a genuine call IS a reference', () => {
|
|
62
|
+
const result = collectValueReferencedNames('helper();')
|
|
63
|
+
expect(result).not.toBeNull()
|
|
64
|
+
expect(result?.has('helper')).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('new.target: "target" is NOT a reference', () => {
|
|
68
|
+
const result = collectValueReferencedNames('function f() { return new.target; }')
|
|
69
|
+
expect(result).not.toBeNull()
|
|
70
|
+
expect(result?.has('target')).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Both halves of the MetaProperty exclusion are pinned: `new.target`
|
|
74
|
+
// above, and `import.meta` here — the one Copilot flagged as uncovered
|
|
75
|
+
// on #2439. `import.meta.url` also incidentally covers the
|
|
76
|
+
// property-access half (`url` is excluded too).
|
|
77
|
+
test('import.meta: "meta" is NOT a reference', () => {
|
|
78
|
+
const result = collectValueReferencedNames('const u = import.meta.url;')
|
|
79
|
+
expect(result).not.toBeNull()
|
|
80
|
+
expect(result?.has('meta')).toBe(false)
|
|
81
|
+
expect(result?.has('url')).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('unparseable text returns null ("cannot answer")', () => {
|
|
85
|
+
const result = collectValueReferencedNames('const x = ;;; {{{ ]]]')
|
|
86
|
+
expect(result).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a child component derives its `bf-s` scope id from its parent
|
|
3
|
+
* scope + mount slot (`${parentScopeId}_${slotId}`) rather than getting a
|
|
4
|
+
* freshly randomized one (`${name}_${random}`).
|
|
5
|
+
*
|
|
6
|
+
* A component derives from its slot unless it is the DIRECT root of a loop
|
|
7
|
+
* row (`IRComponent.loopItemRoot`, set once in `jsx-to-ir.ts`'s loop
|
|
8
|
+
* builder) — a row root owns its own per-row identity, matching Hono's
|
|
9
|
+
* reference behaviour (#2444). Every backend (Hono, each DSL adapter, and
|
|
10
|
+
* both CSR template emitters) MUST consult this single predicate instead of
|
|
11
|
+
* re-deriving the fact from a mutable "am I inside a loop" flag, which is
|
|
12
|
+
* what caused #2444: such flags stay true for an entire loop subtree and
|
|
13
|
+
* can't distinguish a row root from a component nested below it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { IRComponent } from '../types.ts'
|
|
17
|
+
|
|
18
|
+
export function derivesScopeFromSlot(
|
|
19
|
+
comp: Pick<IRComponent, 'slotId' | 'loopItemRoot'>
|
|
20
|
+
): boolean {
|
|
21
|
+
return comp.slotId != null && comp.loopItemRoot !== true
|
|
22
|
+
}
|
|
@@ -50,6 +50,12 @@ export function collectLoopBoundNames(ir: ComponentIR): Set<string> {
|
|
|
50
50
|
// param (which may differ from the map callback's `param`) before
|
|
51
51
|
// any rename to the loop param happens.
|
|
52
52
|
if (node.filterPredicate) names.add(node.filterPredicate.param)
|
|
53
|
+
// A `.map()` callback preamble's declarations lower to per-row locals
|
|
54
|
+
// in the loop body on every DSL adapter (#2447), so a preamble name is
|
|
55
|
+
// bound in body scope exactly like a param — and must get the same
|
|
56
|
+
// exclusion, or a same-named module const would inline over the local
|
|
57
|
+
// the loop just declared.
|
|
58
|
+
for (const name of node.preamble?.declaredNames ?? []) names.add(name)
|
|
53
59
|
for (const child of node.children) visit(child)
|
|
54
60
|
if (node.childComponent) {
|
|
55
61
|
for (const child of node.childComponent.children) visit(child)
|
package/src/analyzer-context.ts
CHANGED
|
@@ -448,6 +448,49 @@ export function typeNodeToTypeInfo(
|
|
|
448
448
|
return { kind: 'array', raw, elementType: recurse(typeNode.elementType) }
|
|
449
449
|
}
|
|
450
450
|
|
|
451
|
+
// Literal types (`'a'`, `42`, `-1`, `true`, `null`) — including as union
|
|
452
|
+
// members, which is where the absence of this arm hurt: `'a' | 'b'`
|
|
453
|
+
// members fell to `{kind:'unknown', raw:"'a'"}`, forcing every consumer
|
|
454
|
+
// that needed the member's family or value to re-parse `raw` with a
|
|
455
|
+
// regex (the Go adapter's literal-union collapse, #2324's
|
|
456
|
+
// `unionMemberLiteral`, `rich-type-evidence`'s nullish-arm match — all
|
|
457
|
+
// three carried one). A literal is structurally a primitive of its
|
|
458
|
+
// family carrying its value; consumers that only care about the family
|
|
459
|
+
// see a plain primitive, and the value rides along in `literalValue`
|
|
460
|
+
// for the ones that need it. `null` inside a union arrives as
|
|
461
|
+
// `LiteralTypeNode(NullKeyword)` — NOT the bare `NullKeyword` the
|
|
462
|
+
// switch above catches — which is exactly why nullish union arms were
|
|
463
|
+
// invisible before.
|
|
464
|
+
if (ts.isLiteralTypeNode(typeNode)) {
|
|
465
|
+
const lit = typeNode.literal
|
|
466
|
+
if (ts.isStringLiteral(lit) || ts.isNoSubstitutionTemplateLiteral(lit)) {
|
|
467
|
+
return { kind: 'primitive', raw, primitive: 'string', literalValue: lit.text }
|
|
468
|
+
}
|
|
469
|
+
if (ts.isNumericLiteral(lit)) {
|
|
470
|
+
return { kind: 'primitive', raw, primitive: 'number', literalValue: lit.text }
|
|
471
|
+
}
|
|
472
|
+
if (
|
|
473
|
+
ts.isPrefixUnaryExpression(lit) &&
|
|
474
|
+
lit.operator === ts.SyntaxKind.MinusToken &&
|
|
475
|
+
ts.isNumericLiteral(lit.operand)
|
|
476
|
+
) {
|
|
477
|
+
return { kind: 'primitive', raw, primitive: 'number', literalValue: `-${lit.operand.text}` }
|
|
478
|
+
}
|
|
479
|
+
if (lit.kind === ts.SyntaxKind.TrueKeyword || lit.kind === ts.SyntaxKind.FalseKeyword) {
|
|
480
|
+
return {
|
|
481
|
+
kind: 'primitive',
|
|
482
|
+
raw,
|
|
483
|
+
primitive: 'boolean',
|
|
484
|
+
literalValue: lit.kind === ts.SyntaxKind.TrueKeyword ? 'true' : 'false',
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (lit.kind === ts.SyntaxKind.NullKeyword) {
|
|
488
|
+
return { kind: 'primitive', raw, primitive: 'null' }
|
|
489
|
+
}
|
|
490
|
+
// BigInt and anything newer: opaque, as before.
|
|
491
|
+
return { kind: 'unknown', raw }
|
|
492
|
+
}
|
|
493
|
+
|
|
451
494
|
// Union types
|
|
452
495
|
if (ts.isUnionTypeNode(typeNode)) {
|
|
453
496
|
return { kind: 'union', raw, unionTypes: typeNode.types.map(recurse) }
|