@barefootjs/jsx 0.26.4 → 0.28.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 (106) hide show
  1. package/dist/compiler.d.ts.map +1 -1
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +941 -130
  5. package/dist/ir-to-client-js/client-only-elision.d.ts +103 -0
  6. package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -0
  7. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
  9. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts +2 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts +7 -0
  13. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +107 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +9 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +203 -0
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -0
  20. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/shared.d.ts +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/branch-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts +54 -0
  25. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts.map +1 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +98 -0
  29. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -0
  30. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +5 -0
  31. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +17 -20
  33. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  34. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +63 -4
  35. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/control-flow.d.ts +1 -1
  37. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/element-refs.d.ts +8 -3
  39. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/emit-reactive.d.ts +8 -1
  41. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/html-template.d.ts +24 -0
  43. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  44. package/dist/ir-to-client-js/imports.d.ts +2 -2
  45. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  46. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  47. package/dist/ir-to-client-js/types.d.ts +9 -1
  48. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  49. package/dist/ir-to-client-js/utils.d.ts +1 -1
  50. package/dist/types.d.ts +25 -2
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +2 -2
  53. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +542 -689
  54. package/src/__tests__/child-components-in-map.test.ts +4 -4
  55. package/src/__tests__/client-js-generation.test.ts +23 -12
  56. package/src/__tests__/composite-branch-loop.test.ts +14 -5
  57. package/src/__tests__/conditional-mapArray-key.test.ts +7 -1
  58. package/src/__tests__/create-selector.test.ts +21 -7
  59. package/src/__tests__/cross-file-client-signal.test.ts +5 -1
  60. package/src/__tests__/early-return-scope-var-ref.test.ts +10 -11
  61. package/src/__tests__/inline-jsx-callback.test.ts +6 -4
  62. package/src/__tests__/ir-jsx-props.test.ts +6 -5
  63. package/src/__tests__/lazy-row-eligibility.test.ts +692 -0
  64. package/src/__tests__/loop-branch-bare-expression-reactive-text.test.ts +98 -0
  65. package/src/__tests__/loop-fallback-wrap.test.ts +34 -16
  66. package/src/__tests__/loop-hoisted-template.test.ts +10 -6
  67. package/src/__tests__/module-level-signal.audit.test.ts +7 -2
  68. package/src/__tests__/nested-loop-conditional.test.ts +9 -7
  69. package/src/__tests__/nested-loop-reactive-attrs.test.ts +2 -2
  70. package/src/__tests__/preamble-region-patch.test.ts +18 -12
  71. package/src/__tests__/profile-cond-binding-ids.test.ts +3 -2
  72. package/src/__tests__/profile-loop-binding-ids.test.ts +4 -3
  73. package/src/__tests__/profile-nested-binding-ids.test.ts +11 -6
  74. package/src/__tests__/reactive-attrs-in-map.test.ts +6 -4
  75. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -1
  76. package/src/__tests__/template-closure.test.ts +1 -1
  77. package/src/compiler.ts +11 -0
  78. package/src/index.ts +1 -0
  79. package/src/ir-to-client-js/client-only-elision.ts +273 -0
  80. package/src/ir-to-client-js/collect-elements.ts +56 -17
  81. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
  82. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +35 -12
  83. package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
  84. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +305 -0
  85. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +44 -12
  86. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +440 -0
  87. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -0
  88. package/src/ir-to-client-js/control-flow/shared.ts +1 -1
  89. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +34 -4
  90. package/src/ir-to-client-js/control-flow/stringify/claim-plan.ts +68 -0
  91. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +18 -9
  92. package/src/ir-to-client-js/control-flow/stringify/insert.ts +17 -11
  93. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +478 -0
  94. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +41 -17
  95. package/src/ir-to-client-js/control-flow/stringify/loop.ts +71 -46
  96. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +241 -31
  97. package/src/ir-to-client-js/control-flow.ts +8 -3
  98. package/src/ir-to-client-js/element-refs.ts +9 -11
  99. package/src/ir-to-client-js/emit-reactive.ts +50 -14
  100. package/src/ir-to-client-js/html-template.ts +29 -9
  101. package/src/ir-to-client-js/imports.ts +13 -4
  102. package/src/ir-to-client-js/reactivity.ts +3 -2
  103. package/src/ir-to-client-js/types.ts +10 -2
  104. package/src/ir-to-client-js/utils.ts +1 -1
  105. package/src/jsx-to-ir.ts +26 -8
  106. package/src/types.ts +25 -2
@@ -0,0 +1,692 @@
1
+ /**
2
+ * BarefootJS Compiler — lazy row graph eligibility + emission
3
+ * (`spec/slot-unification.md` §9, L3).
4
+ *
5
+ * Two layers:
6
+ *
7
+ * 1. `lazyRowEligibility` unit tests — the explicit decision function. One
8
+ * test per ineligibility reason so a future gate change surfaces as a
9
+ * single named failure instead of a generated-JS diff. The eligible case
10
+ * is pinned too, so a gate that accidentally refuses EVERYTHING (the
11
+ * silent way this feature dies) fails loudly.
12
+ * 2. Emitted-shape assertions — an eligible loop must emit `mapArrayLazy`
13
+ * with the pinned plan shape, and an ineligible loop must keep the eager
14
+ * `mapArray` + renderItem emission. Sound-or-loud: exactly two outcomes.
15
+ */
16
+
17
+ import { describe, test, expect } from 'bun:test'
18
+ import {
19
+ classifyLazyBinding,
20
+ lazyRowEligibility,
21
+ type LazyRowEligibilityArgs,
22
+ type LazyRowScopeInfo,
23
+ type LazyRowShapeFacts,
24
+ } from '../ir-to-client-js/control-flow/plan/lazy-row-eligibility'
25
+ import { compileJSX } from '../compiler'
26
+ import { TestAdapter } from '../adapters/test-adapter'
27
+
28
+ // --- fixtures ---------------------------------------------------------------
29
+
30
+ function makeScope(overrides: Partial<LazyRowScopeInfo> = {}): LazyRowScopeInfo {
31
+ return {
32
+ // `rows` is a signal whose initializer is a literal (`[]`) — the
33
+ // canonical §9.3(2)-passing source.
34
+ signals: new Map([['rows', { initializerFreeIdentifiers: new Set<string>() }]]),
35
+ memos: new Set<string>(),
36
+ props: new Set<string>(['_p', 'items']),
37
+ constants: new Map<string, ReadonlySet<string> | null>(),
38
+ inert: new Set<string>(['clsx']),
39
+ profile: false,
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ function makeShape(overrides: Partial<LazyRowShapeFacts> = {}): LazyRowShapeFacts {
45
+ return {
46
+ callSite: 'plain',
47
+ flatMapLeafItem: false,
48
+ anchored: false,
49
+ bodyIsMultiRoot: false,
50
+ hasExplicitKey: true,
51
+ conditionalCount: 0,
52
+ childRefCount: 0,
53
+ nestedComponentCount: 0,
54
+ innerLoopCount: 0,
55
+ hasChildComponent: false,
56
+ hasMapPreamble: false,
57
+ preambleRegionCount: 0,
58
+ hasParamUnwrap: false,
59
+ ...overrides,
60
+ }
61
+ }
62
+
63
+ function args(overrides: Partial<LazyRowEligibilityArgs> = {}): LazyRowEligibilityArgs {
64
+ return {
65
+ shape: makeShape(),
66
+ bindings: [],
67
+ arraySourceIdentifiers: new Set(['rows']),
68
+ scope: makeScope(),
69
+ ...overrides,
70
+ }
71
+ }
72
+
73
+ const itemBinding = (kind: 'attr' | 'text' = 'text') => ({
74
+ kind,
75
+ slotId: 's1',
76
+ readsItem: true,
77
+ readsOuter: false,
78
+ reactiveOuterNames: [] as string[],
79
+ opaqueOuterNames: [] as string[],
80
+ referencesIndex: false,
81
+ })
82
+
83
+ // --- eligibility ------------------------------------------------------------
84
+
85
+ describe('lazyRowEligibility — eligible', () => {
86
+ test('a keyed, single-root, conditional-free plain row over a literal-seeded signal is eligible', () => {
87
+ expect(lazyRowEligibility(args())).toEqual({ eligible: true })
88
+ })
89
+
90
+ test('a mixed item+outer attr binding on a primable signal stays eligible', () => {
91
+ const decision = lazyRowEligibility(args({
92
+ bindings: [{
93
+ kind: 'attr',
94
+ slotId: 's3',
95
+ readsItem: true,
96
+ readsOuter: true,
97
+ reactiveOuterNames: ['selected'],
98
+ opaqueOuterNames: [],
99
+ referencesIndex: false,
100
+ }],
101
+ scope: makeScope({
102
+ signals: new Map([
103
+ ['rows', { initializerFreeIdentifiers: new Set<string>() }],
104
+ ['selected', { initializerFreeIdentifiers: new Set<string>() }],
105
+ ]),
106
+ }),
107
+ }))
108
+ expect(decision).toEqual({ eligible: true })
109
+ })
110
+
111
+ test('branch-scoped plain rows are in scope too', () => {
112
+ expect(lazyRowEligibility(args({ shape: makeShape({ callSite: 'branch-plain' }) })))
113
+ .toEqual({ eligible: true })
114
+ })
115
+ })
116
+
117
+ describe('lazyRowEligibility — shape refusals', () => {
118
+ const cases: Array<[string, Partial<LazyRowShapeFacts>, RegExp]> = [
119
+ ['flatMap descriptor loop', { flatMapLeafItem: true }, /flatMap/],
120
+ ['anchored whole-item conditional', { anchored: true }, /anchored/],
121
+ ['multi-root row', { bodyIsMultiRoot: true }, /multi-root/],
122
+ ['index-keyed loop', { hasExplicitKey: false }, /index-keyed/],
123
+ ['reactive conditional in the row', { conditionalCount: 1 }, /reactive conditional/],
124
+ ['imperative child ref', { childRefCount: 1 }, /child refs/],
125
+ ['child-component body', { hasChildComponent: true }, /child component/],
126
+ ['nested child components', { nestedComponentCount: 1 }, /nested child components/],
127
+ ['inner loop', { innerLoopCount: 1 }, /inner loop/],
128
+ ['map-callback preamble', { hasMapPreamble: true }, /preamble/],
129
+ ['preamble-patched regions', { preambleRegionCount: 1 }, /preamble-patched regions/],
130
+ ['destructured param without bindings', { hasParamUnwrap: true }, /destructured loop param/],
131
+ ]
132
+ for (const [name, shapeOverride, reason] of cases) {
133
+ test(name, () => {
134
+ const decision = lazyRowEligibility(args({ shape: makeShape(shapeOverride) }))
135
+ expect(decision.eligible).toBe(false)
136
+ expect((decision as { reason: string }).reason).toMatch(reason)
137
+ })
138
+ }
139
+ })
140
+
141
+ describe('lazyRowEligibility — profile mode', () => {
142
+ test('profile mode is never lazy, even for an otherwise perfect row', () => {
143
+ const decision = lazyRowEligibility(args({ scope: makeScope({ profile: true }) }))
144
+ expect(decision.eligible).toBe(false)
145
+ expect((decision as { reason: string }).reason).toMatch(/profile mode/)
146
+ })
147
+ })
148
+
149
+ describe('lazyRowEligibility — binding refusals', () => {
150
+ test('a binding referencing the loop index parameter is refused', () => {
151
+ const decision = lazyRowEligibility(args({
152
+ bindings: [{ ...itemBinding(), referencesIndex: true }],
153
+ }))
154
+ expect(decision.eligible).toBe(false)
155
+ expect((decision as { reason: string }).reason).toMatch(/index parameter/)
156
+ })
157
+
158
+ // §9.3a, LIFTED: an outer name the emitter cannot prime no longer sinks
159
+ // the loop. The runtime's unconditional re-subscribe seam keeps
160
+ // the loop-level effect subscribed across reconciles, which is the
161
+ // obligation priming used to carry. Inverted from the refusal it replaces
162
+ // so a gate that starts refusing again fails here by name.
163
+ test('an unprimable outer dependency (a prop accessor) is ELIGIBLE via the seam', () => {
164
+ const decision = lazyRowEligibility(args({
165
+ bindings: [{
166
+ kind: 'attr',
167
+ slotId: 's3',
168
+ readsItem: false,
169
+ readsOuter: true,
170
+ reactiveOuterNames: [],
171
+ opaqueOuterNames: ['_p'],
172
+ referencesIndex: false,
173
+ }],
174
+ }))
175
+ expect(decision).toEqual({ eligible: true })
176
+ })
177
+
178
+ // §9.5, LIFTED: content slots now have a DOM read-back
179
+ // (`lazyClaimSlots(...).read(id)`), so an outer-involving text binding no
180
+ // longer sinks the loop. This test is the inverse of the refusal it
181
+ // replaces — if the gate ever starts refusing again, it fails here rather
182
+ // than as a silent eager-fallback regression.
183
+ test('an outer-involving TEXT binding is ELIGIBLE (content slots have a DOM read-back)', () => {
184
+ const decision = lazyRowEligibility(args({
185
+ bindings: [{
186
+ kind: 'text',
187
+ slotId: 's1',
188
+ readsItem: true,
189
+ readsOuter: true,
190
+ reactiveOuterNames: ['selected'],
191
+ opaqueOuterNames: [],
192
+ referencesIndex: false,
193
+ }],
194
+ scope: makeScope({
195
+ signals: new Map([
196
+ ['rows', { initializerFreeIdentifiers: new Set<string>() }],
197
+ ['selected', { initializerFreeIdentifiers: new Set<string>() }],
198
+ ]),
199
+ }),
200
+ }))
201
+ expect(decision).toEqual({ eligible: true })
202
+ })
203
+
204
+ test('an OPAQUE outer name on a TEXT binding is eligible too (seam + read door)', () => {
205
+ // Both former limits at once: an opaque outer read (§9.3a, covered by
206
+ // the seam) on a content slot (§9.5, covered by the read door).
207
+ const decision = lazyRowEligibility(args({
208
+ bindings: [{
209
+ kind: 'text',
210
+ slotId: 's1',
211
+ readsItem: true,
212
+ readsOuter: true,
213
+ reactiveOuterNames: [],
214
+ opaqueOuterNames: ['isSelected'],
215
+ referencesIndex: false,
216
+ }],
217
+ }))
218
+ expect(decision).toEqual({ eligible: true })
219
+ })
220
+ })
221
+
222
+ describe('lazyRowEligibility — §9.3(2) loop-source consistency gate', () => {
223
+ test('absent arrayFreeIdentifiers is refused, never assumed empty', () => {
224
+ const decision = lazyRowEligibility(args({ arraySourceIdentifiers: null }))
225
+ expect(decision.eligible).toBe(false)
226
+ expect((decision as { reason: string }).reason).toMatch(/free identifiers unavailable/)
227
+ })
228
+
229
+ test('a prop-derived source passes', () => {
230
+ expect(lazyRowEligibility(args({ arraySourceIdentifiers: new Set(['items']) })))
231
+ .toEqual({ eligible: true })
232
+ })
233
+
234
+ test('a signal with NO structured initializer is unprovable → refused', () => {
235
+ const decision = lazyRowEligibility(args({
236
+ scope: makeScope({ signals: new Map([['rows', { initializerFreeIdentifiers: null }]]) }),
237
+ }))
238
+ expect(decision.eligible).toBe(false)
239
+ expect((decision as { reason: string }).reason).toMatch(/no structured initializer/)
240
+ })
241
+
242
+ test('a signal seeded from a props/literal chain passes transitively', () => {
243
+ const decision = lazyRowEligibility(args({
244
+ scope: makeScope({
245
+ signals: new Map([['rows', { initializerFreeIdentifiers: new Set(['seed']) }]]),
246
+ constants: new Map<string, ReadonlySet<string> | null>([['seed', new Set(['items'])]]),
247
+ }),
248
+ }))
249
+ expect(decision).toEqual({ eligible: true })
250
+ })
251
+
252
+ test('a signal seeded from an ENVIRONMENT read is refused', () => {
253
+ const decision = lazyRowEligibility(args({
254
+ scope: makeScope({
255
+ signals: new Map([['rows', { initializerFreeIdentifiers: new Set(['localStorage']) }]]),
256
+ }),
257
+ }))
258
+ expect(decision.eligible).toBe(false)
259
+ expect((decision as { reason: string }).reason).toMatch(/localStorage/)
260
+ })
261
+
262
+ test('an imported name in the source is refused', () => {
263
+ const decision = lazyRowEligibility(args({ arraySourceIdentifiers: new Set(['clsx']) }))
264
+ expect(decision.eligible).toBe(false)
265
+ expect((decision as { reason: string }).reason).toMatch(/import or local function/)
266
+ })
267
+
268
+ test('a memo source is refused by the v1 gate', () => {
269
+ const decision = lazyRowEligibility(args({
270
+ arraySourceIdentifiers: new Set(['sorted']),
271
+ scope: makeScope({ memos: new Set(['sorted']) }),
272
+ }))
273
+ expect(decision.eligible).toBe(false)
274
+ expect((decision as { reason: string }).reason).toMatch(/memo 'sorted'/)
275
+ })
276
+
277
+ test('a constant with no analyzable value is refused', () => {
278
+ const decision = lazyRowEligibility(args({
279
+ arraySourceIdentifiers: new Set(['table']),
280
+ scope: makeScope({ constants: new Map([['table', null]]) }),
281
+ }))
282
+ expect(decision.eligible).toBe(false)
283
+ expect((decision as { reason: string }).reason).toMatch(/no analyzable value/)
284
+ })
285
+ })
286
+
287
+ describe('classifyLazyBinding — fail-safe', () => {
288
+ const scope = makeScope({
289
+ signals: new Map([
290
+ ['rows', { initializerFreeIdentifiers: new Set<string>() }],
291
+ ['selected', { initializerFreeIdentifiers: new Set<string>() }],
292
+ ]),
293
+ })
294
+ const base = { slotId: 's1', rowLocalNames: new Set(['row']), indexParam: '__idx', scope }
295
+
296
+ test('unavailable free identifiers force BOTH readsItem and readsOuter, and mark the binding opaque', () => {
297
+ const c = classifyLazyBinding({ kind: 'attr', free: null, ...base })
298
+ expect(c.readsItem).toBe(true)
299
+ expect(c.readsOuter).toBe(true)
300
+ expect(c.opaqueOuterNames).toEqual(['<unknown>'])
301
+ // …and the gate STILL refuses, even though §9.3a lifted the refusal
302
+ // for opaque NAMES. A name means the identifier set is known and only
303
+ // its primability is not — the seam handles that. This means the set
304
+ // itself is unknown, so `referencesIndex: false` above is an assumption
305
+ // rather than a fact, and neither applyItem nor applyOuter has an index
306
+ // parameter to fall back on.
307
+ const decision = lazyRowEligibility(args({ bindings: [c], scope }))
308
+ expect(decision.eligible).toBe(false)
309
+ expect((decision as { reason: string }).reason).toMatch(/no analyzable identifier set/)
310
+ })
311
+
312
+ test('a pure item read is item-only', () => {
313
+ const c = classifyLazyBinding({ kind: 'text', free: new Set(['row']), ...base })
314
+ expect(c).toMatchObject({ readsItem: true, readsOuter: false, reactiveOuterNames: [], opaqueOuterNames: [] })
315
+ })
316
+
317
+ test('a signal read is a primable reactive-outer dependency', () => {
318
+ const c = classifyLazyBinding({ kind: 'attr', free: new Set(['row', 'selected']), ...base })
319
+ expect(c).toMatchObject({ readsItem: true, readsOuter: true, reactiveOuterNames: ['selected'], opaqueOuterNames: [] })
320
+ })
321
+
322
+ test('pure globals and literal-derived consts need no subscription and are not outer', () => {
323
+ const litScope = makeScope({
324
+ signals: scope.signals,
325
+ constants: new Map<string, ReadonlySet<string> | null>([['SIZES', new Set<string>()]]),
326
+ })
327
+ const c = classifyLazyBinding({
328
+ kind: 'attr', free: new Set(['row', 'Math', 'SIZES']), ...base, scope: litScope,
329
+ })
330
+ expect(c).toMatchObject({ readsItem: true, readsOuter: false, opaqueOuterNames: [] })
331
+ })
332
+
333
+ test('an import, a local function, and a non-literal const are all OPAQUE, not inert', () => {
334
+ // A reactive accessor hides behind ordinary-looking names — the
335
+ // `createSelector` const is the canonical case. Guessing "inert" here
336
+ // would emit an applyOuter effect that reads them non-reactively.
337
+ const selScope = makeScope({
338
+ signals: scope.signals,
339
+ constants: new Map<string, ReadonlySet<string> | null>([['isSelected', new Set(['createSelector', 'selected'])]]),
340
+ inert: new Set(['clsx']),
341
+ })
342
+ const c = classifyLazyBinding({
343
+ kind: 'attr', free: new Set(['row', 'isSelected', 'clsx']), ...base, scope: selScope,
344
+ })
345
+ expect(c.readsOuter).toBe(true)
346
+ expect(c.opaqueOuterNames.sort()).toEqual(['clsx', 'isSelected'])
347
+ // Opaque NAMES no longer refuse the loop (§9.3a lifted) — the
348
+ // identifier set is known here, so only primability was missing and the
349
+ // runtime seam supplies that unconditionally. They still land in
350
+ // `opaqueOuterNames` so a refusal elsewhere can name them precisely.
351
+ expect(lazyRowEligibility(args({ bindings: [c], scope: selScope })).eligible).toBe(true)
352
+ })
353
+
354
+ test('the index parameter is flagged, not silently treated as outer', () => {
355
+ const c = classifyLazyBinding({ kind: 'text', free: new Set(['__idx']), ...base })
356
+ expect(c.referencesIndex).toBe(true)
357
+ })
358
+ })
359
+
360
+ // --- emitted shape ----------------------------------------------------------
361
+
362
+ function clientJs(source: string, file: string): string {
363
+ const result = compileJSX(source, file, { adapter: new TestAdapter() })
364
+ const js = result.files.find(f => f.path.endsWith('.client.js'))
365
+ if (!js) throw new Error(`no client JS emitted for ${file}`)
366
+ return js.content
367
+ }
368
+
369
+ const ELIGIBLE = `
370
+ 'use client'
371
+ import { createSignal } from '@barefootjs/client'
372
+ type Row = { id: number; label: string }
373
+ export function LazyRows() {
374
+ const [rows, setRows] = createSignal<Row[]>([])
375
+ const [selected, setSelected] = createSignal(0)
376
+ return (
377
+ <tbody>
378
+ {rows().map(row => (
379
+ <tr key={row.id} className={selected() === row.id ? 'danger' : ''}>
380
+ <td>{row.id}</td>
381
+ <td>{row.label}</td>
382
+ </tr>
383
+ ))}
384
+ </tbody>
385
+ )
386
+ }
387
+ `
388
+
389
+ describe('lazy row emission — eligible loop', () => {
390
+ const js = clientJs(ELIGIBLE, 'LazyRows.tsx')
391
+
392
+ test('emits mapArrayLazy, not mapArray', () => {
393
+ expect(js).toContain('mapArrayLazy(')
394
+ expect(js).not.toMatch(/\bmapArray\(/)
395
+ })
396
+
397
+ test('imports mapArrayLazy from the runtime', () => {
398
+ expect(js).toMatch(/import \{[^}]*\bmapArrayLazy\b[^}]*\} from '@barefootjs\/client\/runtime'/)
399
+ })
400
+
401
+ test('emits all three plan members with the pinned signatures', () => {
402
+ expect(js).toContain('createRow: (__e, __idx) => {')
403
+ expect(js).toContain('applyItem: (__e) => {')
404
+ expect(js).toContain('applyOuter: (__es, __seed) => {')
405
+ })
406
+
407
+ test('rows carry no per-row reactive resources', () => {
408
+ const planBody = js.slice(js.indexOf('mapArrayLazy('), js.indexOf("}, 'l0')"))
409
+ expect(planBody).not.toContain('createEffect')
410
+ expect(planBody).not.toContain('createRoot')
411
+ expect(planBody).not.toContain('createSignal')
412
+ })
413
+
414
+ test('createRow writes every binding — item-driven AND outer-involving — and seeds refs/last', () => {
415
+ const createRow = js.slice(js.indexOf('createRow:'), js.indexOf('applyItem:'))
416
+ expect(createRow).toContain('__e.refs = [')
417
+ expect(createRow).toContain('__e.last = []')
418
+ expect(createRow).toContain("setAttribute('class'") // outer-involving
419
+ expect(createRow).toContain("('s0', textOrNode(__x))") // item text
420
+ expect(createRow).toContain("('s1', textOrNode(__x))") // item text
421
+ })
422
+
423
+ test('applyItem claims refs lazily and dedups against entry.last', () => {
424
+ const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf('applyOuter:'))
425
+ expect(applyItem).toContain('__e.refs ?? (__e.refs = __lzc_l0(__e))')
426
+ expect(applyItem).toContain('!Object.is(__l[1], __x)')
427
+ })
428
+
429
+ test('applyOuter primes its outer signal read before the row loop (§9 empty-list subscription)', () => {
430
+ const applyOuter = js.slice(js.indexOf('applyOuter:'))
431
+ const primeIdx = applyOuter.indexOf('\n selected()\n')
432
+ const loopIdx = applyOuter.indexOf('for (const __e of __es)')
433
+ expect(primeIdx).toBeGreaterThan(-1)
434
+ expect(loopIdx).toBeGreaterThan(primeIdx)
435
+ })
436
+
437
+ test('applyOuter seeds by read-compare-write (§9.3(1)), never a blind first write', () => {
438
+ const applyOuter = js.slice(js.indexOf('applyOuter:'))
439
+ expect(applyOuter).toContain("__seed ? (__t.getAttribute('class') !== (__x != null ? String(__x) : null))")
440
+ })
441
+
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')
445
+ })
446
+
447
+ /**
448
+ * No-regression pin for the cheap door. This loop's texts are all
449
+ * item-driven, so it must keep the single-closure `lazySlots` writer and
450
+ * the BARE call form — the read-capable door costs an extra closure on
451
+ * every row and may only be taken by loops that actually seed content.
452
+ */
453
+ test('an attr-only-outer loop keeps the write-only lazySlots door and the bare call form', () => {
454
+ expect(js).toContain('lazySlots(')
455
+ expect(js).not.toContain('lazyClaimSlots(')
456
+ expect(js).toContain("__r[1]('s0', textOrNode(__x))")
457
+ expect(js).not.toContain('.write(')
458
+ expect(js).not.toContain('.read(')
459
+ })
460
+
461
+ /**
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).
467
+ *
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.
470
+ */
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(')
475
+ })
476
+
477
+ test('an attr-only applyOuter never materializes the door; applyItem does', () => {
478
+ const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf('applyOuter:'))
479
+ const applyOuter = js.slice(js.indexOf('applyOuter:'))
480
+ expect(applyItem).toContain('const __d = __r[1] ?? (__r[1] = lazySlots(__e.primaryEl, __lzs_l0))')
481
+ expect(applyOuter).not.toContain('lazySlots(')
482
+ expect(applyOuter).not.toContain('__r[1]')
483
+ })
484
+
485
+ test('createRow still builds its door eagerly — it writes on the same tick', () => {
486
+ const createRow = js.slice(js.indexOf('createRow:'), js.indexOf('applyItem:'))
487
+ expect(createRow).toContain('lazySlots(__el, ')
488
+ expect(createRow).not.toContain('?? (__r[1] =')
489
+ })
490
+ })
491
+
492
+ /**
493
+ * §9.5 lifted: an outer-involving TEXT binding. The row renders an
494
+ * item-driven text alongside a text that depends on a component signal, so
495
+ * the loop needs the READ-capable claim door to seed the second one by
496
+ * read-compare-write instead of writing blindly at hydration.
497
+ */
498
+ const ELIGIBLE_OUTER_TEXT = `
499
+ 'use client'
500
+ import { createSignal } from '@barefootjs/client'
501
+ type Row = { id: number; label: string }
502
+ export function LazyOuterTextRows() {
503
+ const [rows, setRows] = createSignal<Row[]>([])
504
+ const [selected, setSelected] = createSignal(0)
505
+ return (
506
+ <tbody>
507
+ {rows().map(row => (
508
+ <tr key={row.id}>
509
+ <td>{row.label}</td>
510
+ <td>{String(selected() === row.id ? 'YES' : 'NO')}</td>
511
+ </tr>
512
+ ))}
513
+ </tbody>
514
+ )
515
+ }
516
+ `
517
+
518
+ describe('lazy row emission — outer-involving TEXT binding (§9.5 lifted)', () => {
519
+ const js = clientJs(ELIGIBLE_OUTER_TEXT, 'LazyOuterTextRows.tsx')
520
+
521
+ test('the loop is eligible — it is no longer refused for having an outer text', () => {
522
+ expect(js).toContain('mapArrayLazy(')
523
+ expect(js).not.toMatch(/\bmapArray\(/)
524
+ })
525
+
526
+ test('claims through the READ-capable door and imports it', () => {
527
+ expect(js).toContain('lazyClaimSlots(')
528
+ expect(js).not.toMatch(/\blazySlots\(/)
529
+ expect(js).toMatch(/import \{[^}]*\blazyClaimSlots\b[^}]*\} from '@barefootjs\/client\/runtime'/)
530
+ })
531
+
532
+ test('every text write goes through the door\'s .write() method', () => {
533
+ expect(js).toContain(".write('s0', textOrNode(__x))")
534
+ expect(js).toContain(".write('s1', textOrNode(__x))")
535
+ // The bare call form belongs to the write-only door and must not survive
536
+ // alongside the RW one.
537
+ expect(js).not.toContain("__r[0]('s0'")
538
+ expect(js).not.toContain("__r[0]('s1'")
539
+ })
540
+
541
+ test('applyOuter seeds the content slot by read-compare-write (§9.3(1))', () => {
542
+ const applyOuter = js.slice(js.indexOf('applyOuter:'))
543
+ // A real branch, not one ternary guard: the seed path binds the string
544
+ // ONCE and reuses it for the compare and the write, so a value with a
545
+ // side-effecting `toString` is not stringified twice.
546
+ expect(applyOuter).toContain('if (__seed) {')
547
+ expect(applyOuter).toContain('const __s = textOrNode(__x)')
548
+ // Through `__d`, the door materialized once for this body — a loop with an
549
+ // outer TEXT cannot avoid claiming at seed (you cannot read-compare-write
550
+ // what you have not resolved), so here the deferral is a no-op by design.
551
+ expect(applyOuter).toContain("const __d = __r[0] ?? (__r[0] = lazyClaimSlots(__e.primaryEl, __lzs_l0))")
552
+ expect(applyOuter).toContain("if (__d.read('s1') !== __s) __d.write('s1', __s)")
553
+ expect(applyOuter).not.toContain("read('s1') !== textOrNode(__x)")
554
+ // …and falls back to the ordinary entry.last dedup on every later run,
555
+ // where the string is built only when the write actually happens.
556
+ expect(applyOuter).toContain('} else if (!(1 in __l) || !Object.is(__l[1], __x))')
557
+ expect(applyOuter).toContain('__l[1] = __x')
558
+ })
559
+
560
+ test('the outer text ALSO applies on item change, because it reads the item too', () => {
561
+ const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf('applyOuter:'))
562
+ expect(applyItem).toContain("__d.write('s1', textOrNode(__x))")
563
+ })
564
+
565
+ test('the item-only text is NOT emitted into applyOuter', () => {
566
+ const applyOuter = js.slice(js.indexOf('applyOuter:'))
567
+ expect(applyOuter).not.toContain("'s0'")
568
+ })
569
+
570
+ /**
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.
576
+ */
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')
581
+ })
582
+ })
583
+
584
+ /**
585
+ * The gate's `index-keyed loop (no explicit key)` refusal is a FAIL-SAFE that
586
+ * no compiling program can reach: an unkeyed `.map()` row is BF023 upstream.
587
+ * Pinned here so the refusal is never mistaken for a widening opportunity —
588
+ * if unkeyed loops ever become legal, this test fails and says where to look.
589
+ */
590
+ describe('unkeyed loops never reach the gate', () => {
591
+ const UNKEYED = `
592
+ 'use client'
593
+ import { createSignal } from '@barefootjs/client'
594
+ export function UnkeyedRows() {
595
+ const [rows, setRows] = createSignal<string[]>([])
596
+ return <ul>{rows().map(row => <li>{row}</li>)}</ul>
597
+ }
598
+ `
599
+
600
+ test('an unkeyed .map() row is a BF023 compile error', () => {
601
+ const result = compileJSX(UNKEYED, 'UnkeyedRows.tsx', { adapter: new TestAdapter() })
602
+ const errors = result.errors.filter(e => e.severity === 'error')
603
+ expect(errors.map(e => e.code)).toContain('BF023')
604
+ })
605
+
606
+ test('a key whose VALUE is the index is an ordinary keyed loop and stays eligible', () => {
607
+ // `key={i}` is what the BF023 suggestion tells users to write for static
608
+ // lists. It is a real key expression, so `loop.key != null` and the gate's
609
+ // keying branch is not involved at all.
610
+ const js = clientJs(`
611
+ 'use client'
612
+ import { createSignal } from '@barefootjs/client'
613
+ export function IndexValueKeyed() {
614
+ const [rows, setRows] = createSignal<string[]>([])
615
+ return <ul>{rows().map((row, i) => <li key={i}>{row}</li>)}</ul>
616
+ }
617
+ `, 'IndexValueKeyed.tsx')
618
+ expect(js).toContain('mapArrayLazy(')
619
+ })
620
+ })
621
+
622
+ describe('lazy row emission — ineligible loops fall back', () => {
623
+
624
+ test('a row with a reactive conditional keeps the eager mapArray emission', () => {
625
+ const js = clientJs(`
626
+ 'use client'
627
+ import { createSignal } from '@barefootjs/client'
628
+ type Row = { id: number; label: string; done: boolean }
629
+ export function CondRows() {
630
+ const [rows, setRows] = createSignal<Row[]>([])
631
+ return (
632
+ <ul>
633
+ {rows().map(row => (
634
+ <li key={row.id}>{row.done ? <b>{row.label}</b> : <i>{row.label}</i>}</li>
635
+ ))}
636
+ </ul>
637
+ )
638
+ }
639
+ `, 'CondRows.tsx')
640
+ expect(js).toMatch(/\bmapArray\(/)
641
+ expect(js).not.toContain('mapArrayLazy(')
642
+ })
643
+
644
+ test('profile mode keeps the eager, per-binding-attributed emission', () => {
645
+ const result = compileJSX(ELIGIBLE, 'LazyRowsProfiled.tsx', { adapter: new TestAdapter(), profile: true })
646
+ const js = result.files.find(f => f.path.endsWith('.client.js'))!.content
647
+ expect(js).not.toContain('mapArrayLazy(')
648
+ expect(js).toMatch(/\bmapArray\(/)
649
+ })
650
+ })
651
+
652
+ /**
653
+ * Presence-or-undefined attributes are the one kind where SSR and the
654
+ * client writer legitimately disagree on the VALUE while agreeing on
655
+ * presence: SSR renders a bare attribute name (`templateAttrExpr`), which
656
+ * parses to `""`, while `emitAttrUpdate` writes `'true'` for `aria-*`. A
657
+ * presence-only seed comparison therefore skips a write it must perform.
658
+ */
659
+ const ELIGIBLE_ARIA = `
660
+ 'use client'
661
+ import { createSignal } from '@barefootjs/client'
662
+ type Row = { id: number; label: string }
663
+ export function LazyAriaRows() {
664
+ const [rows, setRows] = createSignal<Row[]>([])
665
+ const [selected, setSelected] = createSignal(0)
666
+ return (
667
+ <tbody>
668
+ {rows().map(row => (
669
+ <tr key={row.id} aria-selected={(selected() === row.id) || undefined}>
670
+ <td>{row.label}</td>
671
+ </tr>
672
+ ))}
673
+ </tbody>
674
+ )
675
+ }
676
+ `
677
+
678
+ describe('lazy row emission — presence-or-undefined seed comparison', () => {
679
+ const js = clientJs(ELIGIBLE_ARIA, 'LazyAriaRows.tsx')
680
+
681
+ test('the loop is eligible (guards the rest of this describe)', () => {
682
+ expect(js).toContain('mapArrayLazy(')
683
+ })
684
+
685
+ test('seeds an aria-* presence attr by VALUE, not by presence', () => {
686
+ // SSR renders `aria-selected` bare (value ""), the writer writes
687
+ // "true" — comparing `hasAttribute` would call those equal and leave
688
+ // the row at `aria-selected=""` forever.
689
+ expect(js).toContain(`getAttribute('aria-selected') !== (__x ? 'true' : null)`)
690
+ expect(js).not.toContain(`hasAttribute('aria-selected')`)
691
+ })
692
+ })