@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,153 @@
1
+ /**
2
+ * A keyed `.map()` body with an array-builder preamble (`const cells = [];
3
+ * cells.push(<span>...)`) plus a row-level event handler used to make EVERY
4
+ * delegated action throw `t is not a function` (BUG-3).
5
+ *
6
+ * Root cause: the delegated handler's item-lookup binds the plain `.find()`
7
+ * result under the loop param (`const t = items().find(...)`) — `t` is a
8
+ * plain object there, never a signal accessor. But the preamble's JSX leaf
9
+ * was rendered through `irToHtmlTemplate` WITH a loopParams spec, the same
10
+ * one used for the mapArray row-render context (where the param genuinely
11
+ * IS an accessor). That rewrote leaf refs to `t().name` — a call on a plain
12
+ * object — which throws at click time.
13
+ *
14
+ * `build-event-delegation.ts` now renders the delegation-context preamble
15
+ * leaf with no loopParams spec, so refs stay in their plain (`t.name`) form.
16
+ * It also only splices the preamble into a given event's handler when that
17
+ * handler's free identifiers actually reference one of the preamble's
18
+ * declared names (`MapCallbackPreamble.declaredNames`) — the common case is
19
+ * an array builder the handler never reads (`cells` here) — and, when
20
+ * spliced, the preamble always runs INSIDE the item-null guard (BUG-4: a
21
+ * `.find()` miss from a stale-DOM race must short-circuit before a preamble
22
+ * that dereferences the item runs).
23
+ */
24
+
25
+ import { describe, test, expect } from 'bun:test'
26
+ import { compileJSX } from '../compiler'
27
+ import { TestAdapter } from '../adapters/test-adapter'
28
+
29
+ const adapter = new TestAdapter()
30
+
31
+ function clientJsFor(source: string): string {
32
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
33
+ expect(result.errors.filter((e) => e.severity === 'error')).toHaveLength(0)
34
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
35
+ expect(clientJs).toBeDefined()
36
+ return clientJs!.content
37
+ }
38
+
39
+ /**
40
+ * The delegated-dispatcher body sits between the loop's `addEventListener`
41
+ * call and the `hydrate(...)` call that follows it in the emitted module —
42
+ * isolate it so assertions about the HANDLER shape don't accidentally match
43
+ * the (legitimately accessor-form) mapArray row-render callback that
44
+ * precedes it in the same file.
45
+ */
46
+ function delegationBlock(js: string): string {
47
+ const start = js.indexOf('.addEventListener(')
48
+ const end = js.indexOf('\n\nhydrate(')
49
+ expect(start).toBeGreaterThan(-1)
50
+ expect(end).toBeGreaterThan(start)
51
+ return js.slice(start, end)
52
+ }
53
+
54
+ describe('delegated-handler preamble splicing (BUG-3 / BUG-4)', () => {
55
+ test('unreferenced array-builder preamble: no getter-call leaf refs, no dead splice', () => {
56
+ const js = clientJsFor(`
57
+ 'use client'
58
+ import { createSignal } from '@barefootjs/client'
59
+ export function R2a() {
60
+ const [items, setItems] = createSignal([{ id: 1, name: 'x' }, { id: 2, name: 'y' }])
61
+ const del = (id: number) => setItems(items().filter(i => i.id !== id))
62
+ return <ul id="list">{items().map(t => {
63
+ const cells = []
64
+ cells.push(<span>{t.name}</span>)
65
+ return <li key={t.id}>{cells}<button className="del" onClick={() => del(t.id)}>del</button></li>
66
+ })}</ul>
67
+ }
68
+ `)
69
+ const handler = delegationBlock(js)
70
+
71
+ // BUG-3: the leaf inside the delegation-context preamble must stay in
72
+ // plain-object form — `t` is the `.find()` result, not a signal accessor.
73
+ expect(handler).not.toContain('t().')
74
+
75
+ // The preamble (`cells`) is never read by the click handler
76
+ // (`() => del(t.id)`) — it must not be spliced into the dispatcher at
77
+ // all. `cells.push` legitimately appears three times elsewhere: the
78
+ // mapArray row-render callback, the SSR-template literal it mirrors,
79
+ // and (#2389 patch-on-update) the `{cells}` region-patch effect, which
80
+ // re-runs the preamble so it re-reads the live per-item signal. It must
81
+ // not appear a FOURTH time, in the handler.
82
+ expect(handler).not.toContain('cells.push')
83
+ expect(js.split('cells.push')).toHaveLength(4) // three occurrences total
84
+
85
+ // The item guard still gates the handler call.
86
+ expect(handler).toContain('if (t) {')
87
+ expect(handler).toContain('(() => del(t.id))(__bfEvt)')
88
+ })
89
+
90
+ test('preamble referenced by the handler: spliced in plain form, inside the item guard', () => {
91
+ const js = clientJsFor(`
92
+ 'use client'
93
+ import { createSignal } from '@barefootjs/client'
94
+ export function R2b() {
95
+ const [items, setItems] = createSignal([{ id: 1, name: 'x' }, { id: 2, name: 'y' }])
96
+ const del = (label: string) => setItems(items().filter(i => i.name !== label))
97
+ return <ul id="list">{items().map(t => {
98
+ const label = t.name + '!'
99
+ return <li key={t.id}>{label}<button className="del" onClick={() => del(label)}>del</button></li>
100
+ })}</ul>
101
+ }
102
+ `)
103
+ const handler = delegationBlock(js)
104
+
105
+ // Spliced in plain (non-getter) form.
106
+ expect(handler).toContain('const label = t.name + \'!\';')
107
+ expect(handler).not.toContain('t().')
108
+
109
+ // Guard-order (part 3): the preamble line and the handler call both
110
+ // land AFTER `if (t) {`, i.e. inside the item-null guard.
111
+ const guardIdx = handler.indexOf('if (t) {')
112
+ const preambleIdx = handler.indexOf('const label = t.name')
113
+ const callIdx = handler.indexOf('(() => del(label))(__bfEvt)')
114
+ expect(guardIdx).toBeGreaterThanOrEqual(0)
115
+ expect(preambleIdx).toBeGreaterThan(guardIdx)
116
+ expect(callIdx).toBeGreaterThan(preambleIdx)
117
+ })
118
+
119
+ test('destructured param + preamble + handler: no accessor leaks, real destructure resolves refs', () => {
120
+ const js = clientJsFor(`
121
+ 'use client'
122
+ import { createSignal } from '@barefootjs/client'
123
+ export function R2c() {
124
+ const [items, setItems] = createSignal([{ id: 1, name: 'x' }, { id: 2, name: 'y' }])
125
+ const del = (label: string) => setItems(items().filter(i => i.name !== label))
126
+ return <ul id="list">{items().map(({ id, name }) => {
127
+ const label = name + '!'
128
+ return <li key={id}>{label}<button className="del" onClick={() => del(label)}>del</button></li>
129
+ })}</ul>
130
+ }
131
+ `)
132
+ const handler = delegationBlock(js)
133
+
134
+ // No `__bfItem()`-style accessor leaks into the delegated handler — the
135
+ // destructured names resolve as real local bindings off `__bfLoopItem`.
136
+ expect(handler).not.toContain('__bfItem(')
137
+
138
+ // Real destructure (#951 TDZ-safe shape) binds `id`/`name` for real —
139
+ // the preamble and handler both close over the resulting plain locals.
140
+ expect(handler).toContain('const __bfLoopItem = ')
141
+ expect(handler).toContain('const { id, name } = __bfLoopItem')
142
+ expect(handler).toContain('const label = name + \'!\';')
143
+ expect(handler).toContain('(() => del(label))(__bfEvt)')
144
+
145
+ // Guard-order holds for the bindings-branch shape too.
146
+ const guardIdx = handler.indexOf('if (__bfLoopItem) {')
147
+ const preambleIdx = handler.indexOf('const label = name')
148
+ const callIdx = handler.indexOf('(() => del(label))(__bfEvt)')
149
+ expect(guardIdx).toBeGreaterThanOrEqual(0)
150
+ expect(preambleIdx).toBeGreaterThan(guardIdx)
151
+ expect(callIdx).toBeGreaterThan(preambleIdx)
152
+ })
153
+ })
@@ -0,0 +1,108 @@
1
+ /**
2
+ * BarefootJS Compiler - Delegated handler slot lookups are scoped (#2367)
3
+ *
4
+ * A stateful component's delegated event handlers resolve the child slot that
5
+ * owns an event with `target.closest('[bf="sN"]')`. Because `bf` ids are
6
+ * assigned **per component**, the same id string legitimately exists in
7
+ * several components — so an *unscoped* `closest()` climbs across component
8
+ * boundaries and can match a same-id element in an ancestor component. The
9
+ * delegation then takes the wrong branch, fails a downstream lookup, and
10
+ * silently drops the real handler (no error thrown).
11
+ *
12
+ * The fix guards the match with `<container>.contains(sNEl)`. The delegating
13
+ * container holds every slot it delegates on, so a same-id element in an
14
+ * *ancestor* component (never a descendant of the container) is rejected and
15
+ * the handler falls through to the correct branch — without growing the
16
+ * client runtime (native `closest` + native `contains`).
17
+ */
18
+
19
+ import { describe, test, expect } from 'bun:test'
20
+ import { compileJSX } from '../compiler'
21
+ import { TestAdapter } from '../adapters/test-adapter'
22
+
23
+ const adapter = new TestAdapter()
24
+
25
+ describe('delegated handler slot lookups are container-scoped (#2367)', () => {
26
+ test('keyed dynamic loop guards each slot match with container.contains', () => {
27
+ // WordTable-shaped repro: a <tbody> delegates keydown to two input slots
28
+ // (front/back). Each slot match must be gated to the tbody container.
29
+ const source = `
30
+ 'use client'
31
+ import { createSignal } from '@barefootjs/client'
32
+
33
+ interface Row { id: string; front: string; back: string }
34
+
35
+ export function WordTable() {
36
+ const [rows, setRows] = createSignal<Row[]>([])
37
+ const handleFront = (row: Row) => console.log('front', row.id)
38
+ const handleBack = (row: Row) => console.log('back', row.id)
39
+
40
+ return (
41
+ <table>
42
+ <tbody>
43
+ {rows().map(row => (
44
+ <tr key={row.id}>
45
+ <td><input onKeyDown={() => handleFront(row)} /></td>
46
+ <td><input onKeyDown={() => handleBack(row)} /></td>
47
+ </tr>
48
+ ))}
49
+ </tbody>
50
+ </table>
51
+ )
52
+ }
53
+ `
54
+ const result = compileJSX(source, 'WordTable.tsx', { adapter })
55
+ expect(result.errors).toHaveLength(0)
56
+
57
+ const clientJs = result.files.find(f => f.type === 'clientJs')!
58
+ const content = clientJs.content
59
+
60
+ // Delegation is wired on the tbody container.
61
+ expect(content).toContain(".addEventListener('keydown', (__bfEvt) => {")
62
+
63
+ // The container variable the listener is attached to.
64
+ const containerMatch = content.match(/if \((_s\d+)\) \1\.addEventListener\('keydown'/)
65
+ expect(containerMatch).not.toBeNull()
66
+ const container = containerMatch![1]
67
+
68
+ // Both slot branches gate the match on `<container>.contains(sNEl)` so a
69
+ // same-id element in an ancestor component (never a descendant of the
70
+ // container) can't be taken.
71
+ const guards = [...content.matchAll(/if \((s\d+)El && (_s\d+)\.contains\(\1El\)\)/g)]
72
+ expect(guards.length).toBe(2)
73
+ for (const m of guards) {
74
+ expect(m[2]).toBe(container)
75
+ }
76
+
77
+ // The lookup itself stays a plain native `closest` — no new runtime helper.
78
+ expect(content).toContain(`target.closest('[bf="`)
79
+ expect(content).not.toContain('closestWithin')
80
+ })
81
+
82
+ test('static-array delegation is also gated by container.contains', () => {
83
+ const source = `
84
+ 'use client'
85
+
86
+ export function List() {
87
+ const items = [{ id: '1', label: 'A' }, { id: '2', label: 'B' }]
88
+ const handleClick = (id: string) => console.log('item', id)
89
+
90
+ return (
91
+ <ul>
92
+ {items.map(item => (
93
+ <li key={item.id}>
94
+ <button onClick={() => handleClick(item.id)}>Go</button>
95
+ </li>
96
+ ))}
97
+ </ul>
98
+ )
99
+ }
100
+ `
101
+ const result = compileJSX(source, 'List.tsx', { adapter })
102
+ expect(result.errors).toHaveLength(0)
103
+
104
+ const content = result.files.find(f => f.type === 'clientJs')!.content
105
+ expect(content).toMatch(/if \(s\d+El && _s\d+\.contains\(s\d+El\)\)/)
106
+ expect(content).not.toContain('closestWithin')
107
+ })
108
+ })
@@ -0,0 +1,262 @@
1
+ /**
2
+ * flatMap block bodies ride the structured-segments carrier (flatMap
3
+ * unification, spec/callback-fidelity.md root cure): the same
4
+ * `renderPreamble()` door as `.map()` preambles, no `__BF_JSX_N__` sentinel.
5
+ * Pins the two behavior fixes that ride the shared machinery:
6
+ *
7
+ * - leaf text interpolations escape like the SSR JSX runtime (`escapeText`),
8
+ * - TS type annotations in the block body are stripped from the client
9
+ * bundle (the old raw-slice carrier spliced them verbatim — invalid JS).
10
+ *
11
+ * Byte-level SSR/CSR parity for flatMap block bodies is NOT pinned at the
12
+ * conformance layer: the CSR string render and the Hono rawBody SSR have
13
+ * pre-existing structural asymmetries (leaf `data-key` emitted client-side
14
+ * only, with an unescaped attribute value; slot comment markers client-side
15
+ * only) that predate the unification and need their own investigation —
16
+ * see the known-limitation issue referenced in the changeset.
17
+ */
18
+
19
+ import { describe, test, expect } from 'bun:test'
20
+ import { compileJSX } from '../compiler'
21
+ import { TestAdapter } from '../adapters/test-adapter'
22
+
23
+ describe('flatMap block bodies on structured segments', () => {
24
+ test('leaf text interpolations are escapeText-wrapped in the client bundle', () => {
25
+ // The early return keeps this a STATEMENT-carrying body (segments
26
+ // carrier) — a pure single-`return` projection now lowers to neutral
27
+ // nested-loop IR instead (see the projection tests below).
28
+ const src = `
29
+ function F({ items }: { items: { id: string; tags: string[] }[] }) {
30
+ return <ul>{items.flatMap((it) => {
31
+ if (it.tags.length > 9) return []
32
+ return it.tags.map((t) => <li key={t}>{t}</li>)
33
+ })}</ul>
34
+ }
35
+ export { F }
36
+ `
37
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
38
+ expect(r.errors).toHaveLength(0)
39
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
40
+ expect(cj).toMatch(/escapeText\(\(t\)\)/)
41
+ expect(cj).not.toMatch(/__BF_JSX_/)
42
+ })
43
+
44
+ test('destructured prop refs rewrite to _p.xxx in the hydrate template', () => {
45
+ // The hydrate registration template is module scope (`template: (_p) =>`),
46
+ // so a bare destructured-prop reference in the flatMap body would be a
47
+ // runtime ReferenceError. Per-segment templateText (rewriteBarePropRefs)
48
+ // + textVariant 'template' at the CSR-template site rewrite it.
49
+ const src = `
50
+ function F({ limit, items }: { limit: number; items: { id: string; tags: string[] }[] }) {
51
+ return <ul>{items.flatMap((it) => {
52
+ if (it.tags.length > limit) return []
53
+ return it.tags.map((t) => <li key={t}>{t}</li>)
54
+ })}</ul>
55
+ }
56
+ export { F }
57
+ `
58
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
59
+ expect(r.errors).toHaveLength(0)
60
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
61
+ const tpl = cj.match(/template: \(_p\) => `[\s\S]*?` \}\)/)?.[0] ?? ''
62
+ expect(tpl).toMatch(/_p\.limit/)
63
+ expect(tpl).not.toMatch(/[^.\w]limit\b/)
64
+ })
65
+
66
+ test('projection expression bodies lower to neutral IR + descriptor client', () => {
67
+ // `it => it.tags.map(...)` (no braces, no statements) is a pure
68
+ // nested-loop PROJECTION: it lowers to neutral IR (inner IRLoop child)
69
+ // that every SSR adapter templatizes — including DSL backends, per
70
+ // spec/callback-fidelity.md's fidelity table — while the client
71
+ // reconciles the flattened leaves through the descriptor mapArray path
72
+ // synthesized from the same inner loop. Pre-fix it fell through to the
73
+ // IRExpression scalar path and spliced raw JSX into the bundle.
74
+ const src = `
75
+ function F({ items }: { items: { id: string; tags: string[] }[] }) {
76
+ return <ul>{items.flatMap((it) => it.tags.map((t) => <li key={t}>{t}</li>))}</ul>
77
+ }
78
+ export { F }
79
+ `
80
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
81
+ expect(r.errors).toHaveLength(0)
82
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
83
+ // No raw JSX anywhere in the bundle.
84
+ expect(cj).not.toMatch(/<li key=\{t\}>/)
85
+ expect(cj).not.toMatch(/__BF_JSX_/)
86
+ if (cj.includes('mapArray(')) {
87
+ // Descriptor accessor flattens through the inner loop with the leaf key.
88
+ expect(cj).toMatch(/\.flatMap\(\(it\) => it\.tags\.map\(\(t\) => \(\{ k: \(t\), h: `<li/)
89
+ expect(cj).toMatch(/String\(__bfD\.k \?\? __bfI\)/)
90
+ }
91
+ })
92
+
93
+ test('a member-expression tag leaf is a component, not a projection leaf', () => {
94
+ // `<icons.Tag/>` starts lowercase but is a component per JSX semantics —
95
+ // the wireless-leaf gate must not admit it to the projection route
96
+ // (which cannot wire components). It falls to the segments carrier,
97
+ // where a DSL-tier adapter refuses loudly instead of templatizing a
98
+ // component tag as literal HTML.
99
+ const src = `
100
+ import { icons } from './icons'
101
+ function F({ items }: { items: { id: string; tags: string[] }[] }) {
102
+ return <ul>{items.flatMap((it) => it.tags.map((t) => <icons.Tag key={t} label={t} />))}</ul>
103
+ }
104
+ export { F }
105
+ `
106
+ const dsl = new TestAdapter()
107
+ ;(dsl as { acceptsCallbackBody?: () => boolean }).acceptsCallbackBody = () => false
108
+ const r = compileJSX(src, 'F.tsx', { adapter: dsl })
109
+ expect(r.errors.filter(e => e.severity === 'error').length).toBeGreaterThan(0)
110
+ })
111
+
112
+ test('single-return block projection lowers identically (DSL adapters accept it)', () => {
113
+ const src = `
114
+ function F({ items }: { items: { id: string; tags: string[] }[] }) {
115
+ return <ul>{items.flatMap((it) => {
116
+ return it.tags.map((t) => <li key={t}>{t}</li>)
117
+ })}</ul>
118
+ }
119
+ export { F }
120
+ `
121
+ const dsl = new TestAdapter()
122
+ ;(dsl as { acceptsCallbackBody?: () => boolean }).acceptsCallbackBody = () => false
123
+ const r = compileJSX(src, 'F.tsx', { adapter: dsl })
124
+ // Neutral IR — no BF021 gate on a DSL-tier adapter.
125
+ expect(r.errors.filter(e => e.severity === 'error')).toHaveLength(0)
126
+ })
127
+
128
+ test('destructured prop refs rewrite to _p.xxx in expression-body hydrate templates', () => {
129
+ const src = `
130
+ function F({ owner, items }: { owner: string; items: { id: string; tags: string[] }[] }) {
131
+ return <ul>{items.flatMap((it) => it.tags.map((t) => <li key={t}>{t} ({owner})</li>))}</ul>
132
+ }
133
+ export { F }
134
+ `
135
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
136
+ expect(r.errors).toHaveLength(0)
137
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
138
+ const tpl = cj.match(/template: \(_p\) => `[\s\S]*?` \}\)/)?.[0] ?? ''
139
+ expect(tpl).toMatch(/_p\.owner/)
140
+ expect(tpl).not.toMatch(/[^.\w]owner\b/)
141
+ })
142
+
143
+ test('client loop reconciles the FLATTENED descriptors, not the source items', () => {
144
+ // The mapArray accessor flattens through the callback body — each leaf a
145
+ // `({ k, h })` descriptor keyed by the leaf's own `key` — so hydration
146
+ // adopts every SSR leaf (not one per source item), adds build real
147
+ // elements from `h` (not an empty template), and the keyFn is the leaf
148
+ // key (not null/index). This pins the client half of the flatMap loop;
149
+ // pre-fix it emitted `mapArray(() => todos(), _sN, null, …innerHTML = ``…)`
150
+ // — leaf loss at hydration and a cloneNode(null) crash on adds.
151
+ const src = `
152
+ 'use client'
153
+ import { createSignal } from '@barefootjs/client'
154
+ export function F() {
155
+ const [todos, setTodos] = createSignal<{ id: number; tags: string[] }[]>([{ id: 1, tags: ['a'] }])
156
+ return <ul>{todos().flatMap((t) => {
157
+ if (t.tags.length > 5) return []
158
+ const prefix = t.id + ':'
159
+ return t.tags.map((tag) => <li key={\`\${t.id}:\${tag}\`}>{prefix}{tag}</li>)
160
+ })}</ul>
161
+ }
162
+ `
163
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
164
+ expect(r.errors).toHaveLength(0)
165
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
166
+ // Flattened source with descriptor leaves…
167
+ expect(cj).toMatch(/mapArray\(\(\) => \(todos\(\)\)\.flatMap\(\(t\) => \{/)
168
+ expect(cj).toMatch(/\(\{ k: \(`\$\{t\.id\}:\$\{tag\}`\), h: `<li>/)
169
+ // …keyed on the leaf key with index fallback…
170
+ expect(cj).toMatch(/\(__bfD, __bfI\) => String\(__bfD\.k \?\? __bfI\)/)
171
+ // …renderItem builds from the descriptor HTML and patches on change…
172
+ expect(cj).toMatch(/__tpl\.innerHTML = __bfD\(\)\.h/)
173
+ expect(cj).toMatch(/patchLeaf\(__el, __html\)/)
174
+ // …and the statements-before-return are NOT duplicated as a renderItem
175
+ // preamble (the segments carrier is the single door).
176
+ expect(cj).not.toMatch(/return \[\];* if \(__existing\)/)
177
+ // Leaf data-key never rides the string templates — mapArray stamps it.
178
+ expect(cj).not.toMatch(/data-key/)
179
+ })
180
+
181
+ test('a leaf with an event handler refuses loudly (no silent dead DOM)', () => {
182
+ const src = `
183
+ 'use client'
184
+ import { createSignal } from '@barefootjs/client'
185
+ export function F() {
186
+ const [todos, setTodos] = createSignal<{ id: number; tags: string[] }[]>([])
187
+ return <ul>{todos().flatMap((t) => {
188
+ return t.tags.map((tag) => <li key={tag} onClick={() => console.log(tag)}>{tag}</li>)
189
+ })}</ul>
190
+ }
191
+ `
192
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
193
+ const errs = r.errors.filter(e => e.severity === 'error')
194
+ expect(errs.length).toBeGreaterThan(0)
195
+ expect(errs[0].message).toContain('cannot carry')
196
+ })
197
+
198
+ test('a fragment leaf refuses loudly (descriptor path is single-element)', () => {
199
+ // The renderItem adopts `template.content.firstElementChild` and
200
+ // patchLeaf patches ONE element root — a fragment leaf would silently
201
+ // drop its siblings client-side while SSR renders them all.
202
+ const src = `
203
+ 'use client'
204
+ import { createSignal } from '@barefootjs/client'
205
+ export function F() {
206
+ const [todos, setTodos] = createSignal<{ id: number; tags: string[] }[]>([])
207
+ return <ul>{todos().flatMap((t) => {
208
+ return t.tags.map((tag) => <><b>{tag}</b><i>{tag}</i></>)
209
+ })}</ul>
210
+ }
211
+ `
212
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
213
+ const errs = r.errors.filter(e => e.severity === 'error')
214
+ expect(errs.length).toBeGreaterThan(0)
215
+ expect(errs.some(e => e.message.includes('must be a single'))).toBe(true)
216
+ })
217
+
218
+ test('the structural net stays armed when an unrelated diagnostic fired earlier', () => {
219
+ // The scalar-fallthrough net de-dups against refusals fired during the
220
+ // SAME map call (entry-count gate), never against diagnostics recorded
221
+ // earlier in the file — a prior error in another loop must not let raw
222
+ // JSX splice silently into the bundle.
223
+ const src = `
224
+ 'use client'
225
+ import { createSignal } from '@barefootjs/client'
226
+ export function F() {
227
+ const [todos, setTodos] = createSignal<{ id: number; tags: string[] }[]>([])
228
+ const wrap = (n: unknown) => n
229
+ return <div>
230
+ <ul>{todos().flatMap((t) => {
231
+ return t.tags.map((tag) => <li key={tag} onClick={() => console.log(tag)}>{tag}</li>)
232
+ })}</ul>
233
+ <ol>{todos().map((t) => wrap(<li>{t.id}</li>))}</ol>
234
+ </div>
235
+ }
236
+ `
237
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
238
+ const errs = r.errors.filter(e => e.severity === 'error')
239
+ // One refusal per loop: the leaf-wiring refusal AND the structural net.
240
+ expect(errs.some(e => e.message.includes('cannot carry'))).toBe(true)
241
+ expect(errs.some(e => e.message.includes('would leak verbatim'))).toBe(true)
242
+ })
243
+
244
+ test('TS type annotations in the block body are stripped from the client bundle', () => {
245
+ const src = `
246
+ function F({ items }: { items: { id: string; labels: string[] }[] }) {
247
+ return <ul>{items.flatMap((it) => {
248
+ const upper: string[] = it.labels
249
+ return upper.map((l) => <li key={l}>{l}</li>)
250
+ })}</ul>
251
+ }
252
+ export { F }
253
+ `
254
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
255
+ expect(r.errors).toHaveLength(0)
256
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
257
+ // The annotation must not survive into plain JS...
258
+ expect(cj).not.toMatch(/upper: string\[\]/)
259
+ // ...while the declaration itself does.
260
+ expect(cj).toMatch(/const upper = it\.labels/)
261
+ })
262
+ })