@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,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
+ })
@@ -18,7 +18,7 @@
18
18
  * and the conditional follow-up (#941), so this loop widening closes
19
19
  * the divergence gap between the allow-list path and the AST-flag path.
20
20
  *
21
- * Over-reconciliation of a pure-call array has a real cost (reconcileList
21
+ * Over-reconciliation of a pure-call array has a real cost (mapArray
22
22
  * is not a cheap primitive), so the fixture sweep in the accompanying
23
23
  * PR measures byte delta too; but the silent-drop bug this closes is
24
24
  * the expensive one — a frozen client list is a visible correctness
@@ -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,14 +83,14 @@ 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
 
90
90
  test('inline literal array stays static (optimisation preserved)', () => {
91
91
  // `[1, 2, 3]` is an ArrayLiteralExpression with no calls — the
92
- // existing static-render path is the right call. No reconcileList /
93
- // mapArray should appear.
92
+ // existing static-render path is the right call. No `mapArray` call
93
+ // should appear.
94
94
  const source = `
95
95
  'use client'
96
96
  import { createSignal } from '@barefootjs/client'
@@ -107,7 +107,6 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
107
107
 
108
108
  const clientJs = getClientJs(source, 'List.tsx')
109
109
  expect(clientJs).not.toMatch(/\bmapArray\s*\(/)
110
- expect(clientJs).not.toMatch(/\breconcileList\s*\(/)
111
110
  })
112
111
 
113
112
  test('prop array compiles to mapArray (#1586)', () => {
@@ -125,7 +124,11 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
125
124
  `
126
125
 
127
126
  const clientJs = getClientJs(source, 'List.tsx')
128
- 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*\(/)
129
132
  })
130
133
 
131
134
  test('unrecognised-call chain with filter now reconciles', () => {
@@ -150,7 +153,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
150
153
  `
151
154
 
152
155
  const clientJs = getClientJs(source, 'DoneList.tsx')
153
- expect(clientJs).toContain('mapArray(')
156
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
154
157
  expect(clientJs).toContain('computeItems()')
155
158
  })
156
159
 
@@ -182,7 +185,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
182
185
  // Outer reconciles via mapArray. Also assert the inner loop's array
183
186
  // expression (`o.children`) appears in the emitted template — without
184
187
  // this, the test would pass even if the inner loop were dropped.
185
- expect(clientJs).toContain('mapArray(')
188
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
186
189
  expect(clientJs).toContain('outer()')
187
190
  expect(clientJs).toContain('o.children')
188
191
  })
@@ -220,8 +223,13 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
220
223
  // mapArray is emitted (widening now applies). renderItem uses the
221
224
  // synthetic `__bfItem` param; references to `cfg` are rewritten
222
225
  // inline and no unwrap statement is emitted.
223
- expect(clientJs).toContain('mapArray(')
224
- 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/)
225
233
  expect(clientJs).not.toContain('const [, cfg] = __bfItem();')
226
234
  expect(clientJs).toContain('__bfItem()[1].color')
227
235
  })
@@ -248,7 +256,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
248
256
  `
249
257
 
250
258
  const clientJs = getClientJs(source, 'Shelf.tsx')
251
- expect(clientJs).toContain('mapArray(')
259
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
252
260
  expect(clientJs).toContain('listOf()')
253
261
  })
254
262
 
@@ -279,8 +287,13 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
279
287
  `
280
288
 
281
289
  const clientJs = getClientJs(source, 'TypedLegend.tsx')
282
- expect(clientJs).toContain('mapArray(')
283
- 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/)
284
297
  expect(clientJs).not.toContain('const [, cfg] = __bfItem();')
285
298
  expect(clientJs).toContain('__bfItem()[1].color')
286
299
  })
@@ -310,8 +323,13 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
310
323
  `
311
324
 
312
325
  const clientJs = getClientJs(source, 'Fields.tsx')
313
- expect(clientJs).toContain('mapArray(')
314
- 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/)
315
333
  expect(clientJs).not.toContain('const { label, value } = __bfItem();')
316
334
  expect(clientJs).toContain('__bfItem().label')
317
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>`
@@ -234,8 +234,13 @@ export function Counter() {
234
234
  const ssr = files['Counter.tsx']
235
235
  // SSR template should NOT declare count as a getter
236
236
  expect(ssr).not.toContain('const count = () =>')
237
- // SSR template should have a client-only placeholder (not the value)
238
- expect(ssr).toContain('client:s')
237
+ // Slot unification Step B: `{count()}` is the SPAN's only, non-adjacent
238
+ // child, so `client-only-elision.ts` elides its marker pair entirely —
239
+ // no `bfText`/`bfTextEnd` call reaches the SSR output at all. The
240
+ // client's claim plan (`Counter.client.js`) creates the missing Text
241
+ // node from `elidedPath` alone; see `claim-slots.test.ts`'s
242
+ // "markerless text kind" describe block for the runtime contract.
243
+ expect(ssr).not.toContain('bfText(')
239
244
  })
240
245
 
241
246
  test('full compile: exported signal uses `export const` in client JS', () => {
@@ -134,13 +134,14 @@ describe('nested loops/conditionals inside mapArray (#830, #839)', () => {
134
134
  expect(mapArrayCount).toBeGreaterThanOrEqual(3)
135
135
  })
136
136
 
137
- test('reactive text inside conditional inside inner loop uses re-query pattern (#840)', () => {
137
+ test('reactive text inside conditional inside inner loop uses re-claim pattern (#840)', () => {
138
138
  // When a reactive text is inside a conditional branch inside a nested (inner) loop,
139
139
  // insert() may replace the SSR element after the text node is captured.
140
- // The generated code must use the re-query pattern:
141
- // createEffect(() => { const [__rt] = $t(...) ... })
142
- // NOT the capture-then-effect pattern:
143
- // { const [__rt] = $t(...); if (__rt) createEffect(() => ...) }
140
+ // Slot unification A3: the generated code must re-claim on EVERY run via
141
+ // a fresh `claimSlots(...).write(...)` call inside the effect body not
142
+ // a `lazySlots` writer built once outside it, which would go stale once
143
+ // insert() swaps the branch's DOM (see `stringifyInnerLoops`' docstring
144
+ // in `ir-to-client-js/control-flow/stringify/inner-loop.ts`).
144
145
  const source = `
145
146
  'use client'
146
147
  import { createSignal } from '@barefootjs/client'
@@ -175,8 +176,9 @@ describe('nested loops/conditionals inside mapArray (#830, #839)', () => {
175
176
  expect(clientJs).toBeDefined()
176
177
  const content = clientJs!.content
177
178
 
178
- // Re-query pattern: $t() inside createEffect so it always finds the live node
179
- expect(content).toContain('createEffect(() => { const [__rt] = $t(')
179
+ // Re-claim pattern: a fresh `claimSlots(...)` call inside createEffect so
180
+ // it always resolves against the live node, never a cached stale ref.
181
+ expect(content).toMatch(/createEffect\(\(\) => \{ claimSlots\(__innerEl\w*, \[\{ id: 's\d+', kind: 'text', path: \[\] \}\]\)\.write\('s\d+', String\(/)
180
182
  })
181
183
 
182
184
  test('event handler inside conditional branch of loop item appears in bindEvents (#839)', () => {
@@ -339,7 +339,7 @@ describe('reactive attributes inside a nested .map() body (#135)', () => {
339
339
  // The innermost loop's per-item renderItem must emit BOTH a
340
340
  // reactive-text effect for `panel.text` and a reactive-attr effect
341
341
  // for `panel.cls` — the bug dropped only the former.
342
- expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?\.textContent = String\(panel\(\)\.text\)/)
342
+ expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?__bfw_\w+\('s\d+', String\(panel\(\)\.text\)\)/)
343
343
  expect(content).toContain("setAttribute('class'")
344
344
  })
345
345
 
@@ -395,7 +395,7 @@ describe('reactive attributes inside a nested .map() body (#135)', () => {
395
395
  // textContent write — `labelAt(` also appears in the static template
396
396
  // clone, so asserting it independently would pass even with the
397
397
  // effect missing (the exact regression here).
398
- expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?\.textContent = String\(labelAt\(pi\)\)/)
398
+ expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?__bfw_\w+\('s\d+', String\(labelAt\(pi\)\)\)/)
399
399
  expect(content).toContain("setAttribute('class'")
400
400
  })
401
401
  })
@@ -11,9 +11,13 @@
11
11
  * the preamble's `declaredNames` is classified as a preamble-patched
12
12
  * region — slot-marked like an ordinary reactive text (so SSR/CSR row
13
13
  * templates render `<!--bf:sN-->...<!--/-->` / `{bfText("sN")}` the same
14
- * door a reactive text uses), but wired on the client via a dedicated
15
- * `patchSlotRange`-based region-patch effect rather than a `reactiveTexts`
16
- * `.textContent` assignment (which would corrupt the array-joined markup).
14
+ * door a reactive text uses), but wired on the client via a claimed
15
+ * 'markup' slot writer (`@barefootjs/client/runtime/claim-slots.ts`) rather
16
+ * than a `reactiveTexts` `.textContent` assignment (which would corrupt the
17
+ * array-joined markup). Dedup — skip the DOM write when the new value
18
+ * matches the writer's own held `last` value — lives inside that writer;
19
+ * every write, including the first, applies unless deduped (see
20
+ * `claim-slots.ts`'s module docstring).
17
21
  */
18
22
 
19
23
  import { describe, test, expect } from 'bun:test'
@@ -68,7 +72,8 @@ describe('preamble-region-patch (#2389)', () => {
68
72
  expect(clientJs).toMatch(/<!--bf:s\d+-->\$\{Array\.isArray\(cells\) \? cells\.join\(''\) : \(cells \?\? ''\)\}<!--\/-->/)
69
73
 
70
74
  const hono = compileWith(new HonoAdapter())
71
- expect(hono.clientJs).toContain('patchSlotRange')
75
+ expect(hono.clientJs).toContain("kind: 'markup'")
76
+ expect(hono.clientJs).toContain('lazySlots(__el')
72
77
  // Hono SSR renders the SAME slotId through `renderExpression`'s generic
73
78
  // `{bfText("id")}...{bfTextEnd()}` door — no bespoke region handling.
74
79
  const slotMatch = /<!--bf:(s\d+)-->\$\{Array\.isArray\(cells\)/.exec(hono.clientJs)
@@ -81,15 +86,16 @@ describe('preamble-region-patch (#2389)', () => {
81
86
 
82
87
  test('(b) renderItem emits the region-patch effect, re-running the preamble in accessor form', () => {
83
88
  const { clientJs } = compileWith(new TestAdapter())
84
- expect(clientJs).toContain('patchSlotRange')
89
+ expect(clientJs).toContain("kind: 'markup'")
85
90
  // The preamble re-runs inside the effect with the loop-param accessor
86
91
  // wrap (`t().done`), not the plain (`t.done`) form used at the
87
92
  // top-level construction line.
88
93
  expect(clientJs).toMatch(/createEffect\(\(\) => \{\s*const stateLabel = t\(\)\.done/)
89
- // First run only records (trusts SSR/CSR mount-time content); only a
90
- // SUBSEQUENT change patches via patchSlotRange.
91
- expect(clientJs).toMatch(/if \(__last_\w+ === undefined\) \{ __last_\w+ = __html_\w+; return \}/)
92
- expect(clientJs).toMatch(/patchSlotRange\(__el, 's\d+', __html_\w+\)/)
94
+ // The write goes through the claimed 'markup' writer dedup (skip an
95
+ // unchanged string, on every write including the first) now lives
96
+ // inside `writeMarkup` itself (`claim-slots.ts`), not a per-region
97
+ // `__last` local next to the write call.
98
+ expect(clientJs).toMatch(/__bfw_\w+\('s\d+', Array\.isArray\(cells\)/)
93
99
  })
94
100
 
95
101
  test('(c) a loop without a preamble gets no region', () => {
@@ -102,7 +108,7 @@ describe('preamble-region-patch (#2389)', () => {
102
108
  }
103
109
  `
104
110
  const { clientJs } = compileWith(new TestAdapter(), source)
105
- expect(clientJs).not.toContain('patchSlotRange')
111
+ expect(clientJs).not.toContain("kind: 'markup'")
106
112
  })
107
113
 
108
114
  test('(c) a static-array loop with a preamble gets no region', () => {
@@ -123,7 +129,7 @@ describe('preamble-region-patch (#2389)', () => {
123
129
  }
124
130
  `
125
131
  const { clientJs } = compileWith(new TestAdapter(), source)
126
- expect(clientJs).not.toContain('patchSlotRange')
132
+ expect(clientJs).not.toContain("kind: 'markup'")
127
133
  })
128
134
 
129
135
  test('(d) a preamble local never referenced by an expression child gets no region', () => {
@@ -145,6 +151,6 @@ describe('preamble-region-patch (#2389)', () => {
145
151
  }
146
152
  `
147
153
  const { clientJs } = compileWith(new TestAdapter(), source)
148
- expect(clientJs).not.toContain('patchSlotRange')
154
+ expect(clientJs).not.toContain("kind: 'markup'")
149
155
  })
150
156
  })
@@ -60,8 +60,9 @@ describe('conditional binding-effect ids (#1795 Phase 1)', () => {
60
60
  const on = clientJs(true)
61
61
  // Branch reactive attribute effect (createDisposableEffect with the class write).
62
62
  expect(on).toMatch(/setAttribute\('class'[\s\S]*?}, "Disclosure#binding:s\d+"\)\)/)
63
- // Branch reactive text effect (__bfText path).
64
- expect(on).toMatch(/__bfText\([\s\S]*?}, "Disclosure#binding:s\d+"\)\)/)
63
+ // Branch reactive text effect (claimed 'markup' slot writer, slot
64
+ // unification A3).
65
+ expect(on).toMatch(/__bfw_\w+\([\s\S]*?}, "Disclosure#binding:s\d+"\)\)/)
65
66
  })
66
67
 
67
68
  test('buildIdIndex resolves conditional / attribute / text binding ids to source loc', () => {
@@ -50,9 +50,10 @@ describe('loop-child binding-effect ids (#1795 Phase 2)', () => {
50
50
 
51
51
  test('profile on: loop-child text effects carry #binding ids', () => {
52
52
  const on = clientJs(true)
53
- // Both `{it.t}` and `{it.n}` text effects.
54
- expect(on).toMatch(/__rt_s\d+\.textContent = String\(it\(\)\.t\) }, "List#binding:s\d+"\)/)
55
- expect(on).toMatch(/__rt_s\d+\.textContent = String\(it\(\)\.n\) }, "List#binding:s\d+"\)/)
53
+ // Both `{it.t}` and `{it.n}` text effects write through the row's
54
+ // claimed 'text' slot writer (slot unification A3).
55
+ expect(on).toMatch(/__bfw_\w+\('s\d+', String\(it\(\)\.t\)\) }, "List#binding:s\d+"\)/)
56
+ expect(on).toMatch(/__bfw_\w+\('s\d+', String\(it\(\)\.n\)\) }, "List#binding:s\d+"\)/)
56
57
  })
57
58
 
58
59
  test('profile on: the loop-child attribute effect carries a #binding id', () => {
@@ -112,12 +112,17 @@ describe('nested loop binding ids (#1795 Phase 3)', () => {
112
112
  test('loop-child conditional insert() and its branch text carry ids', () => {
113
113
  const on = clientJs(nestedSource, 'Nested', true)
114
114
  // The branch-arm text (`{r.label}` inside the conditional's true arm) —
115
- // __bfText (not a naive `.textContent = String(...)`) so a Child-position
116
- // expression whose value is a live Node splices by identity instead of
117
- // stringifying (#2347).
118
- expect(on).toMatch(/__rt_s\d+ = __bfText\(__rt_s\d+, r\(\)\.label\) }, "Nested#binding:s\d+"\)/)
115
+ // a claimed 'markup' slot writer (slot unification A3), built fresh in
116
+ // the branch's own bindEvents, so a Child-position expression whose
117
+ // value is a live Node splices by identity instead of stringifying
118
+ // (#2347) the same contract `__bfText` used to provide. Wrapped in
119
+ // `escapeTextOrNode` (A3 follow-up): a string value must be escaped
120
+ // before this 'markup' writer's `innerHTML =` insertion, matching what
121
+ // the initial SSR/CSR template already does for the same expression;
122
+ // a live Node passes through untouched.
123
+ expect(on).toMatch(/__bfw_\w+\('s\d+', escapeTextOrNode\(r\(\)\.label\)\) }, "Nested#binding:s\d+"\)/)
119
124
  // The inner loop's child text (`{t}`).
120
- expect(on).toMatch(/String\(t\(\)\) }, "Nested#binding:s\d+"\)/)
125
+ expect(on).toMatch(/String\(t\(\)\)\) }, "Nested#binding:s\d+"\)/)
121
126
  })
122
127
 
123
128
  test('branch-scoped loop (loop inside a conditional) attributes its mapArray', () => {
@@ -132,7 +137,7 @@ describe('nested loop binding ids (#1795 Phase 3)', () => {
132
137
  const emitted = assertNoCoverageGap(staticLoopSource, 'StaticLoop')
133
138
  expect(emitted.length).toBeGreaterThanOrEqual(1)
134
139
  const on = clientJs(staticLoopSource, 'StaticLoop', true)
135
- expect(on).toMatch(/textContent = String\(n\(\)\) }, "StaticLoop#binding:s\d+"\)/)
140
+ expect(on).toMatch(/__bfw_\w+\('s\d+', String\(n\(\)\)\) }, "StaticLoop#binding:s\d+"\)/)
136
141
  })
137
142
 
138
143
  test('component loop attributes its mapArray', () => {
@@ -68,7 +68,7 @@ describe('reactive attributes inside .map() callbacks', () => {
68
68
  expect(clientJs!.content).toContain('disabled')
69
69
  })
70
70
 
71
- test('dynamic array: reactive className handled by reconcileElements re-render (no extra effect needed)', () => {
71
+ test('dynamic array: reactive className handled by mapArray re-render (no extra effect needed)', () => {
72
72
  const source = `
73
73
  'use client'
74
74
 
@@ -91,7 +91,7 @@ describe('reactive attributes inside .map() callbacks', () => {
91
91
 
92
92
  const clientJs = result.files.find(f => f.type === 'clientJs')
93
93
  expect(clientJs).toBeDefined()
94
- // Dynamic arrays use reconcileElements which re-creates items on signal change
94
+ // Dynamic arrays use mapArray which re-creates items on signal change
95
95
  expect(clientJs!.content).toContain('mapArray')
96
96
  })
97
97
 
@@ -228,9 +228,11 @@ describe('reactive attributes inside .map() callbacks', () => {
228
228
  const result = compileJSX(source, 'L.tsx', { adapter })
229
229
  expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
230
230
  const js = result.files.find(f => f.type === 'clientJs')!.content
231
- // Both effects must still be present.
231
+ // Both effects must still be present. The text effect writes through a
232
+ // claimed 'text' slot writer now (slot unification A3), not a bare
233
+ // `.textContent =` assignment.
232
234
  expect(js).toContain("setAttribute('class'")
233
- expect(js).toMatch(/textContent\s*=\s*String\(item\.name\)/)
235
+ expect(js).toMatch(/__bfw_\w+\('s\d+', String\(item\.name\)\)/)
234
236
  // But there must be exactly one `items.forEach` call (not two).
235
237
  const forEachMatches = js.match(/items\.forEach\(/g) ?? []
236
238
  expect(forEachMatches.length).toBe(1)
@@ -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
 
@@ -203,7 +203,7 @@ describe('#1128 — template body never reaches init-scope identifiers', () => {
203
203
 
204
204
  expect(tpl).not.toMatch(/\bitems\b/)
205
205
  // Bare `undefined.map(...)` would throw; substitute `[]` so the
206
- // template renders an empty list and reconcileList populates it.
206
+ // template renders an empty list and mapArray populates it.
207
207
  expect(tpl).toMatch(/\$\{\[\]\.map\(/)
208
208
  })
209
209
 
package/src/compiler.ts CHANGED
@@ -16,6 +16,7 @@ import { analyzeComponent, listComponentFunctions, createProgramForFile, needsTy
16
16
  import { jsxToIR } from './jsx-to-ir.ts'
17
17
  import { stripClientBuiltinImports } from './builtins.ts'
18
18
  import { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
19
+ import { decideClientOnlyElision } from './ir-to-client-js/client-only-elision.ts'
19
20
  import { emitModuleLevelDeclarations } from './ir-to-client-js/emit-module-level.ts'
20
21
  import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode } from './ir-to-client-js/imports.ts'
21
22
  import { setActiveComponentScope, computeFileScope } from './ir-to-client-js/component-scope.ts'
@@ -140,6 +141,10 @@ function compileMultipleComponents(
140
141
  componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
141
142
  checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
142
143
 
144
+ // Slot unification Step B — see the single-component path's identical
145
+ // call for why this must run before adapter.generate/generateClientJs.
146
+ decideClientOnlyElision(componentIR.root)
147
+
143
148
  if (options.cssLayerPrefix) {
144
149
  applyCssLayerPrefix(componentIR, options.cssLayerPrefix)
145
150
  }
@@ -619,6 +624,12 @@ export function compileJSX(
619
624
  componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
620
625
  checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
621
626
 
627
+ // Slot unification Step B (`spec/slot-unification.md` §5 Step B): decide
628
+ // marker elision ONCE, mutating `componentIR.root` in place, before either
629
+ // `adapter.generate` or `generateClientJs` run below — both must see the
630
+ // SAME `markerless`/`elidedPath` flags on the SAME IR nodes.
631
+ decideClientOnlyElision(componentIR.root)
632
+
622
633
  // Cross-file @client signal sources: identify which import sources
623
634
  // need `.client.js` path rewriting in the client bundle.
624
635
  if (ctx.importedClientSignalNames.size > 0) {
package/src/index.ts CHANGED
@@ -65,6 +65,7 @@ export { createProgramForCorpus, type SharedProgramOptions } from './shared-prog
65
65
 
66
66
  // JSX to IR transformer
67
67
  export { jsxToIR } from './jsx-to-ir.ts'
68
+ export { decideClientOnlyElision } from './ir-to-client-js/client-only-elision.ts'
68
69
 
69
70
  // Module exports generation (compiler layer)
70
71
  export { generateModuleExports, extractFunctionParams, formatParamWithType, findReachableNames } from './module-exports.ts'