@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,190 @@
1
+ /**
2
+ * `analyzeLazyConditional` — the §9.5 "row contains a reactive conditional"
3
+ * widening (`plan/lazy-conditional.ts`).
4
+ *
5
+ * Unit half over hand-built `LoopChildConditional` values so each refusal is
6
+ * pinned to the exact shape that produces it, plus emission assertions that an
7
+ * accepted conditional is driven from the apply bodies with its arms hoisted
8
+ * once per loop. The DOM behaviour (does the swap actually land, both ways)
9
+ * lives in `packages/client/__tests__/runtime/lazy-row-conditional.test.ts` —
10
+ * emitted shape and emitted behaviour are different claims.
11
+ */
12
+
13
+ import { describe, test, expect } from 'bun:test'
14
+ import { compileJSX } from '../index.ts'
15
+ import { TestAdapter } from '../adapters/test-adapter'
16
+ import { analyzeLazyConditional } from '../ir-to-client-js/control-flow/plan/lazy-conditional.ts'
17
+ import type { LoopChildBranchSummary, LoopChildConditional } from '../ir-to-client-js/types.ts'
18
+
19
+ const bare = (over: Partial<LoopChildBranchSummary> = {}): LoopChildBranchSummary => ({
20
+ childComponents: [],
21
+ ...over,
22
+ })
23
+
24
+ function cond(over: Partial<LoopChildConditional> = {}): LoopChildConditional {
25
+ return {
26
+ slotId: 's0',
27
+ condition: 'row.done',
28
+ whenTrueHtml: '<span class="yes">done</span>',
29
+ whenFalseHtml: '<em class="no">open</em>',
30
+ whenTrue: bare(),
31
+ whenFalse: bare(),
32
+ conditionFreeIdentifiers: new Set(['row']),
33
+ ...over,
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The caller prepares each arm through `addCondAttrToTemplate` before handing it
39
+ * over — that is where `bf-c` comes from, and where the element-vs-fragment
40
+ * decision is made. These helpers stand in for it.
41
+ */
42
+ const asElement = (html: string, slotId = 's0') => html.replace(/^(<\w+)/, `$1 bf-c="${slotId}"`)
43
+ const asFragment = (html: string, slotId = 's0') =>
44
+ `<!--bf-cond-start:${slotId}-->${html}<!--bf-cond-end:${slotId}-->`
45
+
46
+ const armsOf = (c: LoopChildConditional) => ({
47
+ whenTrueHtml: asElement(c.whenTrueHtml, c.slotId),
48
+ whenFalseHtml: asElement(c.whenFalseHtml, c.slotId),
49
+ })
50
+
51
+ describe('analyzeLazyConditional — accepted', () => {
52
+ test('two wiring-free static element arms', () => {
53
+ const c = cond()
54
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
55
+ expect(r.lazySafe).toBe(true)
56
+ if (r.lazySafe) {
57
+ expect(r.facts.whenTrueHtml).toContain('bf-c="s0"')
58
+ expect(r.facts.condition).toBe('row.done')
59
+ }
60
+ })
61
+ })
62
+
63
+ describe('analyzeLazyConditional — refusals', () => {
64
+ const wiring: Array<[string, Partial<LoopChildBranchSummary>, RegExp]> = [
65
+ ['a child component', { childComponents: [{ name: 'X', slotId: null, props: [], children: [] }] }, /child components/],
66
+ ['an inner loop', { innerLoops: [{} as never] }, /an inner loop/],
67
+ ['a nested conditional', { conditionals: [{} as never] }, /a nested conditional/],
68
+ ['events', { events: [{} as never] }, /events/],
69
+ ['a reactive attr', { reactiveAttrs: [{} as never] }, /reactive attrs/],
70
+ ['reactive text', { reactiveTexts: [{} as never] }, /reactive text/],
71
+ ]
72
+
73
+ for (const [name, over, reason] of wiring) {
74
+ test(`the true arm owns ${name}`, () => {
75
+ const c = cond({ whenTrue: bare(over) })
76
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
77
+ expect(r.lazySafe).toBe(false)
78
+ if (!r.lazySafe) expect(r.reason).toMatch(reason)
79
+ })
80
+ }
81
+
82
+ test('the FALSE arm is checked too, not just the true one', () => {
83
+ const c = cond({ whenFalse: bare({ events: [{} as never] }) })
84
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
85
+ expect(r.lazySafe).toBe(false)
86
+ if (!r.lazySafe) expect(r.reason).toMatch(/its false arm owns events/)
87
+ })
88
+
89
+ test('a fragment conditional has no single node to replace', () => {
90
+ const c = cond()
91
+ const r = analyzeLazyConditional(c, '__idx', {
92
+ whenTrueHtml: asFragment('text only'),
93
+ whenFalseHtml: asElement(c.whenFalseHtml),
94
+ })
95
+ expect(r.lazySafe).toBe(false)
96
+ if (!r.lazySafe) expect(r.reason).toMatch(/fragment conditional/)
97
+ })
98
+
99
+ test('an arm that interpolates a value cannot be hoisted once per loop', () => {
100
+ const c = cond({ whenTrueHtml: '<span>${row().label}</span>' })
101
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
102
+ expect(r.lazySafe).toBe(false)
103
+ if (!r.lazySafe) expect(r.reason).toMatch(/interpolates a value/)
104
+ })
105
+
106
+ test('a condition with no analyzable identifier set refuses rather than assumes', () => {
107
+ const c = cond({ conditionFreeIdentifiers: undefined })
108
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
109
+ expect(r.lazySafe).toBe(false)
110
+ if (!r.lazySafe) expect(r.reason).toMatch(/no analyzable identifier set/)
111
+ })
112
+
113
+ test('a condition reading the loop index refuses — the apply bodies have none', () => {
114
+ const c = cond({ conditionFreeIdentifiers: new Set(['row', '__idx']) })
115
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
116
+ expect(r.lazySafe).toBe(false)
117
+ if (!r.lazySafe) expect(r.reason).toMatch(/reads the loop index parameter '__idx'/)
118
+ })
119
+ })
120
+
121
+ // --- emission ---------------------------------------------------------------
122
+
123
+ function clientJs(source: string, file: string): string {
124
+ const result = compileJSX(source, file, { adapter: new TestAdapter() })
125
+ const js = result.files.find(f => f.path.endsWith('.client.js'))
126
+ if (!js) throw new Error(`no client JS emitted for ${file}`)
127
+ return js.content
128
+ }
129
+
130
+ const ROWS = (arm: string) => `
131
+ 'use client'
132
+ import { createSignal } from '@barefootjs/client'
133
+ type Row = { id: number; label: string; done: boolean }
134
+ export function CondRows(props: { rows: Row[] }) {
135
+ const [rows, setRows] = createSignal<Row[]>(props.rows)
136
+ return (
137
+ <ul>
138
+ {rows().map(row => (
139
+ <li key={row.id}>${arm}</li>
140
+ ))}
141
+ </ul>
142
+ )
143
+ }
144
+ `
145
+
146
+ describe('lazy row emission — wiring-free row conditional', () => {
147
+ const js = clientJs(
148
+ ROWS('{row.done ? <span class="yes">done</span> : <em class="no">open</em>}'),
149
+ 'CondRows.tsx',
150
+ )
151
+
152
+ test('the loop is lazy and calls no per-row insert()', () => {
153
+ expect(js).toContain('mapArrayLazy(')
154
+ expect(js).not.toMatch(/\bmapArray\(/)
155
+ expect(js).not.toContain('insert(')
156
+ })
157
+
158
+ test('both arms are parsed once per LOOP, outside every plan body', () => {
159
+ const beforeCall = js.slice(0, js.indexOf('mapArrayLazy('))
160
+ expect(beforeCall).toMatch(/const __cbt_l0_s\d+ = document\.createElement\('template'\)/)
161
+ expect(beforeCall).toMatch(/const __cbf_l0_s\d+ = document\.createElement\('template'\)/)
162
+ })
163
+
164
+ test('createRow only records the dedup boolean — the clone already has the right arm', () => {
165
+ const createRow = js.slice(js.indexOf('createRow:'), js.indexOf('applyItem:'))
166
+ expect(createRow).toMatch(/__l\[\d\] = !!\(row\(\)\.done\)/)
167
+ expect(createRow).not.toContain('replaceWith')
168
+ })
169
+
170
+ test('applyItem swaps by replaceWith and reassigns the ref', () => {
171
+ const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf("}, 'l0')"))
172
+ expect(applyItem).toContain('__c.replaceWith(__n)')
173
+ // Without this the next flip writes into the node the previous one detached.
174
+ expect(applyItem).toMatch(/__r\[\d\] = __n/)
175
+ })
176
+ })
177
+
178
+ describe('lazy row emission — conditionals that keep the eager path', () => {
179
+ const cases: Array<[string, string]> = [
180
+ ['an arm with an event', '{row.done ? <button onClick={() => {}}>x</button> : <em>open</em>}'],
181
+ ['an arm reading the item', '{row.done ? <span>{row.label}</span> : <em>open</em>}'],
182
+ ['a bare-value ternary (fragment form)', '{row.done ? "yes" : "no"}'],
183
+ ]
184
+ for (const [name, arm] of cases) {
185
+ test(name, () => {
186
+ const js = clientJs(ROWS(arm), 'EagerCond.tsx')
187
+ expect(js).not.toContain('mapArrayLazy(')
188
+ })
189
+ }
190
+ })
@@ -0,0 +1,297 @@
1
+ /**
2
+ * `analyzeLazyPreamble` — the §9.5 "row has a map-callback preamble"
3
+ * widening (`plan/lazy-preamble.ts`).
4
+ *
5
+ * Two layers, same split as `lazy-row-eligibility.test.ts`:
6
+ *
7
+ * 1. Unit tests over hand-built `MapCallbackPreamble` values, so each
8
+ * refusal reason is pinned to the exact source shape that produces it —
9
+ * a refusal that silently changes wording or scope fails here.
10
+ * 2. End-to-end emission: a component whose loop has a value-only preamble
11
+ * must now emit `mapArrayLazy`, with the preamble in `createRow` and
12
+ * nowhere else. That placement is the whole soundness argument (the apply
13
+ * bodies must never reference a preamble local), so it is asserted
14
+ * directly rather than inferred from "it compiled".
15
+ */
16
+
17
+ import { describe, test, expect } from 'bun:test'
18
+ import { compileJSX } from '../index.ts'
19
+ import { TestAdapter } from '../adapters/test-adapter'
20
+ import { analyzeLazyPreamble } from '../ir-to-client-js/control-flow/plan/lazy-preamble.ts'
21
+ import { tsxSourceText, type MapCallbackPreamble } from '../types.ts'
22
+
23
+ function preamble(text: string, declaredNames: string[], builderNames: string[] = []): MapCallbackPreamble {
24
+ return {
25
+ segments: [{ kind: 'js', text }],
26
+ ssrText: tsxSourceText(text),
27
+ declaredNames,
28
+ builderNames,
29
+ }
30
+ }
31
+
32
+ const SIGNALS: ReadonlySet<string> = new Set(['selected', 'filter'])
33
+
34
+ describe('analyzeLazyPreamble — accepted', () => {
35
+ test('no preamble at all', () => {
36
+ expect(analyzeLazyPreamble(undefined, '__idx', SIGNALS).lazySafe).toBe(true)
37
+ })
38
+
39
+ test('const declarations reading only the item', () => {
40
+ const r = analyzeLazyPreamble(preamble("const full = `${row.a} ${row.b}`;", ['full']), '__idx', SIGNALS)
41
+ expect(r.lazySafe).toBe(true)
42
+ if (r.lazySafe) expect([...r.facts.declaredNames]).toEqual(['full'])
43
+ })
44
+
45
+ test('a zero-arg signal read is allowed — the krausest shape', () => {
46
+ const r = analyzeLazyPreamble(
47
+ preamble("const cls = selected() === row.id ? 'danger' : '';", ['cls']),
48
+ '__idx',
49
+ SIGNALS,
50
+ )
51
+ expect(r.lazySafe).toBe(true)
52
+ })
53
+
54
+ test('a destructuring const contributes every bound name, not the property keys', () => {
55
+ const r = analyzeLazyPreamble(preamble('const { a, b: [c] } = row;', ['a', 'c']), '__idx', SIGNALS)
56
+ expect(r.lazySafe).toBe(true)
57
+ if (r.lazySafe) expect([...r.facts.declaredNames].sort()).toEqual(['a', 'c'])
58
+ })
59
+ })
60
+
61
+ describe('analyzeLazyPreamble — refusals', () => {
62
+ const cases: Array<[string, MapCallbackPreamble, RegExp]> = [
63
+ [
64
+ 'a signal declared per row',
65
+ preamble('const [x, setX] = createSignal(0);', ['x', 'setX']),
66
+ /call to createSignal/,
67
+ ],
68
+ [
69
+ 'a memo declared per row',
70
+ preamble('const total = createMemo(() => row.a + row.b);', ['total']),
71
+ /call to createMemo/,
72
+ ],
73
+ [
74
+ 'a non-signal call — could hide a reactive accessor',
75
+ preamble('const on = isSelected(row.id);', ['on']),
76
+ /call to isSelected/,
77
+ ],
78
+ [
79
+ 'a method call on the item',
80
+ preamble("const tags = row.tags.join(', ');", ['tags']),
81
+ /call to row\.tags\.join/,
82
+ ],
83
+ [
84
+ 'a nondeterministic call would differ between createRow and applyItem',
85
+ preamble('const n = Math.random();', ['n']),
86
+ /call to Math\.random/,
87
+ ],
88
+ [
89
+ 'a signal getter called with arguments is not a plain read',
90
+ preamble('const v = selected(row.id);', ['v']),
91
+ /call to selected/,
92
+ ],
93
+ ['a mutable binding', preamble("let cls = '';", ['cls']), /mutable binding/],
94
+ [
95
+ 'a statement that is not a declaration',
96
+ preamble("const a = row.a; console.log(a);", ['a']),
97
+ /non-declaration statement/,
98
+ ],
99
+ [
100
+ 'an arrow expression — fresh identity per run',
101
+ preamble('const fn = (x) => x + row.a;', ['fn']),
102
+ /function or class expression/,
103
+ ],
104
+ ['a new expression', preamble('const d = new Foo(row.a);', ['d']), /new expression/],
105
+ [
106
+ 'a JSX-leaf accumulator',
107
+ preamble('const cells = [];', ['cells'], ['cells']),
108
+ /accumulates JSX leaves/,
109
+ ],
110
+ [
111
+ 'a preamble local shadowing a signal getter',
112
+ preamble('const selected = row.sel;', ['selected']),
113
+ /shadows the signal\/memo getter 'selected'/,
114
+ ],
115
+ [
116
+ 'a preamble reading the loop index',
117
+ preamble('const n = __idx + 1;', ['n']),
118
+ /reads the loop index parameter '__idx'/,
119
+ ],
120
+ ]
121
+
122
+ for (const [name, p, reason] of cases) {
123
+ test(name, () => {
124
+ const r = analyzeLazyPreamble(p, '__idx', SIGNALS)
125
+ expect(r.lazySafe).toBe(false)
126
+ if (!r.lazySafe) expect(r.reason).toMatch(reason)
127
+ })
128
+ }
129
+
130
+ test('a JSX segment is refused even with no builder name', () => {
131
+ const p: MapCallbackPreamble = {
132
+ segments: [{ kind: 'jsx', ir: { type: 'text', value: 'x' } as never }],
133
+ ssrText: tsxSourceText(''),
134
+ declaredNames: [],
135
+ builderNames: [],
136
+ }
137
+ const r = analyzeLazyPreamble(p, '__idx', SIGNALS)
138
+ expect(r.lazySafe).toBe(false)
139
+ if (!r.lazySafe) expect(r.reason).toMatch(/JSX leaf/)
140
+ })
141
+ })
142
+
143
+ // --- emission ---------------------------------------------------------------
144
+
145
+ function clientJs(source: string, file: string): string {
146
+ const result = compileJSX(source, file, { adapter: new TestAdapter() })
147
+ const js = result.files.find(f => f.path.endsWith('.client.js'))
148
+ if (!js) throw new Error(`no client JS emitted for ${file}`)
149
+ return js.content
150
+ }
151
+
152
+ /**
153
+ * The preamble computes an attribute value from the item and an outer signal.
154
+ * An attribute reading a preamble local is not classified as reactive, so it
155
+ * lands in the row TEMPLATE — which is exactly why `createRow` needs the
156
+ * preamble and the apply bodies do not.
157
+ */
158
+ const VALUE_PREAMBLE = `
159
+ 'use client'
160
+ import { createSignal } from '@barefootjs/client'
161
+ type Row = { id: number; label: string; done: boolean }
162
+ export function PreambleRows() {
163
+ const [rows, setRows] = createSignal<Row[]>([])
164
+ const [selected, setSelected] = createSignal(0)
165
+ return (
166
+ <tbody>
167
+ {rows().map(row => {
168
+ const cls = selected() === row.id ? 'danger' : (row.done ? 'done' : '')
169
+ return (
170
+ <tr key={row.id} className={cls}>
171
+ <td>{row.label}</td>
172
+ </tr>
173
+ )
174
+ })}
175
+ </tbody>
176
+ )
177
+ }
178
+ `
179
+
180
+ describe('lazy row emission — value-only preamble', () => {
181
+ const js = clientJs(VALUE_PREAMBLE, 'PreambleRows.tsx')
182
+
183
+ test('the loop is lazy — a preamble no longer refuses on sight', () => {
184
+ expect(js).toContain('mapArrayLazy(')
185
+ expect(js).not.toMatch(/\bmapArray\(/)
186
+ })
187
+
188
+ test('rows still carry no per-row reactive resources', () => {
189
+ const planBody = js.slice(js.indexOf('mapArrayLazy('), js.indexOf("}, 'l0')"))
190
+ expect(planBody).not.toContain('createEffect')
191
+ expect(planBody).not.toContain('createRoot')
192
+ expect(planBody).not.toContain('createSignal')
193
+ })
194
+
195
+ test('createRow runs the preamble, before the clone the template needs it for', () => {
196
+ const createRow = js.slice(js.indexOf('createRow:'), js.indexOf('applyItem:'))
197
+ expect(createRow).toContain('const cls = selected() === row().id')
198
+ expect(createRow.indexOf('const cls =')).toBeLessThan(createRow.indexOf('const __el ='))
199
+ })
200
+
201
+ test('applyOuter re-runs the preamble AND primes what it reads', () => {
202
+ // `className={cls}` is a binding as of the #2447 follow-up, and `cls`
203
+ // reads `selected()` — an OUTER signal the local names nowhere. The
204
+ // classifier substitutes the preamble's free identifiers, so `selected`
205
+ // reaches the prime list; without that the loop-level effect would
206
+ // subscribe to nothing and the class would never update.
207
+ // Bounded at the plan's closing `}, 'l0')` — past that sits the
208
+ // `hydrate` template, which legitimately contains the preamble.
209
+ const plan = js.slice(js.indexOf('mapArrayLazy('), js.indexOf("}, 'l0')"))
210
+ const applyOuter = plan.slice(plan.indexOf('applyOuter:'))
211
+ expect(applyOuter).toContain('const cls = selected() === row().id')
212
+ // The prime statement sits ABOVE the per-entry loop, so the effect
213
+ // subscribes even while the entry list is empty.
214
+ expect(applyOuter.indexOf('selected()')).toBeLessThan(applyOuter.indexOf('for (const __e of __es)'))
215
+ })
216
+
217
+ test('applyItem re-runs it too — this binding reads the item as well', () => {
218
+ // `selected() === row().id ? … : (row().done ? …)` reads BOTH, so the
219
+ // binding lands in both bodies and both need the local. Asserted
220
+ // explicitly rather than left implicit: "which bodies" is the whole
221
+ // per-binding accounting.
222
+ const plan = js.slice(js.indexOf('mapArrayLazy('), js.indexOf("}, 'l0')"))
223
+ const applyItem = plan.slice(plan.indexOf('applyItem:'), plan.indexOf('applyOuter:'))
224
+ expect(applyItem).toContain('const cls = selected() === row().id')
225
+ })
226
+
227
+ test('a preamble NO binding reads is emitted in createRow only', () => {
228
+ // The counterpart: on-demand means a body with no reader pays nothing.
229
+ const js2 = clientJs(`
230
+ 'use client'
231
+ import { createSignal } from '@barefootjs/client'
232
+ type Row = { id: number; label: string }
233
+ export function UnreadPreamble() {
234
+ const [rows, setRows] = createSignal<Row[]>([])
235
+ return (
236
+ <tbody>
237
+ {rows().map(row => {
238
+ const unused = row.label
239
+ return <tr key={row.id}><td>{row.label}</td></tr>
240
+ })}
241
+ </tbody>
242
+ )
243
+ }
244
+ `, 'UnreadPreamble.tsx')
245
+ const plan = js2.slice(js2.indexOf('mapArrayLazy('), js2.indexOf("}, 'l0')"))
246
+ expect(plan.slice(plan.indexOf('createRow:'), plan.indexOf('applyItem:'))).toContain('const unused =')
247
+ expect(plan.slice(plan.indexOf('applyItem:'))).not.toContain('const unused =')
248
+ })
249
+ })
250
+
251
+ /**
252
+ * Each of these keeps the eager emission. Grouped rather than split so the
253
+ * list reads as "what still refuses", the counterpart to the widening above.
254
+ */
255
+ describe('lazy row emission — preambles that keep the eager path', () => {
256
+ const rows = (body: string) => `
257
+ 'use client'
258
+ import { createSignal, createMemo } from '@barefootjs/client'
259
+ type Row = { id: number; label: string }
260
+ export function EagerRows() {
261
+ const [rows, setRows] = createSignal<Row[]>([])
262
+ return (
263
+ <tbody>
264
+ {rows().map(row => {
265
+ ${body}
266
+ })}
267
+ </tbody>
268
+ )
269
+ }
270
+ `
271
+
272
+ const cases: Array<[string, string]> = [
273
+ [
274
+ 'a per-row signal',
275
+ ` const [open, setOpen] = createSignal(false)
276
+ return <tr key={row.id}><td>{row.label}</td></tr>`,
277
+ ],
278
+ [
279
+ 'a mutable local',
280
+ ` let cls = ''
281
+ return <tr key={row.id} className={cls}><td>{row.label}</td></tr>`,
282
+ ],
283
+ [
284
+ 'a preamble local read in child position (a preamble-patched region)',
285
+ ` const full = row.label + '!'
286
+ return <tr key={row.id}><td>{full}</td></tr>`,
287
+ ],
288
+ ]
289
+
290
+ for (const [name, body] of cases) {
291
+ test(name, () => {
292
+ const js = clientJs(rows(body), 'EagerRows.tsx')
293
+ expect(js).not.toContain('mapArrayLazy(')
294
+ expect(js).toMatch(/\bmapArray\(/)
295
+ })
296
+ }
297
+ })
@@ -48,12 +48,12 @@ function makeShape(overrides: Partial<LazyRowShapeFacts> = {}): LazyRowShapeFact
48
48
  anchored: false,
49
49
  bodyIsMultiRoot: false,
50
50
  hasExplicitKey: true,
51
- conditionalCount: 0,
51
+ conditionalRefusal: null,
52
52
  childRefCount: 0,
53
53
  nestedComponentCount: 0,
54
54
  innerLoopCount: 0,
55
55
  hasChildComponent: false,
56
- hasMapPreamble: false,
56
+ mapPreambleRefusal: null,
57
57
  preambleRegionCount: 0,
58
58
  hasParamUnwrap: false,
59
59
  ...overrides,
@@ -78,6 +78,7 @@ const itemBinding = (kind: 'attr' | 'text' = 'text') => ({
78
78
  reactiveOuterNames: [] as string[],
79
79
  opaqueOuterNames: [] as string[],
80
80
  referencesIndex: false,
81
+ readsPreamble: false,
81
82
  })
82
83
 
83
84
  // --- eligibility ------------------------------------------------------------
@@ -120,12 +121,16 @@ describe('lazyRowEligibility — shape refusals', () => {
120
121
  ['anchored whole-item conditional', { anchored: true }, /anchored/],
121
122
  ['multi-root row', { bodyIsMultiRoot: true }, /multi-root/],
122
123
  ['index-keyed loop', { hasExplicitKey: false }, /index-keyed/],
123
- ['reactive conditional in the row', { conditionalCount: 1 }, /reactive conditional/],
124
+ [
125
+ 'a conditional the apply bodies cannot drive',
126
+ { conditionalRefusal: 'conditional on slot s9: its true arm owns events' },
127
+ /its true arm owns events/,
128
+ ],
124
129
  ['imperative child ref', { childRefCount: 1 }, /child refs/],
125
130
  ['child-component body', { hasChildComponent: true }, /child component/],
126
131
  ['nested child components', { nestedComponentCount: 1 }, /nested child components/],
127
132
  ['inner loop', { innerLoopCount: 1 }, /inner loop/],
128
- ['map-callback preamble', { hasMapPreamble: true }, /preamble/],
133
+ ['unsafe map-callback preamble', { mapPreambleRefusal: 'map-callback preamble declares a mutable binding (let/var)' }, /mutable binding/],
129
134
  ['preamble-patched regions', { preambleRegionCount: 1 }, /preamble-patched regions/],
130
135
  ['destructured param without bindings', { hasParamUnwrap: true }, /destructured loop param/],
131
136
  ]
@@ -422,7 +427,7 @@ describe('lazy row emission — eligible loop', () => {
422
427
 
423
428
  test('applyItem claims refs lazily and dedups against entry.last', () => {
424
429
  const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf('applyOuter:'))
425
- expect(applyItem).toContain('__e.refs ?? (__e.refs = __lzc_l0(__e))')
430
+ expect(applyItem).toContain('__e.refs ?? (__e.refs = [])')
426
431
  expect(applyItem).toContain('!Object.is(__l[1], __x)')
427
432
  })
428
433
 
@@ -439,9 +444,20 @@ describe('lazy row emission — eligible loop', () => {
439
444
  expect(applyOuter).toContain("__seed ? (__t.getAttribute('class') !== (__x != null ? String(__x) : null))")
440
445
  })
441
446
 
442
- test('the lazy claim helper scans inside ONE row and is shared by both apply paths', () => {
443
- expect(js).toContain('const __lzc_l0 = (__e) => {')
444
- expect(js).toContain('const __el = __e.primaryEl')
447
+ /**
448
+ * An adopted row claims PER SLOT, on first use, scanning inside that one
449
+ * row. There is no whole-row claim closure: the previous shape made an
450
+ * `applyOuter` driving one attribute run a `qsa` for every OTHER
451
+ * reactive-attr slot in the row, on every row, at hydration.
452
+ *
453
+ * The `in` test rather than `??` is load-bearing — a slot whose scan found
454
+ * nothing must record that and not re-scan on the next tick.
455
+ */
456
+ test('an adopted row claims each element ref on first use, not the whole row', () => {
457
+ expect(js).not.toContain('__lzc_l0')
458
+ const applyOuter = js.slice(js.indexOf('applyOuter:'))
459
+ // refIndex 0 (the row's only reactive-attr slot), slot id `s2`.
460
+ expect(applyOuter).toContain(`0 in __r ? __r[0] : (__r[0] = qsa(__e.primaryEl, '[bf="s2"]'))`)
445
461
  })
446
462
 
447
463
  /**
@@ -459,19 +475,20 @@ describe('lazy row emission — eligible loop', () => {
459
475
  })
460
476
 
461
477
  /**
462
- * The adopted-row claim resolves ELEMENT refs only; the content door's slot
463
- * is left empty for the first content write to fill. `applyOuter` here
464
- * drives one CLASS and no text, so it claims all 1,000 rows of a 1,000-row
465
- * list at seed WITHOUT ever building a door the allocation this pins away
466
- * (measured on the SSR bench: post-hydration heap 1630.6KB -> 1573.2KB).
478
+ * An adopted row builds NOTHING up front not the element refs and not the
479
+ * content door. `applyOuter` here drives one CLASS and no text, so across a
480
+ * 1,000-row list it claims one element per row and never builds a door
481
+ * (the allocation the deferred door first pinned away: post-hydration heap
482
+ * 1630.6KB -> 1573.2KB), and never scans for a slot it does not write.
467
483
  *
468
- * `createRow` is deliberately excluded: it writes every text on the tick it
469
- * builds the row, so its door is used immediately and stays eager.
484
+ * `createRow` is deliberately excluded: it writes every binding on the tick
485
+ * it builds the row, so its refs come from known clone paths and its door is
486
+ * used immediately.
470
487
  */
471
- test('the adopted-row claim leaves the content door slot empty', () => {
472
- const claim = js.slice(js.indexOf('const __lzc_l0 ='), js.indexOf('mapArrayLazy('))
473
- expect(claim).toContain('return [qsa(__el, \'[bf="s2"]\'), null]')
474
- expect(claim).not.toContain('lazySlots(')
488
+ test('an adopted row allocates nothing up front', () => {
489
+ const applyOuter = js.slice(js.indexOf('applyOuter:'))
490
+ expect(applyOuter).toContain('__e.refs ?? (__e.refs = [])')
491
+ expect(applyOuter).not.toContain('lazySlots(')
475
492
  })
476
493
 
477
494
  test('an attr-only applyOuter never materializes the door; applyItem does', () => {
@@ -568,16 +585,68 @@ describe('lazy row emission — outer-involving TEXT binding (§9.5 lifted)', ()
568
585
  })
569
586
 
570
587
  /**
571
- * This row has no reactive ATTRIBUTE, so with the door deferred the element
572
- * refs are gone and the adopted claim is a bare `[null]` at which point
573
- * binding the row root is dead code. Pinned because the binding is emitted
574
- * unconditionally the moment anyone forgets that `refParts` decides whether
575
- * anything reads it.
588
+ * This row has no reactive ATTRIBUTE, so an adopted row's only claim is the
589
+ * content door and that is materialized by the first content write, not up
590
+ * front. Pinned because the whole-row claim closure this replaced emitted a
591
+ * dead `const __el = __e.primaryEl` for exactly this shape.
576
592
  */
577
- test('a text-only adopted claim binds no row root', () => {
578
- const claim = js.slice(js.indexOf('const __lzc_l0 ='), js.indexOf('mapArrayLazy('))
579
- expect(claim).toContain('return [null]')
580
- expect(claim).not.toContain('__el')
593
+ test('a text-only adopted row claims only the door, on first write', () => {
594
+ expect(js).not.toContain('__lzc_l0')
595
+ const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf('applyOuter:'))
596
+ expect(applyItem).toContain('__r[0] ?? (__r[0] = lazyClaimSlots(__e.primaryEl, __lzs_l0))')
597
+ })
598
+ })
599
+
600
+ /**
601
+ * Per-slot claiming, counted (§9 W2).
602
+ *
603
+ * A row with THREE reactive-attr slots where `applyOuter` drives exactly one:
604
+ * the whole-row claim closure this replaced resolved all three on every row at
605
+ * hydration, so a 1,000-row list ran 3,000 `qsa` scans to write 1,000
606
+ * attributes. With each binding claiming its own slot, `applyOuter` contains
607
+ * ONE scan and the count is 1,000.
608
+ *
609
+ * Counted rather than timed on purpose: the existing SSR bench's row has a
610
+ * single reactive-attr slot, so it cannot show this — its `applyOuter` already
611
+ * claimed exactly what it wrote. The scan count is the thing that changed, and
612
+ * it is deterministic.
613
+ */
614
+ const MULTI_SLOT = `
615
+ 'use client'
616
+ import { createSignal } from '@barefootjs/client'
617
+ type Row = { id: number; label: string }
618
+ export function MultiSlotRows(props: { rows: Row[] }) {
619
+ const [rows, setRows] = createSignal<Row[]>(props.rows)
620
+ const [selected, setSelected] = createSignal(0)
621
+ return (
622
+ <tbody>
623
+ {rows().map(row => (
624
+ <tr key={row.id} className={selected() === row.id ? 'danger' : ''}>
625
+ <td title={row.label}>{row.id}</td>
626
+ <td data-x={row.id}>{row.label}</td>
627
+ </tr>
628
+ ))}
629
+ </tbody>
630
+ )
631
+ }
632
+ `
633
+
634
+ describe('lazy row claiming — one scan per slot actually written', () => {
635
+ const js = clientJs(MULTI_SLOT, 'MultiSlotRows.tsx')
636
+ const section = (from: string, to?: string) =>
637
+ js.slice(js.indexOf(from), to ? js.indexOf(to) : js.indexOf("}, 'l0')"))
638
+ const scans = (text: string) => text.split('qsa(').length - 1
639
+
640
+ test('the row really has three reactive-attr slots', () => {
641
+ expect(scans(section('applyItem:', 'applyOuter:'))).toBe(3)
642
+ })
643
+
644
+ test('applyOuter scans only the slot it writes', () => {
645
+ expect(scans(section('applyOuter:'))).toBe(1)
646
+ })
647
+
648
+ test('createRow resolves refs from clone paths, never by scanning', () => {
649
+ expect(scans(section('createRow:', 'applyItem:'))).toBe(0)
581
650
  })
582
651
  })
583
652