@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.
Files changed (101) hide show
  1. package/dist/adapters/child-scope.d.ts +17 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +890 -357
  9. package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
  15. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  21. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +14 -0
  25. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/html-template.d.ts +2 -3
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/imports.d.ts +2 -2
  30. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  32. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/reactivity.d.ts +1 -10
  34. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/types.d.ts +12 -0
  36. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  37. package/dist/jsx-to-ir.d.ts.map +1 -1
  38. package/dist/rich-type-evidence.d.ts.map +1 -1
  39. package/dist/ssr-defaults.d.ts.map +1 -1
  40. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  41. package/dist/types.d.ts +78 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/value-references.d.ts +17 -4
  44. package/dist/value-references.d.ts.map +1 -1
  45. package/package.json +2 -2
  46. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
  47. package/src/__tests__/branch-loop-plain.test.ts +28 -45
  48. package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
  49. package/src/__tests__/csr-template-scope-soundness.test.ts +96 -0
  50. package/src/__tests__/early-return-branch-switch.test.ts +96 -0
  51. package/src/__tests__/form-control-value-ssr.test.ts +102 -0
  52. package/src/__tests__/ir-to-client-js/imports.test.ts +18 -0
  53. package/src/__tests__/lazy-conditional.test.ts +190 -0
  54. package/src/__tests__/lazy-preamble.test.ts +297 -0
  55. package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
  56. package/src/__tests__/loop-item-root-scope.test.ts +213 -0
  57. package/src/__tests__/loop-row-creation-paths.test.ts +169 -0
  58. package/src/__tests__/map-multi-return-body.test.ts +30 -3
  59. package/src/__tests__/nested-loop-conditional.test.ts +27 -6
  60. package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
  61. package/src/__tests__/preamble-declarations.test.ts +207 -0
  62. package/src/__tests__/ssr-defaults.test.ts +44 -0
  63. package/src/__tests__/value-references.test.ts +88 -0
  64. package/src/adapters/child-scope.ts +22 -0
  65. package/src/adapters/loop-bound-names.ts +6 -0
  66. package/src/analyzer-context.ts +43 -0
  67. package/src/analyzer.ts +33 -3
  68. package/src/index.ts +1 -0
  69. package/src/ir-to-client-js/collect-elements.ts +51 -10
  70. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
  71. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
  72. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
  73. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
  74. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
  75. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
  76. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  77. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
  78. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
  79. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
  80. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  81. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +4 -0
  82. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
  83. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
  84. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +23 -4
  85. package/src/ir-to-client-js/csr-substitute.ts +6 -1
  86. package/src/ir-to-client-js/emit-registration.ts +1 -1
  87. package/src/ir-to-client-js/html-template.ts +51 -40
  88. package/src/ir-to-client-js/imports.ts +5 -0
  89. package/src/ir-to-client-js/index.ts +1 -1
  90. package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
  91. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
  92. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  93. package/src/ir-to-client-js/reactivity.ts +25 -0
  94. package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
  95. package/src/ir-to-client-js/types.ts +12 -0
  96. package/src/jsx-to-ir.ts +400 -24
  97. package/src/rich-type-evidence.ts +6 -7
  98. package/src/ssr-defaults.ts +7 -2
  99. package/src/to-locale-date-lowering.ts +8 -2
  100. package/src/types.ts +80 -0
  101. 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)
@@ -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) }