@barefootjs/jsx 0.26.3 → 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 (99) 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 +735 -140
  5. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/client-only-elision.d.ts +103 -0
  7. package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -0
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +12 -1
  10. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -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-event-delegation.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
  15. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +27 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +13 -2
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts +54 -0
  21. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts.map +1 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -3
  27. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +63 -4
  29. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/control-flow.d.ts +1 -1
  31. package/dist/ir-to-client-js/element-refs.d.ts +8 -3
  32. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/emit-reactive.d.ts +8 -1
  34. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/html-template.d.ts +73 -1
  36. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/imports.d.ts +2 -2
  38. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  39. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/types.d.ts +35 -2
  41. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/utils.d.ts +1 -1
  43. package/dist/jsx-to-ir.d.ts.map +1 -1
  44. package/dist/types.d.ts +70 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +555 -678
  48. package/src/__tests__/child-components-in-map.test.ts +4 -4
  49. package/src/__tests__/client-js-generation.test.ts +26 -12
  50. package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
  51. package/src/__tests__/composite-branch-loop.test.ts +2 -2
  52. package/src/__tests__/cross-file-client-signal.test.ts +5 -1
  53. package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
  54. package/src/__tests__/early-return-scope-var-ref.test.ts +10 -11
  55. package/src/__tests__/flatmap-segments.test.ts +182 -0
  56. package/src/__tests__/inline-jsx-callback.test.ts +6 -4
  57. package/src/__tests__/ir-jsx-props.test.ts +6 -5
  58. package/src/__tests__/loop-fallback-wrap.test.ts +3 -4
  59. package/src/__tests__/map-body-no-silent-divergence.test.ts +45 -0
  60. package/src/__tests__/module-level-signal.audit.test.ts +7 -2
  61. package/src/__tests__/nested-loop-conditional.test.ts +9 -7
  62. package/src/__tests__/nested-loop-reactive-attrs.test.ts +2 -2
  63. package/src/__tests__/preamble-region-patch.test.ts +156 -0
  64. package/src/__tests__/profile-cond-binding-ids.test.ts +3 -2
  65. package/src/__tests__/profile-loop-binding-ids.test.ts +4 -3
  66. package/src/__tests__/profile-nested-binding-ids.test.ts +11 -6
  67. package/src/__tests__/reactive-attrs-in-map.test.ts +6 -4
  68. package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
  69. package/src/__tests__/template-closure.test.ts +1 -1
  70. package/src/compiler.ts +11 -0
  71. package/src/index.ts +1 -0
  72. package/src/ir-to-client-js/build-references.ts +16 -0
  73. package/src/ir-to-client-js/client-only-elision.ts +273 -0
  74. package/src/ir-to-client-js/collect-elements.ts +74 -14
  75. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +12 -1
  76. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +11 -3
  77. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
  78. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +36 -0
  79. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
  80. package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
  81. package/src/ir-to-client-js/control-flow/shared.ts +27 -2
  82. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +34 -4
  83. package/src/ir-to-client-js/control-flow/stringify/claim-plan.ts +68 -0
  84. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +67 -19
  85. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +18 -9
  86. package/src/ir-to-client-js/control-flow/stringify/insert.ts +17 -11
  87. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +35 -16
  88. package/src/ir-to-client-js/control-flow/stringify/loop.ts +80 -14
  89. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +241 -31
  90. package/src/ir-to-client-js/control-flow.ts +1 -1
  91. package/src/ir-to-client-js/element-refs.ts +9 -11
  92. package/src/ir-to-client-js/emit-reactive.ts +50 -14
  93. package/src/ir-to-client-js/html-template.ts +144 -15
  94. package/src/ir-to-client-js/imports.ts +7 -4
  95. package/src/ir-to-client-js/reactivity.ts +7 -0
  96. package/src/ir-to-client-js/types.ts +34 -2
  97. package/src/ir-to-client-js/utils.ts +1 -1
  98. package/src/jsx-to-ir.ts +382 -15
  99. package/src/types.ts +72 -0
@@ -258,6 +258,51 @@ function T({ groups }: { groups: { id: string; items: { id: string; tags: string
258
258
  })}</ul>
259
259
  ))}</div>
260
260
  }
261
+ export { T }`,
262
+ },
263
+ {
264
+ id: 'flatmap-expression-body',
265
+ // The unbraced twin of flatmap-block-body: `t => t.tags.map(...)` with no
266
+ // block. Pre-fix this fell through every dispatch arm to the IRExpression
267
+ // scalar path and spliced the raw callback — JSX included — verbatim into
268
+ // the client bundle (a silent SyntaxError caught only by parseErrors).
269
+ dslTier: true,
270
+ source: `
271
+ function T({ items }: { items: { id: string; tags: string[] }[] }) {
272
+ return <ul>{items.flatMap((it) => it.tags.map((t) => <li key={t}>{t}</li>))}</ul>
273
+ }
274
+ export { T }`,
275
+ },
276
+ {
277
+ id: 'flatmap-expression-body-parenthesized',
278
+ source: `
279
+ function T({ items }: { items: { id: string; tags: string[] }[] }) {
280
+ return <ul>{items.flatMap((it) => (it.tags.map((t) => <li key={t}>{t}</li>)))}</ul>
281
+ }
282
+ export { T }`,
283
+ },
284
+ {
285
+ id: 'flatmap-expression-body-signal-array',
286
+ source: `
287
+ 'use client'
288
+ import { createSignal } from '@barefootjs/client'
289
+ function T() {
290
+ const [items] = createSignal([{ id: '1', tags: ['a'] }])
291
+ return <ul>{items().flatMap((it) => it.tags.map((t) => <li key={t}>{t}</li>))}</ul>
292
+ }
293
+ export { T }`,
294
+ },
295
+ {
296
+ id: 'map-jsx-inside-unrecognized-call',
297
+ // An inline JSX literal in a body shape no dispatch arm recognizes (a call
298
+ // wrapping JSX). The structural net refuses loudly instead of letting the
299
+ // scalar fallback splice the raw JSX into the bundle.
300
+ dslTier: true,
301
+ source: `
302
+ declare function wrap(x: unknown): unknown
303
+ function T({ items }: { items: { id: string }[] }) {
304
+ return <ul>{items.map((it) => wrap(<li key={it.id}>{it.id}</li>))}</ul>
305
+ }
261
306
  export { T }`,
262
307
  },
263
308
  {
@@ -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
  })
@@ -0,0 +1,156 @@
1
+ /**
2
+ * #2389 patch-on-update: a keyed `.map()` row body whose preamble builds
3
+ * content from item state (`const stateLabel = t.done ? ... ; const cells
4
+ * = []; cells.push(<td>{stateLabel}</td>)`) goes STALE on a same-key item
5
+ * update — `mapArray` reuses the row via per-item `setItem`, re-running
6
+ * only the row's wired text/attr slots. `{cells}` had no slot wiring at
7
+ * all, so it froze at its mount-time content forever while the sibling
8
+ * `{t.name}` text slot updated normally.
9
+ *
10
+ * The fix: a loop-body expression child whose free identifiers intersect
11
+ * the preamble's `declaredNames` is classified as a preamble-patched
12
+ * region — slot-marked like an ordinary reactive text (so SSR/CSR row
13
+ * templates render `<!--bf:sN-->...<!--/-->` / `{bfText("sN")}` the same
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).
21
+ */
22
+
23
+ import { describe, test, expect } from 'bun:test'
24
+ import { compileJSX } from '../compiler'
25
+ import { TestAdapter } from '../adapters/test-adapter'
26
+ import { HonoAdapter } from '../../../adapter-hono/src/index.ts'
27
+
28
+ const ROW_SOURCE = `
29
+ 'use client'
30
+ import { createSignal } from '@barefootjs/client'
31
+ export function Todos() {
32
+ const [todos, setTodos] = createSignal([
33
+ { id: 1, name: 'a', done: false },
34
+ { id: 2, name: 'b', done: false },
35
+ ])
36
+ const toggle = (id: number) =>
37
+ setTodos(todos().map(t => t.id === id ? { ...t, done: !t.done } : t))
38
+ return (
39
+ <table><tbody>
40
+ {todos().map((t) => {
41
+ const stateLabel = t.done ? 'done & dusted' : 'open'
42
+ const cells = []
43
+ cells.push(<td className="state">{stateLabel}</td>)
44
+ return (
45
+ <tr key={t.id}>
46
+ {cells}
47
+ <td>{t.name}</td>
48
+ <td><button onClick={() => toggle(t.id)}>toggle</button></td>
49
+ </tr>
50
+ )
51
+ })}
52
+ </tbody></table>
53
+ )
54
+ }
55
+ `
56
+
57
+ function compileWith(adapter: TestAdapter | HonoAdapter, source: string = ROW_SOURCE) {
58
+ const result = compileJSX(source, 'Todos.tsx', { adapter })
59
+ expect(result.errors.filter((e) => e.severity === 'error')).toHaveLength(0)
60
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
61
+ const marked = result.files.find((f) => f.type === 'markedTemplate' || f.type === 'ssr')
62
+ expect(clientJs).toBeDefined()
63
+ return { clientJs: clientJs!.content, marked: marked?.content, result }
64
+ }
65
+
66
+ describe('preamble-region-patch (#2389)', () => {
67
+ test('(a) row template + SSR marked template both carry the region slot marker', () => {
68
+ const { clientJs } = compileWith(new TestAdapter())
69
+ // Row template (the `__tpl.innerHTML = ...` string literal inside
70
+ // renderItem): the region renders as a paired comment marker around the
71
+ // array-joined value, exactly like a reactive text slot.
72
+ expect(clientJs).toMatch(/<!--bf:s\d+-->\$\{Array\.isArray\(cells\) \? cells\.join\(''\) : \(cells \?\? ''\)\}<!--\/-->/)
73
+
74
+ const hono = compileWith(new HonoAdapter())
75
+ expect(hono.clientJs).toContain("kind: 'markup'")
76
+ expect(hono.clientJs).toContain('lazySlots(__el')
77
+ // Hono SSR renders the SAME slotId through `renderExpression`'s generic
78
+ // `{bfText("id")}...{bfTextEnd()}` door — no bespoke region handling.
79
+ const slotMatch = /<!--bf:(s\d+)-->\$\{Array\.isArray\(cells\)/.exec(hono.clientJs)
80
+ expect(slotMatch).not.toBeNull()
81
+ const slotId = slotMatch![1]
82
+ expect(hono.marked).toContain(`{bfText("${slotId}")}`)
83
+ expect(hono.marked).toContain('{cells}')
84
+ expect(hono.marked).toContain('{bfTextEnd()}')
85
+ })
86
+
87
+ test('(b) renderItem emits the region-patch effect, re-running the preamble in accessor form', () => {
88
+ const { clientJs } = compileWith(new TestAdapter())
89
+ expect(clientJs).toContain("kind: 'markup'")
90
+ // The preamble re-runs inside the effect with the loop-param accessor
91
+ // wrap (`t().done`), not the plain (`t.done`) form used at the
92
+ // top-level construction line.
93
+ expect(clientJs).toMatch(/createEffect\(\(\) => \{\s*const stateLabel = t\(\)\.done/)
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\)/)
99
+ })
100
+
101
+ test('(c) a loop without a preamble gets no region', () => {
102
+ const source = `
103
+ 'use client'
104
+ import { createSignal } from '@barefootjs/client'
105
+ export function Plain() {
106
+ const [items, setItems] = createSignal([{ id: 1, name: 'a' }])
107
+ return <ul>{items().map(t => <li key={t.id}>{t.name}</li>)}</ul>
108
+ }
109
+ `
110
+ const { clientJs } = compileWith(new TestAdapter(), source)
111
+ expect(clientJs).not.toContain("kind: 'markup'")
112
+ })
113
+
114
+ test('(c) a static-array loop with a preamble gets no region', () => {
115
+ const source = `
116
+ import { createSignal } from '@barefootjs/client'
117
+ const items = [{ id: 1, done: false }, { id: 2, done: true }]
118
+ export function StaticRows() {
119
+ return (
120
+ <table><tbody>
121
+ {items.map((t) => {
122
+ const stateLabel = t.done ? 'done' : 'open'
123
+ const cells = []
124
+ cells.push(<td>{stateLabel}</td>)
125
+ return <tr key={t.id}>{cells}<td>{t.id}</td></tr>
126
+ })}
127
+ </tbody></table>
128
+ )
129
+ }
130
+ `
131
+ const { clientJs } = compileWith(new TestAdapter(), source)
132
+ expect(clientJs).not.toContain("kind: 'markup'")
133
+ })
134
+
135
+ test('(d) a preamble local never referenced by an expression child gets no region', () => {
136
+ const source = `
137
+ 'use client'
138
+ import { createSignal } from '@barefootjs/client'
139
+ export function Unread() {
140
+ const [todos, setTodos] = createSignal([{ id: 1, name: 'a', done: false }])
141
+ return (
142
+ <ul>
143
+ {todos().map((t) => {
144
+ // Declared but never read as a bare expression child anywhere
145
+ // in the returned JSX — nothing qualifies for a region.
146
+ const stateLabel = t.done ? 'done' : 'open'
147
+ return <li key={t.id}>{t.name}</li>
148
+ })}
149
+ </ul>
150
+ )
151
+ }
152
+ `
153
+ const { clientJs } = compileWith(new TestAdapter(), source)
154
+ expect(clientJs).not.toContain("kind: 'markup'")
155
+ })
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)
@@ -124,8 +124,9 @@ describe('#1247 — static-loop CSR self-heal', () => {
124
124
  const m = clientJs.match(/if \(!__iterEl\) \{[\s\S]*?\n\s+\}\n\s+if \(__iterEl\)/)
125
125
  expect(m).toBeTruthy()
126
126
  const block = m![0]
127
- // The cloned template must reference `emoji` directly.
128
- expect(block).toMatch(/\$\{emoji\}/)
127
+ // The cloned template must reference `emoji` directly (possibly through
128
+ // the escapeAttr/escapeText interpolation wrappers).
129
+ expect(block).toMatch(/\$\{(?:escape(?:Attr|Text)\()?emoji\)?\}/)
129
130
  // It must NOT reference `__bfItem()` — that accessor only exists
130
131
  // inside `mapArray` renderItems, not inside a plain forEach.
131
132
  expect(block).not.toMatch(/__bfItem\(\)/)
@@ -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'
@@ -153,6 +153,13 @@ export function buildReferencesGraph(ctx: ClientJsContext, irRoot: IRNode): Refe
153
153
  if (elem.filterPredicate) addExprEdges(ROOT_SOURCE, elem.filterPredicate.raw, 'template-closure')
154
154
  if (elem.sortComparator) addExprEdges(ROOT_SOURCE, elem.sortComparator.raw, 'template-closure')
155
155
  if (elem.preamble) addExprEdges(ROOT_SOURCE, preambleAnalysisText(elem.preamble), 'template-closure')
156
+ // flatMap descriptor bodies (`elem.flatMapClient`) are NOT traced here:
157
+ // the rendered body embeds leaf HTML template literals, and the regex
158
+ // identifier extractor would turn tag/attr names into false-positive
159
+ // edges. The Phase 3 loop visitor traces the same content structurally
160
+ // (js segments via `preambleAnalysisText`, leaf attrs/keys/interpolations
161
+ // via `walkIR` on `flatMapCallback` segments) — that's what keeps
162
+ // `const maxTags = _p.maxTags` in the extracted props.
156
163
  for (const attr of elem.bindings.reactiveAttrs) {
157
164
  addExprEdges(ROOT_SOURCE, attr.expression, 'template-closure')
158
165
  }
@@ -318,6 +325,15 @@ export function buildReferencesGraph(ctx: ClientJsContext, irRoot: IRNode): Refe
318
325
  if (l.filterPredicate) addExprEdges(ROOT_SOURCE, l.filterPredicate.raw, 'template-closure')
319
326
  if (l.sortComparator) addExprEdges(ROOT_SOURCE, l.sortComparator.raw, 'template-closure')
320
327
  if (l.preamble) addExprEdges(ROOT_SOURCE, preambleAnalysisText(l.preamble), 'template-closure')
328
+ // flatMap bodies: js segments reference init-scope names; segment
329
+ // leaves are IR nodes off `children`, so `descend()` never reaches
330
+ // them — walk them explicitly (mirrors attachParsedExpressions).
331
+ if (l.flatMapCallback) {
332
+ addExprEdges(ROOT_SOURCE, preambleAnalysisText(l.flatMapCallback), 'template-closure')
333
+ for (const seg of l.flatMapCallback.segments) {
334
+ if (seg.kind === 'jsx') walkIR(seg.ir, null, visitor)
335
+ }
336
+ }
321
337
  descend()
322
338
  if (l.childComponent) walkChildComponent(l.childComponent)
323
339
  if (l.nestedComponents) {