@barefootjs/jsx 0.26.2 → 0.26.4

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 (98) hide show
  1. package/dist/adapters/interface.d.ts +29 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +9 -0
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  6. package/dist/adapters/parsed-expr-emitter.d.ts +0 -10
  7. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  8. package/dist/adapters/test-adapter.d.ts.map +1 -1
  9. package/dist/analyzer-context.d.ts +11 -1
  10. package/dist/analyzer-context.d.ts.map +1 -1
  11. package/dist/analyzer.d.ts +40 -1
  12. package/dist/analyzer.d.ts.map +1 -1
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.js +1086 -269
  15. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +14 -1
  18. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
  26. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +39 -0
  28. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow/shared.d.ts +12 -1
  30. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts +1 -1
  32. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -0
  34. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/html-template.d.ts +67 -1
  37. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/imports.d.ts +2 -2
  39. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
  41. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/types.d.ts +29 -4
  43. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  44. package/dist/jsx-to-ir.d.ts.map +1 -1
  45. package/dist/loop-destructure.d.ts.map +1 -1
  46. package/dist/strip-types.d.ts +18 -0
  47. package/dist/strip-types.d.ts.map +1 -1
  48. package/dist/types.d.ts +143 -32
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +277 -33
  52. package/src/__tests__/client-js-generation.test.ts +18 -5
  53. package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
  54. package/src/__tests__/compiler-stress-1244.test.ts +12 -1
  55. package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
  56. package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
  57. package/src/__tests__/flatmap-segments.test.ts +262 -0
  58. package/src/__tests__/map-arbitrary-body.test.ts +226 -0
  59. package/src/__tests__/map-body-no-silent-divergence.test.ts +405 -0
  60. package/src/__tests__/map-multi-return-body.test.ts +172 -0
  61. package/src/__tests__/preamble-region-patch.test.ts +150 -0
  62. package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
  63. package/src/__tests__/unsupported-expression.test.ts +20 -2
  64. package/src/adapters/interface.ts +40 -0
  65. package/src/adapters/jsx-adapter.ts +10 -0
  66. package/src/adapters/loop-bound-names.ts +6 -2
  67. package/src/adapters/parsed-expr-emitter.ts +5 -10
  68. package/src/adapters/test-adapter.ts +10 -0
  69. package/src/analyzer-context.ts +35 -1
  70. package/src/analyzer.ts +162 -24
  71. package/src/compiler.ts +2 -2
  72. package/src/expression-parser.ts +27 -19
  73. package/src/ir-to-client-js/build-references.ts +19 -2
  74. package/src/ir-to-client-js/collect-elements.ts +69 -13
  75. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +14 -1
  76. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +18 -5
  77. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +4 -0
  78. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +15 -2
  79. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +32 -3
  80. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +26 -3
  81. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +53 -2
  82. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
  83. package/src/ir-to-client-js/control-flow/plan/loop.ts +40 -0
  84. package/src/ir-to-client-js/control-flow/shared.ts +28 -2
  85. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +25 -3
  86. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +76 -21
  87. package/src/ir-to-client-js/control-flow/stringify/loop.ts +65 -1
  88. package/src/ir-to-client-js/control-flow.ts +11 -0
  89. package/src/ir-to-client-js/html-template.ts +244 -39
  90. package/src/ir-to-client-js/imports.ts +1 -1
  91. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
  92. package/src/ir-to-client-js/reactivity.ts +6 -0
  93. package/src/ir-to-client-js/types.ts +28 -3
  94. package/src/jsx-to-ir.ts +1059 -167
  95. package/src/loop-destructure.ts +9 -5
  96. package/src/rich-type-refusal.ts +6 -2
  97. package/src/strip-types.ts +47 -0
  98. package/src/types.ts +159 -35
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Stage 2 of spec/callback-fidelity.md — folding a `.map()` callback whose
3
+ * body is an if/else-if chain or `switch` (including fallthrough case labels),
4
+ * optionally preceded by a leading-`const`/`let` preamble, into a nested
5
+ * `IRConditional`, instead of the prior silent verbatim leak. The preamble
6
+ * fold is adapter-gated: a JS runtime folds it, a DSL adapter refuses (BF021 +
7
+ * `/* @client *\/`). Also pins the conservative bail for shapes the fold can't
8
+ * carry — a branch-local local, or a preamble with a null-returning branch —
9
+ * so nothing is dropped silently.
10
+ */
11
+
12
+ import { describe, test, expect } from 'bun:test'
13
+ import { compileJSX } from '../compiler'
14
+ import { extractMultiReturnJsxBranches } from '../analyzer'
15
+ import { TestAdapter } from '../adapters/test-adapter'
16
+ import * as ts from 'typescript'
17
+
18
+ const adapter = new TestAdapter()
19
+
20
+ function clientJs(source: string): string {
21
+ const result = compileJSX(source, 'List.tsx', { adapter })
22
+ expect(result.errors).toHaveLength(0)
23
+ const cj = result.files.find(f => f.type === 'clientJs')
24
+ expect(cj).toBeDefined()
25
+ return cj!.content
26
+ }
27
+
28
+ const wrap = (body: string) => `
29
+ function List({ items }: { items: { id: string; on: boolean; kind: string }[] }) {
30
+ return <ul>{items.map((it) => ${body})}</ul>
31
+ }
32
+ export { List }
33
+ `
34
+
35
+ describe('.map() multi-return body fold (Stage 2)', () => {
36
+ test('if/else-if chain folds to a conditional — no raw JSX leak', () => {
37
+ const js = clientJs(wrap(`{
38
+ if (it.kind === 'a') return <li key={it.id}>A</li>
39
+ else if (it.kind === 'b') return <li key={it.id}>B</li>
40
+ return <li key={it.id}>C</li>
41
+ }`))
42
+ // The raw \`if (...) return <li ...>\` must not survive into the callback.
43
+ expect(js).not.toMatch(/return <li/)
44
+ // A ternary chain over the branch conditions is emitted instead.
45
+ expect(js).toMatch(/kind === 'a'/)
46
+ expect(js).toMatch(/kind === 'b'/)
47
+ })
48
+
49
+ test('switch (with default) folds with a parenthesized strict-equality condition', () => {
50
+ const js = clientJs(wrap(`{
51
+ switch (it.kind) {
52
+ case 'a': return <b key={it.id}>A</b>
53
+ default: return <span key={it.id}>D</span>
54
+ }
55
+ }`))
56
+ expect(js).not.toMatch(/switch\s*\(/)
57
+ // Both operands parenthesized so a low-precedence case keeps === semantics.
58
+ expect(js).toMatch(/\(it\(\)\.kind\) === \('a'\)/)
59
+ })
60
+
61
+ test('switch with fallthrough case labels folds with an OR condition', () => {
62
+ const js = clientJs(wrap(`{
63
+ switch (it.kind) {
64
+ case 'a':
65
+ case 'b':
66
+ return <b key={it.id}>AB</b>
67
+ default:
68
+ return <span key={it.id}>D</span>
69
+ }
70
+ }`))
71
+ expect(js).not.toMatch(/switch\s*\(/)
72
+ expect(js).not.toMatch(/return <(b|span)/)
73
+ // Both fallthrough labels OR-joined into one branch condition.
74
+ expect(js).toMatch(/=== \('a'\)/)
75
+ expect(js).toMatch(/=== \('b'\)/)
76
+ expect(js).toMatch(/\|\|/)
77
+ })
78
+
79
+ describe('conservative bail — no silent drop', () => {
80
+ function extract(body: string, allowPreamble = false) {
81
+ const sf = ts.createSourceFile('t.tsx', `const f = (it: any) => ${body}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
82
+ let block: ts.Block | undefined
83
+ const visit = (n: ts.Node) => {
84
+ if (ts.isArrowFunction(n) && ts.isBlock(n.body)) block = n.body
85
+ ts.forEachChild(n, visit)
86
+ }
87
+ visit(sf)
88
+ return extractMultiReturnJsxBranches(block!, allowPreamble)
89
+ }
90
+
91
+ test('a branch-local const bails (would otherwise be dropped)', () => {
92
+ const r = extract(`{ if (it.on) { const x = it.kind; return <b>{x}</b> } return <span>Z</span> }`)
93
+ expect(r).toBeNull()
94
+ })
95
+
96
+ test('a switch case with an extra statement bails', () => {
97
+ const r = extract(`{ switch (it.kind) { case 'a': { const y = it.id; return <b>{y}</b> } default: return <span>D</span> } }`)
98
+ expect(r).toBeNull()
99
+ })
100
+
101
+ test('a switch case with a break before the return bails (return unreachable)', () => {
102
+ // `case 'a': break; return <A/>` — the break makes the return
103
+ // unreachable at runtime (the case exits to undefined), so it must not
104
+ // fold as if it rendered <A/>. (#2378 review.)
105
+ const r = extract(`{ switch (it.kind) { case 'a': break; return <b>A</b>; default: return <span>D</span> } }`)
106
+ expect(r).toBeNull()
107
+ })
108
+
109
+ test('a leading const is a preamble only when allowPreamble is set', () => {
110
+ const body = `{ const label = it.kind; if (it.on) return <b>{label}</b>; return <span>{label}</span> }`
111
+ // The helper-function inliner passes no allowPreamble — bails.
112
+ expect(extract(body)).toBeNull()
113
+ // The .map() body fold opts in — collects the leading const as a preamble.
114
+ const r = extract(body, true)
115
+ expect(r).not.toBeNull()
116
+ expect(r!.preamble?.length).toBe(1)
117
+ })
118
+
119
+ test('a leading `var` preamble is not collected (var hoisting semantics)', () => {
120
+ // Only `const`/`let` are collectable; `var` hoists differently, so the
121
+ // per-iteration preamble emit wouldn't preserve semantics. (#2379 review.)
122
+ const body = `{ var label = it.kind; if (it.on) return <b>{label}</b>; return <span>{label}</span> }`
123
+ expect(extract(body, true)).toBeNull()
124
+ })
125
+
126
+ test('a preamble with a null-returning branch bails (mapArrayAnchored hazard)', () => {
127
+ // `const f = …; if (!f) return null; return <div key={fid}>…</div>` — the
128
+ // null early-return routes the loop through the anchored conditional-item
129
+ // runtime, where a preamble local breaks keyed reactivity (the
130
+ // pivot-table demo shape). Fold only the all-JSX preamble case.
131
+ const body = `{ const f = it.field; if (!f) return null; return <div key={it.id}>{f}</div> }`
132
+ expect(extract(body, true)).toBeNull()
133
+ })
134
+ })
135
+
136
+ describe('leading-const preamble is adapter-gated (JS folds, DSL refuses)', () => {
137
+ const preambleBody = `{
138
+ const label = it.kind.toUpperCase()
139
+ if (it.on) return <b key={it.id}>{label}</b>
140
+ return <span key={it.id}>{label}</span>
141
+ }`
142
+
143
+ function compileWith(source: string, dsl: boolean) {
144
+ const a = new TestAdapter()
145
+ // Model a DSL adapter: its template runtime can't run a callback body
146
+ // verbatim, so `acceptsCallbackBody` reports false for every kind.
147
+ if (dsl) a.acceptsCallbackBody = () => false
148
+ return compileJSX(source, 'List.tsx', { adapter: a })
149
+ }
150
+
151
+ test('a JS-runtime adapter folds the preamble (const emitted, no error)', () => {
152
+ const r = compileWith(wrap(preambleBody), false)
153
+ expect(r.errors).toHaveLength(0)
154
+ const cj = r.files.find(f => f.type === 'clientJs')!
155
+ expect(cj.content).toMatch(/const label\b/)
156
+ expect(cj.content).not.toMatch(/return <(b|span)/)
157
+ })
158
+
159
+ test('a DSL adapter refuses with BF021 + the /* @client */ escape', () => {
160
+ const r = compileWith(wrap(preambleBody), true)
161
+ const bf021 = r.errors.filter(e => e.code === 'BF021')
162
+ expect(bf021).toHaveLength(1)
163
+ expect(bf021[0].suggestion?.message).toContain('@client')
164
+ })
165
+
166
+ test('/* @client */ suppresses the DSL refusal', () => {
167
+ const clientSource = wrap(preambleBody).replace('items.map', '/* @client */ items.map')
168
+ const r = compileWith(clientSource, true)
169
+ expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
170
+ })
171
+ })
172
+ })
@@ -0,0 +1,150 @@
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 dedicated
15
+ * `patchSlotRange`-based region-patch effect rather than a `reactiveTexts`
16
+ * `.textContent` assignment (which would corrupt the array-joined markup).
17
+ */
18
+
19
+ import { describe, test, expect } from 'bun:test'
20
+ import { compileJSX } from '../compiler'
21
+ import { TestAdapter } from '../adapters/test-adapter'
22
+ import { HonoAdapter } from '../../../adapter-hono/src/index.ts'
23
+
24
+ const ROW_SOURCE = `
25
+ 'use client'
26
+ import { createSignal } from '@barefootjs/client'
27
+ export function Todos() {
28
+ const [todos, setTodos] = createSignal([
29
+ { id: 1, name: 'a', done: false },
30
+ { id: 2, name: 'b', done: false },
31
+ ])
32
+ const toggle = (id: number) =>
33
+ setTodos(todos().map(t => t.id === id ? { ...t, done: !t.done } : t))
34
+ return (
35
+ <table><tbody>
36
+ {todos().map((t) => {
37
+ const stateLabel = t.done ? 'done & dusted' : 'open'
38
+ const cells = []
39
+ cells.push(<td className="state">{stateLabel}</td>)
40
+ return (
41
+ <tr key={t.id}>
42
+ {cells}
43
+ <td>{t.name}</td>
44
+ <td><button onClick={() => toggle(t.id)}>toggle</button></td>
45
+ </tr>
46
+ )
47
+ })}
48
+ </tbody></table>
49
+ )
50
+ }
51
+ `
52
+
53
+ function compileWith(adapter: TestAdapter | HonoAdapter, source: string = ROW_SOURCE) {
54
+ const result = compileJSX(source, 'Todos.tsx', { adapter })
55
+ expect(result.errors.filter((e) => e.severity === 'error')).toHaveLength(0)
56
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
57
+ const marked = result.files.find((f) => f.type === 'markedTemplate' || f.type === 'ssr')
58
+ expect(clientJs).toBeDefined()
59
+ return { clientJs: clientJs!.content, marked: marked?.content, result }
60
+ }
61
+
62
+ describe('preamble-region-patch (#2389)', () => {
63
+ test('(a) row template + SSR marked template both carry the region slot marker', () => {
64
+ const { clientJs } = compileWith(new TestAdapter())
65
+ // Row template (the `__tpl.innerHTML = ...` string literal inside
66
+ // renderItem): the region renders as a paired comment marker around the
67
+ // array-joined value, exactly like a reactive text slot.
68
+ expect(clientJs).toMatch(/<!--bf:s\d+-->\$\{Array\.isArray\(cells\) \? cells\.join\(''\) : \(cells \?\? ''\)\}<!--\/-->/)
69
+
70
+ const hono = compileWith(new HonoAdapter())
71
+ expect(hono.clientJs).toContain('patchSlotRange')
72
+ // Hono SSR renders the SAME slotId through `renderExpression`'s generic
73
+ // `{bfText("id")}...{bfTextEnd()}` door — no bespoke region handling.
74
+ const slotMatch = /<!--bf:(s\d+)-->\$\{Array\.isArray\(cells\)/.exec(hono.clientJs)
75
+ expect(slotMatch).not.toBeNull()
76
+ const slotId = slotMatch![1]
77
+ expect(hono.marked).toContain(`{bfText("${slotId}")}`)
78
+ expect(hono.marked).toContain('{cells}')
79
+ expect(hono.marked).toContain('{bfTextEnd()}')
80
+ })
81
+
82
+ test('(b) renderItem emits the region-patch effect, re-running the preamble in accessor form', () => {
83
+ const { clientJs } = compileWith(new TestAdapter())
84
+ expect(clientJs).toContain('patchSlotRange')
85
+ // The preamble re-runs inside the effect with the loop-param accessor
86
+ // wrap (`t().done`), not the plain (`t.done`) form used at the
87
+ // top-level construction line.
88
+ 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+\)/)
93
+ })
94
+
95
+ test('(c) a loop without a preamble gets no region', () => {
96
+ const source = `
97
+ 'use client'
98
+ import { createSignal } from '@barefootjs/client'
99
+ export function Plain() {
100
+ const [items, setItems] = createSignal([{ id: 1, name: 'a' }])
101
+ return <ul>{items().map(t => <li key={t.id}>{t.name}</li>)}</ul>
102
+ }
103
+ `
104
+ const { clientJs } = compileWith(new TestAdapter(), source)
105
+ expect(clientJs).not.toContain('patchSlotRange')
106
+ })
107
+
108
+ test('(c) a static-array loop with a preamble gets no region', () => {
109
+ const source = `
110
+ import { createSignal } from '@barefootjs/client'
111
+ const items = [{ id: 1, done: false }, { id: 2, done: true }]
112
+ export function StaticRows() {
113
+ return (
114
+ <table><tbody>
115
+ {items.map((t) => {
116
+ const stateLabel = t.done ? 'done' : 'open'
117
+ const cells = []
118
+ cells.push(<td>{stateLabel}</td>)
119
+ return <tr key={t.id}>{cells}<td>{t.id}</td></tr>
120
+ })}
121
+ </tbody></table>
122
+ )
123
+ }
124
+ `
125
+ const { clientJs } = compileWith(new TestAdapter(), source)
126
+ expect(clientJs).not.toContain('patchSlotRange')
127
+ })
128
+
129
+ test('(d) a preamble local never referenced by an expression child gets no region', () => {
130
+ const source = `
131
+ 'use client'
132
+ import { createSignal } from '@barefootjs/client'
133
+ export function Unread() {
134
+ const [todos, setTodos] = createSignal([{ id: 1, name: 'a', done: false }])
135
+ return (
136
+ <ul>
137
+ {todos().map((t) => {
138
+ // Declared but never read as a bare expression child anywhere
139
+ // in the returned JSX — nothing qualifies for a region.
140
+ const stateLabel = t.done ? 'done' : 'open'
141
+ return <li key={t.id}>{t.name}</li>
142
+ })}
143
+ </ul>
144
+ )
145
+ }
146
+ `
147
+ const { clientJs } = compileWith(new TestAdapter(), source)
148
+ expect(clientJs).not.toContain('patchSlotRange')
149
+ })
150
+ })
@@ -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\(\)/)
@@ -125,13 +125,31 @@ describe('Unsupported Expression Error (BF021)', () => {
125
125
  expect(ir!.type).toBe('element')
126
126
  })
127
127
 
128
- test('compileJSX includes IR-phase BF021 errors in result', () => {
129
- const result = compileJSX(unsupportedSource, 'TodoList.tsx', { adapter })
128
+ test('compileJSX surfaces IR-phase BF021 for a DSL target (no acceptsCallbackBody)', () => {
129
+ // Model a DSL adapter: its template runtime can't run an off-subset
130
+ // predicate verbatim, so the Phase-1 diagnostic must surface in the result.
131
+ // Real DSL adapters leave `acceptsCallbackBody` unset; the gate
132
+ // (`?.(kind) ?? false`) treats unset and a false-returning predicate
133
+ // identically, so override it to decline every callback kind.
134
+ const dslAdapter = new TestAdapter()
135
+ dslAdapter.acceptsCallbackBody = () => false
136
+ const result = compileJSX(unsupportedSource, 'TodoList.tsx', { adapter: dslAdapter })
130
137
  const bf021 = result.errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
131
138
 
132
139
  expect(bf021).toHaveLength(1)
133
140
  expect(bf021[0].severity).toBe('error')
134
141
  expect(bf021[0].message).toContain('Expression cannot be compiled to marked template')
142
+ expect(bf021[0].suggestion?.message).toContain('@client')
143
+ })
144
+
145
+ test('compileJSX does NOT raise BF021 for a JS-runtime target (fidelity)', () => {
146
+ // A JS runtime (Hono / CSR — the default TestAdapter, extending JsxAdapter)
147
+ // runs the predicate verbatim, so an off-subset body is not a universal
148
+ // error. It stays in the array string for the runtime to evaluate.
149
+ // See spec/callback-fidelity.md.
150
+ const result = compileJSX(unsupportedSource, 'TodoList.tsx', { adapter })
151
+ const bf021 = result.errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
152
+ expect(bf021).toHaveLength(0)
135
153
  })
136
154
  })
137
155
 
@@ -130,6 +130,37 @@ export type TemplatePrimitiveRegistry = Record<string, TemplatePrimitiveEmit>
130
130
  */
131
131
  export type TemplateCallAcceptor = (calleeName: string) => boolean
132
132
 
133
+ /**
134
+ * The collection-method callbacks whose body the compiler may hand to a
135
+ * backend to run. Used by {@link CallbackBodyAcceptor}. See
136
+ * `spec/callback-fidelity.md`.
137
+ */
138
+ export type CallbackBodyKind =
139
+ | 'filter'
140
+ | 'sort'
141
+ | 'map'
142
+ | 'flatMap'
143
+ | 'find'
144
+ | 'some'
145
+ | 'every'
146
+ | 'reduce'
147
+
148
+ /**
149
+ * Predicate: can this adapter's runtime render an *off-subset* callback body
150
+ * of the given kind verbatim? A callback whose body the compiler can't express
151
+ * as a template / ParsedExpr subtree is only renderable at SSR by a backend
152
+ * whose template runtime is a full JS engine.
153
+ *
154
+ * JS-runtime adapters (Hono SSR, CSR — anything extending `JsxAdapter`) return
155
+ * true, so the compiler keeps the callback inlined for the runtime to execute
156
+ * instead of raising a universal Phase-1 diagnostic. DSL adapters (Go, Perl,
157
+ * …) leave this undefined; an off-subset body then raises the usual diagnostic
158
+ * with the `/* @client *\/` escape, and the user opts that piece into
159
+ * client-only rendering. Granular by kind so a DSL adapter may later accept a
160
+ * subset (e.g. `filter` but not `sort`). See `spec/callback-fidelity.md`.
161
+ */
162
+ export type CallbackBodyAcceptor = (kind: CallbackBodyKind) => boolean
163
+
133
164
  export interface TemplateAdapter {
134
165
  name: string
135
166
  extension: string
@@ -201,6 +232,15 @@ export interface TemplateAdapter {
201
232
  */
202
233
  acceptsTemplateCall?: TemplateCallAcceptor
203
234
 
235
+ /**
236
+ * Whether this adapter's runtime can render an off-subset callback body
237
+ * (`filter`/`sort`/`find`/… predicate or comparator the compiler can't
238
+ * lower to a template / ParsedExpr) verbatim. JS-runtime adapters set this
239
+ * (via `JsxAdapter`); DSL adapters leave it undefined and instead surface
240
+ * the diagnostic + `/* @client *\/` escape. See `spec/callback-fidelity.md`.
241
+ */
242
+ acceptsCallbackBody?: CallbackBodyAcceptor
243
+
204
244
  // Main entry point - generates complete template from IR
205
245
  generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput
206
246
 
@@ -12,6 +12,7 @@ import type {
12
12
  } from '../types.ts'
13
13
  import { BF_SCOPE, BF_SLOT, BF_COND } from '@barefootjs/shared'
14
14
  import { BaseAdapter } from './interface.ts'
15
+ import type { CallbackBodyAcceptor } from './interface.ts'
15
16
  import { ENV_SIGNAL_CLIENT_FACTORY } from './env-signal.ts'
16
17
  import { formatParamWithType, findReachableNames } from '../module-exports.ts'
17
18
 
@@ -26,6 +27,15 @@ export abstract class JsxAdapter extends BaseAdapter {
26
27
  /** Subclasses define whether to use typed values for type-safe output */
27
28
  protected abstract jsxConfig: JsxAdapterConfig
28
29
 
30
+ /**
31
+ * JS-runtime adapters (Hono SSR, CSR, the test adapter) render an off-subset
32
+ * callback body by running it verbatim, so the compiler need not raise a
33
+ * universal Phase-1 diagnostic for a `filter`/`sort`/… body it can't lower.
34
+ * DSL adapters extend `BaseAdapter` and leave this undefined.
35
+ * See `spec/callback-fidelity.md`.
36
+ */
37
+ acceptsCallbackBody: CallbackBodyAcceptor = () => true
38
+
29
39
  // ===========================================================================
30
40
  // Import Formatting
31
41
  // ===========================================================================
@@ -57,8 +57,12 @@ export function collectLoopBoundNames(ir: ComponentIR): Set<string> {
57
57
  for (const nested of node.nestedComponents ?? []) {
58
58
  for (const child of nested.children) visit(child)
59
59
  }
60
- for (const frag of node.flatMapCallback?.fragments ?? []) {
61
- visit(frag.ir)
60
+ for (const seg of node.flatMapCallback?.segments ?? []) {
61
+ if (seg.kind === 'jsx') visit(seg.ir)
62
+ }
63
+ // Preamble leaves (array-builder bodies) are nested IR the same way.
64
+ for (const seg of node.preamble?.segments ?? []) {
65
+ if (seg.kind === 'jsx') visit(seg.ir)
62
66
  }
63
67
  break
64
68
  case 'conditional':
@@ -87,16 +87,11 @@ export type ArrayMethod =
87
87
  */
88
88
  export type SortMethod = 'sort' | 'toSorted'
89
89
 
90
- /**
91
- * `reduce` / `reduceRight` are handled by the dedicated `reduceMethod()`
92
- * dispatcher arm (#1448 Tier C) for the same reason sort is: they carry
93
- * a structured `ReduceOp` (the parsed arithmetic-fold spec) rather than
94
- * a `ParsedExpr[]` args list, so folding them into `arrayMethod()` would
95
- * force a spec-or-args runtime check at every call site. The method name
96
- * is threaded through so adapters can pick the fold direction (left for
97
- * `reduce`, right for `reduceRight`).
98
- */
99
- export type ReduceMethod = 'reduce' | 'reduceRight'
90
+ // NB: there is no structured `reduce` / `reduceRight` emitter type here.
91
+ // #2018 P5 removed the folded `ReduceOp` form a `.reduce(fn, init)` now
92
+ // arrives as a generic `call` whose reducer body + initial value serialize
93
+ // to the runtime evaluator, so no dedicated `reduceMethod()` dispatcher (or
94
+ // `ReduceMethod` type) exists.
100
95
 
101
96
  export type LiteralType = 'string' | 'number' | 'boolean' | 'null'
102
97
 
@@ -253,6 +253,16 @@ export class TestAdapter extends JsxAdapter {
253
253
  // parsed as a block statement (matches hono-adapter behavior).
254
254
  const safeChildren = children.startsWith('{') ? `<>${children}</>` : children
255
255
 
256
+ // A `.map()` callback preamble (a Stage-2 value-only `const`, or a Stage-3
257
+ // arbitrary array-builder) runs verbatim in the block body. Use the typed
258
+ // carrier so raw JSX leaves stay intact for the JSX runtime; `{out}`-style
259
+ // element-array children render natively. (Without this the preamble was
260
+ // dropped and its identifiers rendered unbound.)
261
+ const preamble = loop.preamble?.ssrText
262
+ if (preamble) {
263
+ return `{${loop.array}.map((${loop.param}${indexParam}) => { ${preamble} return ${safeChildren} })}`
264
+ }
265
+
256
266
  return `{${loop.array}.map((${loop.param}${indexParam}) => ${safeChildren})}`
257
267
  }
258
268
 
@@ -25,6 +25,7 @@ import type {
25
25
  DeclinedReactiveFactory,
26
26
  } from './types.ts'
27
27
  import { type ExcludeRange, collectAllTypeRanges, reconstructWithoutTypes } from './strip-types.ts'
28
+ import type { CallbackBodyAcceptor } from './adapters/interface.ts'
28
29
 
29
30
  /**
30
31
  * Deferred info for BF043 (props destructuring warning).
@@ -199,6 +200,15 @@ export interface AnalyzerContext {
199
200
 
200
201
  // Errors
201
202
  errors: CompilerError[]
203
+ /**
204
+ * Capability of the adapter this component is being compiled for: can its
205
+ * runtime render an off-subset callback body (`filter`/`sort`/… predicate)
206
+ * verbatim? Set from `TemplateAdapter.acceptsCallbackBody`; undefined for
207
+ * direct analyzer callers (no adapter) and DSL adapters. Consulted at the
208
+ * Phase-1 callback-lowering sites so a JS-runtime target isn't rejected for
209
+ * a body it could run. See `spec/callback-fidelity.md`.
210
+ */
211
+ acceptsCallbackBody?: CallbackBodyAcceptor
202
212
 
203
213
  // Directive
204
214
  hasUseClientDirective: boolean
@@ -232,11 +242,13 @@ export interface AnalyzerContext {
232
242
 
233
243
  export function createAnalyzerContext(
234
244
  sourceFile: ts.SourceFile,
235
- filePath: string
245
+ filePath: string,
246
+ acceptsCallbackBody?: CallbackBodyAcceptor
236
247
  ): AnalyzerContext {
237
248
  return {
238
249
  sourceFile,
239
250
  filePath,
251
+ acceptsCallbackBody,
240
252
 
241
253
  componentName: null,
242
254
  componentNode: null,
@@ -300,6 +312,22 @@ export function createAnalyzerContext(
300
312
  } catch {
301
313
  ownSourceFile = undefined
302
314
  }
315
+ // Test-gated trust-boundary assertion (write-side string rule,
316
+ // CLAUDE.md): getJS output is spliced into emitted artifacts, so a
317
+ // JSX-bearing node here is a leak by construction — mixed content must
318
+ // travel as structured segments, never as raw text. Every getJS call
319
+ // site shares this contract; the env gate keeps the subtree walk off
320
+ // the production hot path (the trichotomy harness enables it). Scoped
321
+ // to error-free compiles: a compile that already refused loudly may
322
+ // take degraded fallback paths whose artifacts are gated by the error —
323
+ // the invariant this trips on is the SILENT leak.
324
+ if (process.env.BF_ASSERT_NO_JSX_IN_GETJS === '1' && this.errors.length === 0 && nodeContainsJsx(node)) {
325
+ throw new Error(
326
+ 'getJS() called on a JSX-bearing node — raw JSX must never be spliced ' +
327
+ 'into emitted output. Carry mixed content as structured segments ' +
328
+ '(MapCallbackPreamble / FlatMapCallback) instead.'
329
+ )
330
+ }
303
331
  if (ownSourceFile && ownSourceFile !== sourceFile) {
304
332
  return node.getText(ownSourceFile)
305
333
  }
@@ -308,6 +336,12 @@ export function createAnalyzerContext(
308
336
  }
309
337
  }
310
338
 
339
+ /** Subtree JSX check for the test-gated getJS assertion above. */
340
+ function nodeContainsJsx(node: ts.Node): boolean {
341
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true
342
+ return ts.forEachChild(node, nodeContainsJsx) ?? false
343
+ }
344
+
311
345
  // =============================================================================
312
346
  // Source Location Helper
313
347
  // =============================================================================