@barefootjs/jsx 0.26.4 → 0.27.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 (73) 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 +321 -104
  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/shared.d.ts +1 -1
  9. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts +54 -0
  10. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts.map +1 -0
  11. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +17 -20
  15. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +63 -4
  17. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow.d.ts +1 -1
  19. package/dist/ir-to-client-js/element-refs.d.ts +8 -3
  20. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/emit-reactive.d.ts +8 -1
  22. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/html-template.d.ts +24 -0
  24. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/imports.d.ts +2 -2
  26. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/types.d.ts +9 -1
  29. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/utils.d.ts +1 -1
  31. package/dist/types.d.ts +25 -2
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +398 -671
  35. package/src/__tests__/child-components-in-map.test.ts +4 -4
  36. package/src/__tests__/client-js-generation.test.ts +19 -11
  37. package/src/__tests__/composite-branch-loop.test.ts +2 -2
  38. package/src/__tests__/cross-file-client-signal.test.ts +5 -1
  39. package/src/__tests__/early-return-scope-var-ref.test.ts +10 -11
  40. package/src/__tests__/inline-jsx-callback.test.ts +6 -4
  41. package/src/__tests__/ir-jsx-props.test.ts +6 -5
  42. package/src/__tests__/loop-fallback-wrap.test.ts +3 -4
  43. package/src/__tests__/module-level-signal.audit.test.ts +7 -2
  44. package/src/__tests__/nested-loop-conditional.test.ts +9 -7
  45. package/src/__tests__/nested-loop-reactive-attrs.test.ts +2 -2
  46. package/src/__tests__/preamble-region-patch.test.ts +18 -12
  47. package/src/__tests__/profile-cond-binding-ids.test.ts +3 -2
  48. package/src/__tests__/profile-loop-binding-ids.test.ts +4 -3
  49. package/src/__tests__/profile-nested-binding-ids.test.ts +11 -6
  50. package/src/__tests__/reactive-attrs-in-map.test.ts +6 -4
  51. package/src/__tests__/template-closure.test.ts +1 -1
  52. package/src/compiler.ts +11 -0
  53. package/src/index.ts +1 -0
  54. package/src/ir-to-client-js/client-only-elision.ts +273 -0
  55. package/src/ir-to-client-js/collect-elements.ts +8 -4
  56. package/src/ir-to-client-js/control-flow/shared.ts +1 -1
  57. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +12 -4
  58. package/src/ir-to-client-js/control-flow/stringify/claim-plan.ts +68 -0
  59. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +18 -9
  60. package/src/ir-to-client-js/control-flow/stringify/insert.ts +17 -11
  61. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +35 -16
  62. package/src/ir-to-client-js/control-flow/stringify/loop.ts +48 -46
  63. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +241 -31
  64. package/src/ir-to-client-js/control-flow.ts +1 -1
  65. package/src/ir-to-client-js/element-refs.ts +9 -11
  66. package/src/ir-to-client-js/emit-reactive.ts +50 -14
  67. package/src/ir-to-client-js/html-template.ts +29 -9
  68. package/src/ir-to-client-js/imports.ts +7 -4
  69. package/src/ir-to-client-js/reactivity.ts +3 -2
  70. package/src/ir-to-client-js/types.ts +10 -2
  71. package/src/ir-to-client-js/utils.ts +1 -1
  72. package/src/jsx-to-ir.ts +8 -7
  73. package/src/types.ts +25 -2
@@ -211,7 +211,7 @@ describe('child components inside .map() (#344)', () => {
211
211
  expect(clientJs!.content).toContain('toggleRow(index)')
212
212
  })
213
213
 
214
- test('dynamic signal array: component generates reconcileElements with createComponent', () => {
214
+ test('dynamic signal array: component generates mapArray with createComponent', () => {
215
215
  const source = `
216
216
  'use client'
217
217
  import { createSignal } from '@barefootjs/client'
@@ -321,7 +321,7 @@ describe('child components inside .map() (#344)', () => {
321
321
  const uniqueVars = new Set(allVars)
322
322
  expect(allVars.length).toBe(uniqueVars.size)
323
323
 
324
- // Component slot ref ($c) and reconcileElements should both be present
324
+ // Component slot ref ($c) and mapArray should both be present
325
325
  expect(content).toContain('$c(__scope')
326
326
  expect(content).toContain('mapArray')
327
327
  })
@@ -355,7 +355,7 @@ describe('child components inside .map() (#344)', () => {
355
355
  expect(clientJs).toBeDefined()
356
356
  const content = clientJs!.content
357
357
 
358
- // Should use reconcileElements with createComponent
358
+ // Should use mapArray with createComponent
359
359
  expect(content).toContain('mapArray')
360
360
  expect(content).toContain("createComponent('TableRow'")
361
361
 
@@ -650,7 +650,7 @@ describe('child components inside .map() (#344)', () => {
650
650
  expect(clientJs).toBeDefined()
651
651
  const content = clientJs!.content
652
652
 
653
- // Dynamic array should use reconcileElements and event delegation
653
+ // Dynamic array should use mapArray and event delegation
654
654
  expect(content).toContain('mapArray')
655
655
  expect(content).toContain(".addEventListener('click', (__bfEvt) => {")
656
656
  expect(content).toContain('handleClick(item.id)')
@@ -151,7 +151,7 @@ describe('Client JS generation', () => {
151
151
  })
152
152
 
153
153
  describe('map with index parameter', () => {
154
- test('includes index parameter in reconcileElements renderItem callback', () => {
154
+ test('includes index parameter in mapArray renderItem callback', () => {
155
155
  const source = `
156
156
  'use client'
157
157
  import { createMemo } from '@barefootjs/client'
@@ -1760,7 +1760,10 @@ describe('Client JS generation', () => {
1760
1760
  // update. See `preamble-region-patch.test.ts` for the full region
1761
1761
  // contract (row-template marker, region-patch effect, SSR parity).
1762
1762
  expect(clientJs?.content).toContain('${escapeText(label)}')
1763
- expect(clientJs?.content).toContain('patchSlotRange')
1763
+ // Slot unification A3: preamble regions now write through a claimed
1764
+ // 'markup' slot instead of `patchSlotRange`.
1765
+ expect(clientJs?.content).toContain("kind: 'markup'")
1766
+ expect(clientJs?.content).toContain('lazySlots(__el')
1764
1767
  })
1765
1768
 
1766
1769
  test('handles block body with multiple variable declarations', () => {
@@ -2423,11 +2426,12 @@ describe('Client JS generation', () => {
2423
2426
  expect(clientJs).toBeDefined()
2424
2427
  const content = clientJs!.content
2425
2428
 
2426
- // Text node updates route through `__bfText`, which renders nullish
2427
- // values as '' (not the string "undefined") and also splices live
2428
- // Nodes by identity (#1663). The nullish guard moved from the inline
2429
- // `String(__val ?? '')` assignment into that helper.
2430
- expect(content).toContain('__bfText(')
2429
+ // Text node updates route through a claimed 'markup' slot writer (slot
2430
+ // unification A3), whose `writeMarkup` renders nullish values as ''
2431
+ // (not the string "undefined") and also splices live Nodes by
2432
+ // identity (#1663) the same contract `__bfText` used to provide.
2433
+ // The nullish guard lives in the runtime writer, not inline here.
2434
+ expect(content).toContain("kind: 'markup'")
2431
2435
  expect(content).not.toMatch(/\.nodeValue = String\(__val\)(?! )/)
2432
2436
  })
2433
2437
 
@@ -2464,8 +2468,10 @@ describe('Client JS generation', () => {
2464
2468
  expect(clientJs).toBeDefined()
2465
2469
  const content = clientJs!.content
2466
2470
 
2467
- // text() inside a component inside a conditional uses $t() in branch-scoped effect
2468
- expect(content).toContain('$t(__branchScope')
2471
+ // text() inside a component inside a conditional writes through a
2472
+ // claimed 'markup' slot built fresh in the branch's own bindEvents
2473
+ // (slot unification A3) — re-claimed on every branch activation.
2474
+ expect(content).toContain("lazySlots(__branchScope, [{ id: '^s2', kind: 'markup', path: [] }])")
2469
2475
  })
2470
2476
 
2471
2477
  test('propagates insideConditional through fragment children', () => {
@@ -2495,8 +2501,10 @@ describe('Client JS generation', () => {
2495
2501
  expect(clientJs).toBeDefined()
2496
2502
  const content = clientJs!.content
2497
2503
 
2498
- // text() inside a fragment inside a conditional uses $t() in branch-scoped effect
2499
- expect(content).toContain('$t(__branchScope')
2504
+ // text() inside a fragment inside a conditional writes through a
2505
+ // claimed 'markup' slot built fresh in the branch's own bindEvents
2506
+ // (slot unification A3) — re-claimed on every branch activation.
2507
+ expect(content).toContain("lazySlots(__branchScope, [{ id: 's2', kind: 'markup', path: [] }])")
2500
2508
  })
2501
2509
  })
2502
2510
 
@@ -2,7 +2,7 @@
2
2
  * BarefootJS Compiler - Composite loops inside conditional branches (#724)
3
3
  *
4
4
  * Verifies that loops with child components inside ternary conditionals
5
- * generate reconcileElements with createComponent in the branch's bindEvents.
5
+ * generate mapArray with createComponent in the branch's bindEvents.
6
6
  */
7
7
 
8
8
  import { describe, test, expect } from 'bun:test'
@@ -52,7 +52,7 @@ describe('composite loops inside conditional branches (#724)', () => {
52
52
  expect(clientJs).toBeDefined()
53
53
  const js = clientJs!.content
54
54
 
55
- // Should use reconcileElements inside the branch's bindEvents
55
+ // Should use mapArray inside the branch's bindEvents
56
56
  expect(js).toContain('mapArray(')
57
57
 
58
58
  // After O-1's mode-collapse PR, child component init goes through the
@@ -104,7 +104,11 @@ export function Viewer() {
104
104
  const files = Object.fromEntries(r.files.map(f => [f.path, f.content]))
105
105
  const ssr = Object.values(files).find(c => c.includes('export function Viewer'))
106
106
  expect(ssr).toBeDefined()
107
- expect(ssr).toContain('client:s')
107
+ // Slot unification Step B: `{val()}` is the SPAN's only, non-adjacent
108
+ // child, so its marker pair is elided entirely (see the identical note
109
+ // in module-level-signal.audit.test.ts) — the client's claim plan
110
+ // creates the missing Text node from `elidedPath` alone.
111
+ expect(ssr).not.toContain('bfText(')
108
112
  expect(ssr).not.toContain("const val = () =>")
109
113
  })
110
114
 
@@ -65,12 +65,10 @@ describe('JSX expression referencing a local declared inside an early-return if-
65
65
 
66
66
  const content = clientJsContent(result)
67
67
 
68
- // Pre-fix: emitted `updateClientMarker(__scope, 's4', aLocal)` at
69
- // outer init scope `aLocal` undefined. Post-fix: the JSX is
70
- // inlined directly into the template, so the createEffect /
71
- // updateClientMarker block is not needed at all for this static
72
- // shape.
73
- expect(content).not.toContain('updateClientMarker')
68
+ // Pre-fix, `aLocal` leaked into a reactive-update block at outer init
69
+ // scope where it doesn't exist (see file docstring). Post-fix: the JSX
70
+ // is inlined directly into the template, so no reactive-update block is
71
+ // needed at all for this static shape.
74
72
  expect(content).not.toMatch(/\baLocal\b/)
75
73
 
76
74
  const hydrate = hydrateLine(result)
@@ -97,9 +95,10 @@ describe('JSX expression referencing a local declared inside an early-return if-
97
95
  const content = clientJsContent(result)
98
96
  // The bare `label` identifier must not appear in the emitted init
99
97
  // function — it's resolved to its initializer value at IR-build
100
- // time.
101
- expect(content).not.toMatch(/updateClientMarker\([^)]*\blabel\b[^)]*\)/)
102
- expect(content).toContain("updateClientMarker(__scope, 's4', 'hello')")
98
+ // time. `@client` writes through a claimed 'text' slot writer
99
+ // (`claim-slots.ts`).
100
+ expect(content).not.toMatch(/__bfw_\w+\([^)]*\blabel\b[^)]*\)/)
101
+ expect(content).toContain("__bfw_s4('s4', 'hello')")
103
102
  })
104
103
 
105
104
  test('ternary scope var (`readOnly ? null : <jsx/>`) routes to client-only conditional', () => {
@@ -124,8 +123,8 @@ describe('JSX expression referencing a local declared inside an early-return if-
124
123
  expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
125
124
 
126
125
  const content = clientJsContent(result)
127
- // Pre-fix: `updateClientMarker(__scope, 's*', compactResize)`
128
- // referenced an undeclared name. Post-fix: the ternary becomes an
126
+ // Pre-fix, `compactResize` leaked into the emitted reactive-update
127
+ // block as an undeclared name. Post-fix: the ternary becomes an
129
128
  // IRConditional with `clientOnly: true`, routed through `insert()`
130
129
  // — and `props.readOnly` is rewritten to `_p.readOnly`.
131
130
  expect(content).not.toMatch(/\bcompactResize\b/)
@@ -440,10 +440,12 @@ describe('inline JSX as object-literal arrow value (#1663)', () => {
440
440
  expect(js).toMatch(/hydrate\(\s*['"]BFInlineJsxCallback1(?:__|['"])/)
441
441
  })
442
442
 
443
- test('dynamic JSX-returning call routes the slot through __bfText', () => {
443
+ test('dynamic JSX-returning call routes the slot through a claimed markup slot', () => {
444
444
  // The child expression `{themeLogo(props.id)}` evaluates to a live
445
445
  // component element on the client; the slot update must splice it in by
446
- // identity via __bfText rather than stringify it into "[object …]".
446
+ // identity via a claimed 'markup' slot writer (slot unification A3)
447
+ // rather than stringify it into "[object …]" — the same contract
448
+ // `__bfText` used to provide.
447
449
  const source = `
448
450
  'use client'
449
451
  import { BrandLogo } from './brand-logo'
@@ -458,7 +460,7 @@ describe('inline JSX as object-literal arrow value (#1663)', () => {
458
460
  }
459
461
  `
460
462
  const js = clientJs(source)
461
- expect(js).toContain('__bfText(')
462
- expect(js).toMatch(/__bfText[^']*from '@barefootjs\/client\/runtime'/)
463
+ expect(js).toContain("kind: 'markup'")
464
+ expect(js).toMatch(/lazySlots[^']*from '@barefootjs\/client\/runtime'/)
463
465
  })
464
466
  })
@@ -359,11 +359,12 @@ describe('JSX props (#559)', () => {
359
359
 
360
360
  const clientJs = result.files.find(f => f.type === 'clientJs')
361
361
  expect(clientJs).toBeDefined()
362
- // The text update routes through `__bfText`, which preserves the
363
- // server-rendered DOM for `__isSlot` values (and splices live Nodes
364
- // by identity) instead of the old inline `nodeValue = String(...)`
365
- // assignment. The `__isSlot` guard now lives inside that helper (#1663).
366
- expect(clientJs!.content).toContain('__bfText')
362
+ // The text update routes through a claimed 'markup' slot writer (slot
363
+ // unification A3), whose `writeMarkup` preserves the server-rendered
364
+ // DOM for `__isSlot` values (and splices live Nodes by identity)
365
+ // instead of the old inline `nodeValue = String(...)` assignment
366
+ // the same contract `__bfText` used to provide (#1663).
367
+ expect(clientJs!.content).toContain("kind: 'markup'")
367
368
  })
368
369
  })
369
370
  })
@@ -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
@@ -89,8 +89,8 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
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)', () => {
@@ -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)
@@ -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'