@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,213 @@
1
+ /**
2
+ * `IRComponent.loopItemRoot` / `derivesScopeFromSlot()` (#2444): a
3
+ * component that is the DIRECT root of a loop row gets its own randomized
4
+ * scope id; a component nested BELOW the row root derives its scope id
5
+ * from parent scope + slot, like any other slotted child. Mirrors Hono's
6
+ * `renderChildrenInLoop` / `renderConditional` ctx-forwarding exactly.
7
+ */
8
+
9
+ import { describe, test, expect } from 'bun:test'
10
+ import { analyzeComponent } from '../analyzer'
11
+ import { jsxToIR } from '../jsx-to-ir'
12
+ import { compileJSX } from '../compiler'
13
+ import { TestAdapter } from '../adapters/test-adapter'
14
+ import { derivesScopeFromSlot } from '../adapters/child-scope'
15
+ import type { IRComponent, IRElement, IRLoop, IRNode } from '../types'
16
+
17
+ const adapter = new TestAdapter()
18
+
19
+ function findLoop(node: IRNode): IRLoop {
20
+ if (node.type === 'loop') return node
21
+ if (node.type === 'element') {
22
+ for (const child of node.children) {
23
+ if (child.type === 'loop') return child
24
+ }
25
+ }
26
+ throw new Error('no loop found in IR')
27
+ }
28
+
29
+ describe('IRComponent.loopItemRoot (#2444)', () => {
30
+ test('a component nested below a plain-element loop row root is NOT a loop item root', () => {
31
+ const source = `
32
+ 'use client'
33
+ import { createSignal } from '@barefootjs/client'
34
+ function Badge(props: { text: string }) {
35
+ return <span>{props.text}</span>
36
+ }
37
+ export function Rows(props: { items: { id: number; label: string }[] }) {
38
+ const [rows] = createSignal(props.items)
39
+ return (
40
+ <ul>
41
+ {rows().map(row => (
42
+ <li key={row.id}>
43
+ <Badge text={row.label} />
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ )
48
+ }
49
+ `
50
+ const ctx = analyzeComponent(source, 'Rows.tsx', 'Rows')
51
+ const ir = jsxToIR(ctx)
52
+ expect(ir).not.toBeNull()
53
+
54
+ const loop = findLoop(ir!)
55
+ expect(loop.children).toHaveLength(1)
56
+ const li = loop.children[0] as IRElement
57
+ expect(li.type).toBe('element')
58
+ const badge = li.children.find(c => c.type === 'component') as IRComponent
59
+ expect(badge).toBeDefined()
60
+ expect(badge.loopItemRoot).not.toBe(true)
61
+ expect(derivesScopeFromSlot(badge)).toBe(true)
62
+ })
63
+
64
+ test('a component that IS the loop row root IS a loop item root', () => {
65
+ const source = `
66
+ 'use client'
67
+ import { createSignal } from '@barefootjs/client'
68
+ function Row(props: { label: string }) {
69
+ return <li>{props.label}</li>
70
+ }
71
+ export function Rows(props: { items: { id: number; label: string }[] }) {
72
+ const [rows] = createSignal(props.items)
73
+ return (
74
+ <ul>
75
+ {rows().map(row => (
76
+ <Row key={row.id} label={row.label} />
77
+ ))}
78
+ </ul>
79
+ )
80
+ }
81
+ `
82
+ const ctx = analyzeComponent(source, 'Rows.tsx', 'Rows')
83
+ const ir = jsxToIR(ctx)
84
+ expect(ir).not.toBeNull()
85
+
86
+ const loop = findLoop(ir!)
87
+ expect(loop.children).toHaveLength(1)
88
+ const row = loop.children[0] as IRComponent
89
+ expect(row.type).toBe('component')
90
+ expect(row.loopItemRoot).toBe(true)
91
+ expect(derivesScopeFromSlot(row)).toBe(false)
92
+ })
93
+
94
+ test('a component in a whole-item conditional loop body IS a loop item root (matches Hono ctx-forwarding)', () => {
95
+ const source = `
96
+ 'use client'
97
+ import { createSignal } from '@barefootjs/client'
98
+ function Row(props: { label: string }) {
99
+ return <li>{props.label}</li>
100
+ }
101
+ export function Rows(props: { items: { id: number; label: string; show: boolean }[] }) {
102
+ const [rows] = createSignal(props.items)
103
+ return (
104
+ <ul>
105
+ {rows().map(row => row.show && <Row key={row.id} label={row.label} />)}
106
+ </ul>
107
+ )
108
+ }
109
+ `
110
+ const ctx = analyzeComponent(source, 'Rows.tsx', 'Rows')
111
+ const ir = jsxToIR(ctx)
112
+ expect(ir).not.toBeNull()
113
+
114
+ const loop = findLoop(ir!)
115
+ expect(loop.children).toHaveLength(1)
116
+ const cond = loop.children[0]
117
+ expect(cond.type).toBe('conditional')
118
+ if (cond.type !== 'conditional') throw new Error('unreachable')
119
+ const row = cond.whenTrue as IRComponent
120
+ expect(row.type).toBe('component')
121
+ expect(row.loopItemRoot).toBe(true)
122
+ })
123
+
124
+ test('a component nested inside a fragment loop body is NOT a loop item root (Hono does not forward through fragments)', () => {
125
+ const source = `
126
+ 'use client'
127
+ import { createSignal } from '@barefootjs/client'
128
+ function Badge(props: { text: string }) {
129
+ return <span>{props.text}</span>
130
+ }
131
+ export function Rows(props: { items: { id: number; label: string }[] }) {
132
+ const [rows] = createSignal(props.items)
133
+ return (
134
+ <ul>
135
+ {rows().map(row => (
136
+ <>
137
+ <Badge key={row.id} text={row.label} />
138
+ </>
139
+ ))}
140
+ </ul>
141
+ )
142
+ }
143
+ `
144
+ const ctx = analyzeComponent(source, 'Rows.tsx', 'Rows')
145
+ const ir = jsxToIR(ctx)
146
+ expect(ir).not.toBeNull()
147
+
148
+ const loop = findLoop(ir!)
149
+ expect(loop.children).toHaveLength(1)
150
+ const fragment = loop.children[0]
151
+ expect(fragment.type).toBe('fragment')
152
+ if (fragment.type !== 'fragment') throw new Error('unreachable')
153
+ const badge = fragment.children.find(c => c.type === 'component') as IRComponent
154
+ expect(badge).toBeDefined()
155
+ expect(badge.loopItemRoot).not.toBe(true)
156
+ })
157
+
158
+ test('CSR template: a component nested below the row root passes its slot suffix to renderChild', () => {
159
+ const source = `
160
+ 'use client'
161
+ import { createSignal } from '@barefootjs/client'
162
+ function Badge(props: { text: string }) {
163
+ return <span>{props.text}</span>
164
+ }
165
+ export function Rows(props: { items: { id: number; label: string }[] }) {
166
+ const [rows] = createSignal(props.items)
167
+ return (
168
+ <ul>
169
+ {rows().map(row => (
170
+ <li key={row.id}>
171
+ <Badge text={row.label} />
172
+ </li>
173
+ ))}
174
+ </ul>
175
+ )
176
+ }
177
+ `
178
+ const result = compileJSX(source, 'Rows.tsx', { adapter })
179
+ expect(result.errors).toHaveLength(0)
180
+ const clientJs = result.files.find(f => f.type === 'clientJs')
181
+ expect(clientJs).toBeDefined()
182
+ // The nested Badge must receive a slot suffix (4th renderChild arg) so
183
+ // it derives its scope id — a bare `renderChild('Badge', {...})` with
184
+ // no suffix would mean the fix regressed.
185
+ expect(clientJs!.content).toMatch(/renderChild\('Badge[^']*',\s*\{text:\s*row\.label\},\s*undefined,\s*'s\d+'\)/)
186
+ })
187
+
188
+ test('CSR template: a component that IS the loop row root gets no slot suffix', () => {
189
+ const source = `
190
+ 'use client'
191
+ import { createSignal } from '@barefootjs/client'
192
+ function Row(props: { label: string }) {
193
+ return <li>{props.label}</li>
194
+ }
195
+ export function Rows(props: { items: { id: number; label: string }[] }) {
196
+ const [rows] = createSignal(props.items)
197
+ return (
198
+ <ul>
199
+ {rows().map(row => (
200
+ <Row key={row.id} label={row.label} />
201
+ ))}
202
+ </ul>
203
+ )
204
+ }
205
+ `
206
+ const result = compileJSX(source, 'Rows.tsx', { adapter })
207
+ expect(result.errors).toHaveLength(0)
208
+ const clientJs = result.files.find(f => f.type === 'clientJs')
209
+ expect(clientJs).toBeDefined()
210
+ // Static-array loop item root: no slot suffix — it owns its own random id.
211
+ expect(clientJs!.content).not.toMatch(/renderChild\('row',[^)]*'s\d+'\)/)
212
+ })
213
+ })
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Which code creates a loop row's root element, and whether that row is
3
+ * connected before its body's tail runs.
4
+ *
5
+ * The "connect before init" invariant exists because `useContext` resolves by
6
+ * walking `parentElement`: a child that initialises inside a detached row finds
7
+ * no ancestors and falls through to the global last-writer-wins context store,
8
+ * silently reading whichever provider on the page wrote last. So the creation
9
+ * site has to know its destination in advance, and there are two ways to tell
10
+ * it — a PLACEHOLDER already in the tree (`mountAt`, child slots), or the
11
+ * AMBIENT row mount point.
12
+ *
13
+ * Both work because a runtime FUNCTION creates the element and can take an
14
+ * argument. A row whose root is a template clone written inline in the emitted
15
+ * body has no such function, so the compiler emits `mountRowRoot(clone)` to
16
+ * consume the same ambient point — but only for the variant that initialises
17
+ * anything inside the row. A row with no nested init has nothing that could
18
+ * resolve wrongly, and skipping it there keeps the high-volume `mapArrayLazy`
19
+ * emission untouched.
20
+ *
21
+ * This matrix pins that split against compiled output rather than
22
+ * recollection. `mountsRow` is the load-bearing column: if it ever flips to
23
+ * false for a shape that initialises a child, that shape has silently gone
24
+ * back to initialising detached, and nothing else in the suite would say so —
25
+ * the wrong value it produces is only wrong when a second provider of the same
26
+ * context happens to be on the page.
27
+ */
28
+ import { describe, test, expect } from 'bun:test'
29
+ import { compileJSX } from '../compiler'
30
+ import { TestAdapter } from '../adapters/test-adapter'
31
+
32
+ interface RowShape {
33
+ /** How the row's root element comes into existence. */
34
+ rowRoot: 'createComponent' | 'template-clone'
35
+ /**
36
+ * Does a child inside the row reach its `init` through a placeholder slot
37
+ * (`upsertChild` / `upsertChildItem`)?
38
+ *
39
+ * Narrower than "something initialises in this row" — a conditional child
40
+ * arrives via `insert` + `initChild` and reads false here. Read it alongside
41
+ * `mountsRow`, never as a substitute.
42
+ *
43
+ * `initChild` on the row ROOT is deliberately excluded: that is the
44
+ * hydration branch adopting an SSR-rendered row, which is in the document by
45
+ * construction and never part of this problem.
46
+ */
47
+ nestedChildInit: boolean
48
+ /** Multi-root (Fragment) body — the sibling-walk lookup path. */
49
+ multiRoot: boolean
50
+ /** Is the fresh row connected before the body's tail runs? */
51
+ mountsRow: boolean
52
+ /** Which list runtime the loop compiles to. */
53
+ loopRuntime: string
54
+ }
55
+
56
+ function classify(loopBody: string): RowShape {
57
+ const source = `"use client"
58
+ import { createSignal } from '@barefootjs/client'
59
+ import { Chip } from './chip'
60
+ function T() {
61
+ const [items] = createSignal([{ id: '1', name: 'a' }])
62
+ return <ul>{items().map((it) => ${loopBody})}</ul>
63
+ }
64
+ export { T }`
65
+ const result = compileJSX(source, 'T.tsx', { adapter: new TestAdapter() })
66
+ const hard = result.errors.filter((e) => e.severity !== 'warning')
67
+ if (hard.length > 0) throw new Error(`unexpected compile error: ${hard.map((e) => e.code).join(',')}`)
68
+ const js = result.files.find((f) => f.type === 'clientJs')?.content ?? ''
69
+
70
+ // Slice from the list call to the end of the emitted init function.
71
+ // Everything the row does at setup time is in there, and the top-level
72
+ // `createComponent('T', …)` export sits after it, so it cannot be mistaken
73
+ // for a row root. Starting at the call also excludes the import line, so
74
+ // `mountsRow` reflects a real call site rather than the import of one.
75
+ const call = /mapArray(?:Lazy|Anchored)?\(/.exec(js)
76
+ if (!call) throw new Error('no list runtime call emitted')
77
+ const hydrateAt = js.indexOf('\nhydrate(')
78
+ const region = js.slice(call.index, hydrateAt === -1 ? undefined : hydrateAt)
79
+
80
+ return {
81
+ rowRoot: /cloneNode\(/.test(region) ? 'template-clone' : 'createComponent',
82
+ nestedChildInit: /upsertChild\(|upsertChildItem\(/.test(region),
83
+ multiRoot: /__bfExtras|upsertChildItem\(|qsaItem\(/.test(region),
84
+ mountsRow: /mountRowRoot\(/.test(region),
85
+ loopRuntime: /mapArray(?:Lazy|Anchored)?/.exec(region)?.[0] ?? 'unknown',
86
+ }
87
+ }
88
+
89
+ describe('loop row creation paths — who makes the row root, and when it is connected', () => {
90
+ test('component row root: the runtime creates it, so it takes the mount point itself', () => {
91
+ // `<Chip/>` IS the row. `createComponent` consumes the ambient point in its
92
+ // own step 7b, so no separate call is emitted.
93
+ expect(classify('<Chip key={it.id} id={it.id} />')).toEqual({
94
+ rowRoot: 'createComponent',
95
+ nestedChildInit: false,
96
+ multiRoot: false,
97
+ mountsRow: false,
98
+ loopRuntime: 'mapArray',
99
+ })
100
+ })
101
+
102
+ test('markup row root with no component inside: a clone, and deliberately not mounted', () => {
103
+ // Nothing initialises inside this row, so there is no `useContext` call
104
+ // during setup to resolve wrongly — and this is the high-volume path, so
105
+ // leaving it alone keeps the `mapArrayLazy` emission and the measurements
106
+ // taken against it untouched. `mountsRow: false` here is a decision.
107
+ expect(classify('<li key={it.id}>{it.name}</li>')).toEqual({
108
+ rowRoot: 'template-clone',
109
+ nestedChildInit: false,
110
+ multiRoot: false,
111
+ mountsRow: false,
112
+ loopRuntime: 'mapArrayLazy',
113
+ })
114
+ })
115
+
116
+ test('markup row root with a nested component: clone, mounted before the tail', () => {
117
+ // The row root is user markup, so it is not and cannot become a component;
118
+ // `mountRowRoot` is what gives it a destination. Flip `mountsRow` to false
119
+ // and `upsertChild` goes back to running the child's `init` against a
120
+ // detached element.
121
+ expect(classify('<li key={it.id}><Chip id={it.id} /></li>')).toEqual({
122
+ rowRoot: 'template-clone',
123
+ nestedChildInit: true,
124
+ multiRoot: false,
125
+ mountsRow: true,
126
+ loopRuntime: 'mapArray',
127
+ })
128
+ })
129
+
130
+ test('two nested components in one markup row: same case, one mount', () => {
131
+ expect(classify('<li key={it.id}><Chip id={it.id} /><Chip id={it.name} /></li>')).toEqual({
132
+ rowRoot: 'template-clone',
133
+ nestedChildInit: true,
134
+ multiRoot: false,
135
+ mountsRow: true,
136
+ loopRuntime: 'mapArray',
137
+ })
138
+ })
139
+
140
+ test('Fragment row with a nested component: mounted, and the multi-root lookup copes', () => {
141
+ // A Fragment root can never be a component, so this shape only became
142
+ // reachable for a mount point once the clone path got one. It is also the
143
+ // multi-root lookup, whose sibling walk in `qsa-item.ts` used to end with
144
+ // `return` and skip the `__bfExtras` stash the moment the primary was
145
+ // attached — a `break` now, so an attached primary still finds its extras.
146
+ expect(classify('<><Chip id={it.id} /><span>{it.name}</span></>')).toEqual({
147
+ rowRoot: 'template-clone',
148
+ nestedChildInit: true,
149
+ multiRoot: true,
150
+ mountsRow: true,
151
+ loopRuntime: 'mapArray',
152
+ })
153
+ })
154
+
155
+ test('conditional child in a markup row: mounted, though it inits through `insert`', () => {
156
+ // `nestedChildInit` is false because this child arrives via `insert` +
157
+ // `initChild` rather than `upsertChild` — but it is still a child
158
+ // initialising inside the row, and the emission is keyed on the loop
159
+ // variant (composite), not on which call the child arrives through. This
160
+ // row is why the two columns have to be read separately.
161
+ expect(classify('<li key={it.id}>{it.name ? <Chip id={it.id} /> : null}</li>')).toEqual({
162
+ rowRoot: 'template-clone',
163
+ nestedChildInit: false,
164
+ multiRoot: false,
165
+ mountsRow: true,
166
+ loopRuntime: 'mapArray',
167
+ })
168
+ })
169
+ })
@@ -133,13 +133,23 @@ describe('.map() multi-return body fold (Stage 2)', () => {
133
133
  })
134
134
  })
135
135
 
136
- describe('leading-const preamble is adapter-gated (JS folds, DSL refuses)', () => {
136
+ describe('leading-const preamble folded on JS, lowered on DSL, gated on shape', () => {
137
137
  const preambleBody = `{
138
138
  const label = it.kind.toUpperCase()
139
139
  if (it.on) return <b key={it.id}>{label}</b>
140
140
  return <span key={it.id}>{label}</span>
141
141
  }`
142
142
 
143
+ // The same fold, but with a preamble no template language can declare: a
144
+ // destructuring binding takes several names off one initializer, and no
145
+ // adapter has a single per-row local form for that. Nothing lowers, so
146
+ // both branches would read an unassigned name.
147
+ const undeclarableBody = `{
148
+ const { kind } = it
149
+ if (it.on) return <b key={it.id}>{kind}</b>
150
+ return <span key={it.id}>{kind}</span>
151
+ }`
152
+
143
153
  function compileWith(source: string, dsl: boolean) {
144
154
  const a = new TestAdapter()
145
155
  // Model a DSL adapter: its template runtime can't run a callback body
@@ -156,15 +166,32 @@ describe('.map() multi-return body fold (Stage 2)', () => {
156
166
  expect(cj.content).not.toMatch(/return <(b|span)/)
157
167
  })
158
168
 
159
- test('a DSL adapter refuses with BF021 + the /* @client */ escape', () => {
169
+ test('a DSL adapter accepts it too the preamble lowers to a per-row local (#2447)', () => {
170
+ // This used to be a BF021 refusal, on the premise that a loop-local
171
+ // can't reach a conditional branch template. The fold puts the
172
+ // conditional INSIDE the loop body, so once the preamble lowers to a
173
+ // per-row declaration the local is in scope in both arms — see the
174
+ // `map-preamble-branch-body` fixture, which now renders on all nine
175
+ // adapters.
160
176
  const r = compileWith(wrap(preambleBody), true)
177
+ expect(r.errors.filter(e => e.severity !== 'warning')).toHaveLength(0)
178
+ })
179
+
180
+ test('a preamble that is NOT declarable still refuses with BF021 + /* @client */', () => {
181
+ const r = compileWith(wrap(undeclarableBody), true)
161
182
  const bf021 = r.errors.filter(e => e.code === 'BF021')
162
183
  expect(bf021).toHaveLength(1)
184
+ expect(bf021[0].message).toMatch(/preamble is not a sequence of value declarations/)
163
185
  expect(bf021[0].suggestion?.message).toContain('@client')
164
186
  })
165
187
 
188
+ test('a JS runtime runs the undeclarable preamble verbatim — no refusal', () => {
189
+ const r = compileWith(wrap(undeclarableBody), false)
190
+ expect(r.errors.filter(e => e.severity !== 'warning')).toHaveLength(0)
191
+ })
192
+
166
193
  test('/* @client */ suppresses the DSL refusal', () => {
167
- const clientSource = wrap(preambleBody).replace('items.map', '/* @client */ items.map')
194
+ const clientSource = wrap(undeclarableBody).replace('items.map', '/* @client */ items.map')
168
195
  const r = compileWith(clientSource, true)
169
196
  expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
170
197
  })
@@ -342,14 +342,35 @@ describe('nested loops/conditionals inside mapArray (#830, #839)', () => {
342
342
  // Locate the nested mapArray callback body — the inner one is the
343
343
  // `node.children.map(child => ...)` that uses `child.id` as its key.
344
344
  // Note: pre-O-1 the inner mapArray was emitted twice (once per
345
- // SSR/CSR side of the outer renderItem) so this regex used to find
346
- // 2+ matches. After the O-1 dedup we only need at least 1.
347
- const mapCalls = content.match(/mapArray\([\s\S]*?\}\)\s*\}/g)
348
- expect(mapCalls).not.toBeNull()
349
- expect(mapCalls!.length).toBeGreaterThanOrEqual(1)
345
+ // SSR/CSR side of the outer renderItem) so this used to find 2+
346
+ // matches. After the O-1 dedup we only need at least 1.
347
+ //
348
+ // Extracted via a balanced-PAREN scan from each `mapArray(` call site
349
+ // (a call is itself a single balanced-paren expression) — NOT a lazy
350
+ // `[\s\S]*?...\}\)\s*\}` regex, which coincidentally matched the old
351
+ // output only because the FIRST `}) }` byte sequence anywhere in the
352
+ // file happened to fall right after the inner mapArray call. Any
353
+ // unrelated text elsewhere containing that same 3-character sequence
354
+ // (e.g. the SSR template's `renderChild(..., 's1')` slot argument,
355
+ // #2444) silently shifted or erased the match with no relation to
356
+ // mapArray's actual extent — a real balanced scan can't have that failure mode.
357
+ const mapCalls: string[] = []
358
+ const mapArrayCallRe = /mapArray\(/g
359
+ let callMatch: RegExpExecArray | null
360
+ while ((callMatch = mapArrayCallRe.exec(content)) !== null) {
361
+ let depth = 1
362
+ let i = callMatch.index + callMatch[0].length
363
+ while (i < content.length && depth > 0) {
364
+ if (content[i] === '(') depth++
365
+ else if (content[i] === ')') depth--
366
+ i++
367
+ }
368
+ mapCalls.push(content.slice(callMatch.index, i))
369
+ }
370
+ expect(mapCalls.length).toBeGreaterThanOrEqual(1)
350
371
 
351
372
  // Pick the inner mapArray. It references `child.id` as the key function.
352
- const innerMapArray = mapCalls!.find(m => /\(child\)\s*=>\s*String\(child\.id\)/.test(m))
373
+ const innerMapArray = mapCalls.find(m => /\(child\)\s*=>\s*String\(child\.id\)/.test(m))
353
374
  expect(innerMapArray).toBeDefined()
354
375
 
355
376
  // Count initChild('Checkbox', ...) occurrences inside the inner callback,