@barefootjs/jsx 0.27.0 → 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 (50) hide show
  1. package/dist/index.js +620 -26
  2. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
  3. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  4. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts +2 -1
  5. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts +7 -0
  7. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +107 -0
  9. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -0
  10. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +9 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +203 -0
  13. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -0
  14. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -0
  15. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/branch-loop.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +98 -0
  18. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -0
  19. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +5 -0
  20. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/imports.d.ts +2 -2
  24. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +162 -36
  27. package/src/__tests__/client-js-generation.test.ts +4 -1
  28. package/src/__tests__/composite-branch-loop.test.ts +12 -3
  29. package/src/__tests__/conditional-mapArray-key.test.ts +7 -1
  30. package/src/__tests__/create-selector.test.ts +21 -7
  31. package/src/__tests__/lazy-row-eligibility.test.ts +692 -0
  32. package/src/__tests__/loop-branch-bare-expression-reactive-text.test.ts +98 -0
  33. package/src/__tests__/loop-fallback-wrap.test.ts +31 -12
  34. package/src/__tests__/loop-hoisted-template.test.ts +10 -6
  35. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -1
  36. package/src/ir-to-client-js/collect-elements.ts +48 -13
  37. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
  38. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +35 -12
  39. package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
  40. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +305 -0
  41. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +44 -12
  42. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +440 -0
  43. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -0
  44. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +22 -0
  45. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +478 -0
  46. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +6 -1
  47. package/src/ir-to-client-js/control-flow/stringify/loop.ts +23 -0
  48. package/src/ir-to-client-js/control-flow.ts +7 -2
  49. package/src/ir-to-client-js/imports.ts +8 -2
  50. package/src/jsx-to-ir.ts +18 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Emission pin for the loop-branch-stale-text fix.
3
+ *
4
+ * `summarizeLoopChildBranch` (ir-to-client-js/collect-elements.ts) used to
5
+ * collect NO `reactiveTexts` for ANY loop-row conditional branch whose whole
6
+ * content is a bare `expression` (no wrapping element) — including a plain
7
+ * property read like `row.label`, which can never construct a live DOM
8
+ * node. That blanket skip meant a keyed row's branch text silently froze at
9
+ * its mount-time value whenever the item changed without the conditional's
10
+ * OWN condition flipping (`insert()`, runtime/insert.ts, correctly no-ops
11
+ * when its condition is unchanged — branch-internal updates are the effect
12
+ * system's job).
13
+ *
14
+ * The fix narrows the skip to `node.hasFunctionCalls` (jsx-to-ir.ts's
15
+ * `exprHasFunctionCalls`, a recursive AST walk — catches a call nested
16
+ * anywhere, not just a top-level one) — a call can return a live DOM node
17
+ * (a hoisted `renderNode={(n) => <Pill/>}` callback lowered to a component
18
+ * call, #1211/#1213, pinned by `nested-loop-conditional.test.ts`) and the
19
+ * compiler cannot tell from syntax whether it does, so that shape must keep
20
+ * the skip. Everything call-free is provably incapable of yielding a Node
21
+ * and is now collected.
22
+ *
23
+ * The companion half of the fix lives in `transformConditionalBranch`
24
+ * (jsx-to-ir.ts): a bare loop-item read like `row.label` previously got NO
25
+ * `slotId` at all (`needsSlot` didn't consider a `render-item` free
26
+ * reference), so even with the collect-elements.ts skip narrowed there was
27
+ * nothing for the new effect — or the `<!--bf:sN-->` marker
28
+ * `irToHtmlTemplate` emits for it — to attach to. Both are exercised here
29
+ * together because neither half alone reproduces the fix.
30
+ */
31
+ import { describe, test, expect } from 'bun:test'
32
+ import { compileJSX } from '../compiler'
33
+ import { TestAdapter } from '../adapters/test-adapter'
34
+
35
+ const adapter = new TestAdapter()
36
+
37
+ function clientJsFor(branchTrue: string): string {
38
+ const source = `
39
+ 'use client'
40
+ import { createSignal } from '@barefootjs/client'
41
+ type Props = { format?: (s: string) => string }
42
+ type Row = { id: number; label: string; done: boolean }
43
+ export function C(_p: Props) {
44
+ const [rows] = createSignal<Row[]>([])
45
+ return <ul>{rows().map(row => <li key={row.id}>{row.done ? ${branchTrue} : 'pending'}</li>)}</ul>
46
+ }
47
+ `
48
+ const result = compileJSX(source, 'C.tsx', { adapter })
49
+ const errors = result.errors.filter(e => e.severity === 'error')
50
+ if (errors.length > 0) throw new Error(errors.map(e => e.message).join('\n'))
51
+ return result.files.find(f => f.type === 'clientJs')!.content
52
+ }
53
+
54
+ describe('loop-branch-stale-text — reactiveTexts narrowing (collect-elements.ts)', () => {
55
+ test('a bare property-read branch (no call anywhere) gets its own text effect', () => {
56
+ const js = clientJsFor('row.label')
57
+ expect(js).toContain("escapeTextOrNode(row().label)")
58
+ // And a slotId was actually allocated for it — `transformConditionalBranch`'s
59
+ // `refsLoopParam` half of the fix, without which the effect above would
60
+ // have nothing to `lazySlots(...)` claim.
61
+ expect(js).toMatch(/lazySlots\(__branchScope, \[\{ id: 's\d+', kind: 'markup', path: \[\] \}\]\)/)
62
+ })
63
+
64
+ test('a template-literal branch built from property reads only (no call) also gets a text effect', () => {
65
+ const js = clientJsFor('`[${row.label}]`')
66
+ expect(js).toContain('escapeTextOrNode(`[${row().label}]`)')
67
+ })
68
+
69
+ test('a branch with a call ANYWHERE (top-level) keeps the skip — no per-item text effect', () => {
70
+ const js = clientJsFor('_p.format(row.label)')
71
+ expect(js).not.toContain('escapeTextOrNode(_p.format(row().label))')
72
+ // bindEvents for that arm stays empty (mirrors the pre-fix shape exactly).
73
+ expect(js).toMatch(/bindEvents: \(__branchScope, \{ isFirstRun: __bfFirstRun = false \} = \{\}\) => \{\s*\}/m)
74
+ })
75
+
76
+ test('a branch with a call NESTED inside a template literal also keeps the skip', () => {
77
+ // The call is not at the top level of the expression — `exprHasFunctionCalls`
78
+ // is a full recursive AST walk, so this must be caught too.
79
+ const js = clientJsFor('`${_p.format(row.label)}!`')
80
+ expect(js).not.toContain("escapeTextOrNode(`${_p.format(row().label)}!`)")
81
+ })
82
+
83
+ test('a branch with a call nested inside a sub-ternary keeps the skip', () => {
84
+ const js = clientJsFor("row.id > 0 ? _p.format(row.label) : row.label")
85
+ // The outer branch (itself a nested conditional) is unaffected by this
86
+ // narrowing — nested conditionals already get their own `insert()` via
87
+ // `collectLoopChildConditionals`, never through `reactiveTexts`. This
88
+ // case exists to confirm compilation succeeds and the call-bearing leaf
89
+ // still doesn't get a bare reactiveTexts effect wrapping the whole
90
+ // nested-ternary expression text.
91
+ expect(js).not.toContain('escapeTextOrNode(row.id > 0 ? _p.format(row().label) : row().label)')
92
+ })
93
+
94
+ test('an inert literal branch (no loop-param ref, no call) stays exactly as before — no slotId, no effect', () => {
95
+ const js = clientJsFor("'yes'")
96
+ expect(js).not.toContain("escapeTextOrNode('yes')")
97
+ })
98
+ })
@@ -58,7 +58,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
58
58
  const clientJs = getClientJs(source, 'List.tsx')
59
59
  // Dynamic loops emit a `mapArray(` call site. Tighter than
60
60
  // `/mapArray|reconcile/` which would fire on any stray mention.
61
- expect(clientJs).toContain('mapArray(')
61
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
62
62
  expect(clientJs).toContain('items()')
63
63
  })
64
64
 
@@ -83,7 +83,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
83
83
  `
84
84
 
85
85
  const clientJs = getClientJs(source, 'List.tsx')
86
- expect(clientJs).toContain('mapArray(')
86
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
87
87
  expect(clientJs).toContain('getItems()')
88
88
  })
89
89
 
@@ -124,7 +124,11 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
124
124
  `
125
125
 
126
126
  const clientJs = getClientJs(source, 'List.tsx')
127
- expect(clientJs).toMatch(/\bmapArray\s*\(/)
127
+ // `mapArrayLazy` counts: a keyed, single-root, conditional-free row over a
128
+ // PROP array is lazy-row-eligible (spec/slot-unification.md §9.4 — props
129
+ // are the canonical hydration-consistent source), so it takes the lazy
130
+ // reconciler. What #1586 pins is "reconciles at all", not which family.
131
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
128
132
  })
129
133
 
130
134
  test('unrecognised-call chain with filter now reconciles', () => {
@@ -149,7 +153,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
149
153
  `
150
154
 
151
155
  const clientJs = getClientJs(source, 'DoneList.tsx')
152
- expect(clientJs).toContain('mapArray(')
156
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
153
157
  expect(clientJs).toContain('computeItems()')
154
158
  })
155
159
 
@@ -181,7 +185,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
181
185
  // Outer reconciles via mapArray. Also assert the inner loop's array
182
186
  // expression (`o.children`) appears in the emitted template — without
183
187
  // this, the test would pass even if the inner loop were dropped.
184
- expect(clientJs).toContain('mapArray(')
188
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
185
189
  expect(clientJs).toContain('outer()')
186
190
  expect(clientJs).toContain('o.children')
187
191
  })
@@ -219,8 +223,13 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
219
223
  // mapArray is emitted (widening now applies). renderItem uses the
220
224
  // synthetic `__bfItem` param; references to `cfg` are rewritten
221
225
  // inline and no unwrap statement is emitted.
222
- expect(clientJs).toContain('mapArray(')
223
- expect(clientJs).toContain('(__bfItem, ')
226
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
227
+ // The synthetic accessor param, in whichever reconciler shape applies:
228
+ // `mapArray`'s renderItem head `(__bfItem, __idx, __existing) =>`, or a
229
+ // lazy row plan's `const __bfItem = () => __e.item` binding
230
+ // (spec/slot-unification.md §9). Either way the destructured references
231
+ // below must read through the accessor, which is what this pins.
232
+ expect(clientJs).toMatch(/\(__bfItem, |const __bfItem = \(\) => __e\.item/)
224
233
  expect(clientJs).not.toContain('const [, cfg] = __bfItem();')
225
234
  expect(clientJs).toContain('__bfItem()[1].color')
226
235
  })
@@ -247,7 +256,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
247
256
  `
248
257
 
249
258
  const clientJs = getClientJs(source, 'Shelf.tsx')
250
- expect(clientJs).toContain('mapArray(')
259
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
251
260
  expect(clientJs).toContain('listOf()')
252
261
  })
253
262
 
@@ -278,8 +287,13 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
278
287
  `
279
288
 
280
289
  const clientJs = getClientJs(source, 'TypedLegend.tsx')
281
- expect(clientJs).toContain('mapArray(')
282
- expect(clientJs).toContain('(__bfItem, ')
290
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
291
+ // The synthetic accessor param, in whichever reconciler shape applies:
292
+ // `mapArray`'s renderItem head `(__bfItem, __idx, __existing) =>`, or a
293
+ // lazy row plan's `const __bfItem = () => __e.item` binding
294
+ // (spec/slot-unification.md §9). Either way the destructured references
295
+ // below must read through the accessor, which is what this pins.
296
+ expect(clientJs).toMatch(/\(__bfItem, |const __bfItem = \(\) => __e\.item/)
283
297
  expect(clientJs).not.toContain('const [, cfg] = __bfItem();')
284
298
  expect(clientJs).toContain('__bfItem()[1].color')
285
299
  })
@@ -309,8 +323,13 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
309
323
  `
310
324
 
311
325
  const clientJs = getClientJs(source, 'Fields.tsx')
312
- expect(clientJs).toContain('mapArray(')
313
- expect(clientJs).toContain('(__bfItem, ')
326
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
327
+ // The synthetic accessor param, in whichever reconciler shape applies:
328
+ // `mapArray`'s renderItem head `(__bfItem, __idx, __existing) =>`, or a
329
+ // lazy row plan's `const __bfItem = () => __e.item` binding
330
+ // (spec/slot-unification.md §9). Either way the destructured references
331
+ // below must read through the accessor, which is what this pins.
332
+ expect(clientJs).toMatch(/\(__bfItem, |const __bfItem = \(\) => __e\.item/)
314
333
  expect(clientJs).not.toContain('const { label, value } = __bfItem();')
315
334
  expect(clientJs).toContain('__bfItem().label')
316
335
  expect(clientJs).toContain('__bfItem().value')
@@ -72,16 +72,20 @@ describe('hoisted loop-body skeleton template (perf)', () => {
72
72
  `
73
73
  const { clientJs, template } = compile(source, 'Bench.tsx')
74
74
 
75
- // Hoisted declaration present, before the mapArray call.
75
+ // Hoisted declaration present, before the reconciler call. This row shape
76
+ // is also lazy-row-eligible (spec/slot-unification.md §9.4), so the
77
+ // reconciler is `mapArrayLazy` and the clone happens in the row plan's
78
+ // `createRow` rather than in a `mapArray` renderItem — the hoisting
79
+ // question this suite pins is orthogonal to which reconciler consumes it.
76
80
  const declIdx = clientJs.indexOf('.innerHTML = `<tr')
77
- const mapArrayIdx = clientJs.indexOf('mapArray(')
81
+ const mapArrayIdx = clientJs.search(/\bmapArray(Lazy)?\(/)
78
82
  expect(declIdx).toBeGreaterThan(-1)
79
83
  expect(mapArrayIdx).toBeGreaterThan(-1)
80
84
  expect(declIdx).toBeLessThan(mapArrayIdx)
81
85
 
82
- // renderItem clones from the hoisted template instead of re-parsing
86
+ // The row is built by cloning the hoisted template instead of re-parsing
83
87
  // innerHTML per row.
84
- expect(clientJs).toMatch(/const __el = __existing \?\? __tpl_\w+\.content\.firstElementChild\.cloneNode\(true\)/)
88
+ expect(clientJs).toMatch(/const __el = __tpl_\w+\.content\.firstElementChild\.cloneNode\(true\)/)
85
89
 
86
90
  // The hoisted template itself carries no interpolations and no dynamic
87
91
  // `class` attribute (that's covered by the reactive-attr createEffect).
@@ -172,7 +176,7 @@ describe('hoisted loop-body skeleton template (perf)', () => {
172
176
  }
173
177
  `
174
178
  const { clientJs } = compile(source, 'SpreadList.tsx')
175
- expect(clientJs).toContain('mapArray(')
179
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
176
180
  expect(clientJs).not.toMatch(/__tpl_\w+ = document\.createElement\('template'\)/)
177
181
  })
178
182
 
@@ -195,7 +199,7 @@ describe('hoisted loop-body skeleton template (perf)', () => {
195
199
  }
196
200
  `
197
201
  const { clientJs } = compile(source, 'Dots.tsx')
198
- expect(clientJs).toContain('mapArray(')
202
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
199
203
  // A `<circle>` root with only dynamic attrs (all covered by reactive-attr
200
204
  // createEffects) and a literal `r="4"` is exactly the shape the fast path
201
205
  // proves safe — this DOES hoist, and it must wrap in a synthetic `<svg>`
@@ -95,7 +95,12 @@ describe('#1247 — static-loop CSR self-heal', () => {
95
95
  }
96
96
  `
97
97
  const clientJs = getClientJs(source, 'PropList.tsx')
98
- expect(clientJs).toMatch(/\bmapArray\s*\(/)
98
+ // Either reconciler shape satisfies the point of this test — the loop must
99
+ // go through the keyed `mapArray` family, not the static `forEach` path.
100
+ // A keyed, single-root, conditional-free row over a prop array is
101
+ // lazy-row-eligible (spec/slot-unification.md §9.4), so it emits
102
+ // `mapArrayLazy`; widen (don't narrow) the assertion.
103
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
99
104
  expect(clientJs).not.toMatch(/\.forEach\s*\(/)
100
105
  })
101
106
 
@@ -1413,19 +1413,54 @@ function summarizeLoopChildBranch(
1413
1413
  // on the branch root yields exactly this branch's direct bindings
1414
1414
  // without re-collecting what a nested arm already owns (#2347).
1415
1415
  reactiveAttrs: collectLoopChildReactiveAttrs(node, ctx, loopParam, loopParamBindings, true),
1416
- // Skip when the branch's ENTIRE content is a single bare `expression`
1417
- // (no wrapping element) e.g. a hoisted `renderNode={(n) => <Pill/>}`
1418
- // callback (#1211/#1213). That value is already fully re-evaluated and
1419
- // spliced via `__bfSlot` whenever `insert()` (re-)mounts this branch;
1420
- // an *additional* nested createEffect for the same expression re-calls
1421
- // it and creates a second, independent live element (a JSX-callback
1422
- // result isn't idempotent to re-invoke the way a plain signal read is),
1423
- // and the loop-child arm's `$t()`-based anchor lookup designed for
1424
- // text nodes doesn't cleanly displace an already-mounted Element,
1425
- // so the second instance lands beside the first instead of replacing
1426
- // it. A text *nested inside* a static wrapper element in the branch
1427
- // (the element-descent case) is unaffected and still collected below.
1428
- reactiveTexts: node.type === 'expression'
1416
+ // Skip ONLY when the branch's entire content is a single bare
1417
+ // `expression` (no wrapping element) that MAY yield a live DOM node
1418
+ // i.e. it contains a call anywhere (`node.hasFunctionCalls`, computed
1419
+ // by the AST walk in `exprHasFunctionCalls`/jsx-to-ir.ts, recursively
1420
+ // catches a call nested in a template literal or a nested ternary, not
1421
+ // just a top-level call). A call can return an Element (a hoisted
1422
+ // `renderNode={(n) => <Pill/>}` callback lowered to a component call,
1423
+ // #1211/#1213) and the compiler cannot tell from syntax whether it does
1424
+ // — both `renderChild(...)` and `_p.renderCell(...)` are a
1425
+ // `CallExpression` so this stays conservative for ANY call, not just
1426
+ // ones known to return JSX. Re-evaluating a non-idempotent call inside
1427
+ // an *additional* nested createEffect (on top of the eval already done
1428
+ // for the `__bfSlot`-wrapped splice whenever `insert()` (re-)mounts this
1429
+ // branch) would call it again on every unrelated tick and discard the
1430
+ // previous element's listeners/state.
1431
+ //
1432
+ // Everything else — a property access (`row.label`), an identifier, a
1433
+ // literal, a template literal, string concatenation, or a nested
1434
+ // ternary of those — cannot itself construct a DOM node (only a JSX
1435
+ // literal or a call can, and a JSX literal branch is never `type:
1436
+ // 'expression'` in the first place — see `transformJsxExpression`), so
1437
+ // it is safe to collect. This is the fix for the loop-branch-stale-text
1438
+ // defect: a keyed loop row whose branch is e.g. `row.done ? row.label :
1439
+ // 'pending'` previously had NO update path at all when `row.label`
1440
+ // changed without `row.done` flipping — `insert()` (runtime/insert.ts)
1441
+ // correctly no-ops when its condition is unchanged (branch-internal
1442
+ // updates are deliberately the effect system's job, not DOM
1443
+ // replacement's), but with reactiveTexts always `[]` for this shape,
1444
+ // no effect existed either, so the branch was frozen at its mount-time
1445
+ // value. The stale rationale in a prior version of this comment blamed
1446
+ // "the loop-child arm's `$t()`-based anchor lookup", but `$t()` no
1447
+ // longer exists — it was one of four content mechanisms deleted by the
1448
+ // slot-unification work. The current claim door
1449
+ // (`stringifyLoopChildArm` → `lazySlots(..., kind: 'markup')` →
1450
+ // `writeMarkup`, runtime/claim-slots.ts) clears the claimed range and
1451
+ // splices the Node by identity, so "lands beside the first" cannot
1452
+ // happen through it; only the call's non-idempotence still applies, and
1453
+ // that alone is why the skip narrows rather than disappears.
1454
+ //
1455
+ // Collecting here is necessary but not sufficient: `n.slotId` must also
1456
+ // be set for `collectLoopChildReactiveTexts` (ir-to-client-js/
1457
+ // reactivity.ts) to do anything — see `transformConditionalBranch`'s
1458
+ // `refsLoopParam` check (jsx-to-ir.ts) for the matching half of this fix
1459
+ // that gives a bare loop-item-reading branch a slotId at all, which also
1460
+ // makes `irToHtmlTemplate` emit its `<!--bf:sN-->…<!--/-->` marker (the
1461
+ // same call builds both the SSR and the CSR/hydration template, so the
1462
+ // two can't disagree on shape).
1463
+ reactiveTexts: node.type === 'expression' && node.hasFunctionCalls
1429
1464
  ? []
1430
1465
  : collectLoopChildReactiveTexts(node, ctx, loopParam, loopParamBindings, true),
1431
1466
  }
@@ -18,6 +18,7 @@
18
18
  import type { CompositeLoopPlan, LoopChildRefBinding, PreambleRegionPlan } from './loop.ts'
19
19
  import type { EventDelegationPlan } from './event-delegation.ts'
20
20
  import type { ReactiveEffectsPlan } from './reactive-effects.ts'
21
+ import type { LazyRowPlanData } from './build-lazy-row.ts'
21
22
 
22
23
  export interface BranchPlainLoopPlan {
23
24
  /**
@@ -89,6 +90,11 @@ export interface BranchPlainLoopPlan {
89
90
  * renderItem layout, mirroring the top-level plain plan.
90
91
  */
91
92
  preambleRegions: readonly PreambleRegionPlan[]
93
+ /**
94
+ * Lazy row graph plan (`spec/slot-unification.md` §9, L3) — see
95
+ * `PlainLoopVariant.lazyRow`. Undefined ⇒ today's eager emission.
96
+ */
97
+ lazyRow?: LazyRowPlanData
92
98
  }
93
99
 
94
100
  export interface BranchCompositeLoopPlan {
@@ -15,6 +15,8 @@ import { buildChainedArrayExpr, varSlotId, wrapLoopParamAsAccessor } from '../..
15
15
  import { buildBranchCompositePlan } from './build-composite-loop.ts'
16
16
  import { buildBranchLoopDelegationPlan } from './build-event-delegation.ts'
17
17
  import { buildReactiveEffectsPlan } from './build-reactive-effects.ts'
18
+ import { buildLazyRowPlan } from './build-lazy-row.ts'
19
+ import type { LazyRowScopeInfo } from './lazy-row-eligibility.ts'
18
20
  import { destructureLoopParam, loopKeyFn, buildChildRefBindings, buildPreambleRegionPlans } from '../shared.ts'
19
21
  import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
20
22
  import type {
@@ -23,7 +25,11 @@ import type {
23
25
  BranchPlainLoopPlan,
24
26
  } from './branch-loop.ts'
25
27
 
26
- export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: string): BranchLoopPlan {
28
+ export function buildBranchLoopPlan(
29
+ loop: BranchLoop,
30
+ profileComponentName?: string,
31
+ lazyScope?: LazyRowScopeInfo,
32
+ ): BranchLoopPlan {
27
33
  const containerSlotId = loop.containerSlotId
28
34
  const cv = varSlotId(containerSlotId)
29
35
  const containerVar = `__loop_${cv}`
@@ -45,6 +51,17 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
45
51
 
46
52
  // flatMap descriptor mode — see buildPlainLoopPlan (build-loop.ts).
47
53
  const fm = loop.flatMapClient
54
+ const arrayExpr = fm
55
+ ? `(${buildChainedArrayExpr(loop)}).flatMap(${fm.params} => ${fm.body})`
56
+ : buildChainedArrayExpr(loop)
57
+ const indexParam = loop.index || '__idx'
58
+ const mapPreambleWrapped = loop.preamble
59
+ ? renderPreamble(loop.preamble, {
60
+ transformJs: (t) => wrapLoopParamAsAccessor(t, loop.param, loop.paramBindings),
61
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
62
+ })
63
+ : ''
64
+ const preambleRegions = buildPreambleRegionPlans(loop.preambleRegions, loop.param, loop.paramBindings)
48
65
  const plan: BranchPlainLoopPlan = {
49
66
  kind: 'plain',
50
67
  rowConstruction: 'string-template',
@@ -52,23 +69,29 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
52
69
  containerVar,
53
70
  markerId: loop.markerId,
54
71
  flatMapLeafItem: fm ? true : undefined,
55
- arrayExpr: fm
56
- ? `(${buildChainedArrayExpr(loop)}).flatMap(${fm.params} => ${fm.body})`
57
- : buildChainedArrayExpr(loop),
72
+ arrayExpr,
58
73
  keyFn: fm
59
74
  ? (fm.keyed ? '(__bfD, __bfI) => String(__bfD.k ?? __bfI)' : 'null')
60
75
  : loopKeyFn(loop),
61
76
  paramHead,
62
77
  paramUnwrap,
63
- indexParam: loop.index || '__idx',
78
+ indexParam,
64
79
  // Wrap loop-param references to signal-accessor form so the preamble
65
80
  // matches the template literal's already-wrapped reads (#1065).
66
- mapPreambleWrapped: loop.preamble
67
- ? renderPreamble(loop.preamble, {
68
- transformJs: (t) => wrapLoopParamAsAccessor(t, loop.param, loop.paramBindings),
69
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
70
- })
71
- : '',
81
+ mapPreambleWrapped,
82
+ // Lazy row graph (§9, L3) — undefined for every ineligible loop.
83
+ lazyRow: buildLazyRowPlan({
84
+ loop,
85
+ arrayExpr,
86
+ indexParam,
87
+ paramUnwrap,
88
+ mapPreambleWrapped,
89
+ preambleRegionCount: preambleRegions.length,
90
+ callSite: 'branch-plain',
91
+ flatMapLeafItem: Boolean(fm),
92
+ anchored: false,
93
+ scope: lazyScope,
94
+ }) ?? undefined,
72
95
  template: loop.template,
73
96
  reactiveEffects: hasReactiveEffects
74
97
  ? buildReactiveEffectsPlan({
@@ -82,7 +105,7 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
82
105
  : null,
83
106
  eventDelegation: buildBranchLoopDelegationPlan(loop, cv, profileComponentName),
84
107
  childRefs: buildChildRefBindings(loop.bindings.refs, loop.param, loop.paramBindings),
85
- preambleRegions: buildPreambleRegionPlans(loop.preambleRegions, loop.param, loop.paramBindings),
108
+ preambleRegions,
86
109
  bodyIsMultiRoot: loop.bodyIsMultiRoot ?? false,
87
110
  profileLoopId: profileComponentName ? `${profileComponentName}#binding:${containerSlotId}` : undefined,
88
111
  }
@@ -14,6 +14,7 @@ import type {
14
14
  } from '../../types.ts'
15
15
  import { addCondAttrToTemplate } from '../../html-template.ts'
16
16
  import { buildBranchLoopPlan } from './build-branch-loop.ts'
17
+ import type { LazyRowScopeInfo } from './lazy-row-eligibility.ts'
17
18
  import type {
18
19
  InsertPlan,
19
20
  InsertArm,
@@ -26,6 +27,12 @@ export interface BuildInsertOptions {
26
27
  eventNameMode: 'dom' | 'raw'
27
28
  /** Owning component name in profile mode (#1690, SR3) — else undefined. */
28
29
  profileComponentName?: string
30
+ /**
31
+ * Component-scope name facts for the lazy row graph gate
32
+ * (`spec/slot-unification.md` §9.4). Threaded down to branch-scoped plain
33
+ * loops; omitted → those loops keep today's eager emission.
34
+ */
35
+ lazyScope?: LazyRowScopeInfo
29
36
  }
30
37
 
31
38
  export function buildInsertPlan(
@@ -88,13 +95,13 @@ function buildArmBody(branch: BranchSummary, options: BuildInsertOptions): ArmBo
88
95
  expression: t.expression,
89
96
  })),
90
97
  // Branch-scoped loops, fully Plan-built (Item 2 final migration).
91
- loops: branch.loops.map(l => buildBranchLoopPlan(l, pc)),
98
+ loops: branch.loops.map(l => buildBranchLoopPlan(l, pc, options.lazyScope)),
92
99
  // Nested conditionals are themselves InsertPlans — built recursively so
93
100
  // the same stringifier handles arbitrary depth. Their scope is always
94
101
  // `__branchScope` (the parent arm's bindEvents argument), regardless of
95
102
  // the outer scope; only the eventNameMode is inherited.
96
103
  conditionals: branch.conditionals.map(c =>
97
- buildInsertPlan(c, { scope: { kind: 'branchScope' }, eventNameMode: options.eventNameMode, profileComponentName: pc }),
104
+ buildInsertPlan(c, { scope: { kind: 'branchScope' }, eventNameMode: options.eventNameMode, profileComponentName: pc, lazyScope: options.lazyScope }),
98
105
  ),
99
106
  }
100
107
  }