@barefootjs/jsx 0.29.0 → 0.30.2

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