@barefootjs/jsx 0.26.2 → 0.26.3

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 (80) 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 +662 -223
  15. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +2 -0
  17. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +12 -0
  25. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/html-template.d.ts +19 -1
  31. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/types.d.ts +4 -4
  34. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  35. package/dist/jsx-to-ir.d.ts.map +1 -1
  36. package/dist/loop-destructure.d.ts.map +1 -1
  37. package/dist/strip-types.d.ts +18 -0
  38. package/dist/strip-types.d.ts.map +1 -1
  39. package/dist/types.d.ts +96 -32
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +2 -2
  42. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +94 -0
  43. package/src/__tests__/client-js-generation.test.ts +10 -3
  44. package/src/__tests__/compiler-stress-1244.test.ts +12 -1
  45. package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
  46. package/src/__tests__/flatmap-segments.test.ts +80 -0
  47. package/src/__tests__/map-arbitrary-body.test.ts +226 -0
  48. package/src/__tests__/map-body-no-silent-divergence.test.ts +360 -0
  49. package/src/__tests__/map-multi-return-body.test.ts +172 -0
  50. package/src/__tests__/unsupported-expression.test.ts +20 -2
  51. package/src/adapters/interface.ts +40 -0
  52. package/src/adapters/jsx-adapter.ts +10 -0
  53. package/src/adapters/loop-bound-names.ts +6 -2
  54. package/src/adapters/parsed-expr-emitter.ts +5 -10
  55. package/src/adapters/test-adapter.ts +10 -0
  56. package/src/analyzer-context.ts +35 -1
  57. package/src/analyzer.ts +162 -24
  58. package/src/compiler.ts +2 -2
  59. package/src/expression-parser.ts +27 -19
  60. package/src/ir-to-client-js/build-references.ts +3 -2
  61. package/src/ir-to-client-js/collect-elements.ts +3 -3
  62. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +2 -0
  63. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +7 -2
  64. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +4 -0
  65. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +15 -2
  66. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +16 -3
  67. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +26 -3
  68. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -2
  69. package/src/ir-to-client-js/control-flow/plan/loop.ts +12 -0
  70. package/src/ir-to-client-js/control-flow/shared.ts +2 -1
  71. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +9 -2
  72. package/src/ir-to-client-js/control-flow.ts +11 -0
  73. package/src/ir-to-client-js/html-template.ts +133 -37
  74. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
  75. package/src/ir-to-client-js/types.ts +4 -3
  76. package/src/jsx-to-ir.ts +690 -164
  77. package/src/loop-destructure.ts +9 -5
  78. package/src/rich-type-refusal.ts +6 -2
  79. package/src/strip-types.ts +47 -0
  80. package/src/types.ts +110 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/jsx",
3
- "version": "0.26.2",
3
+ "version": "0.26.3",
4
4
  "description": "JSX compiler for BarefootJS - transforms JSX to server HTML + client JS",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -53,7 +53,7 @@
53
53
  "directory": "packages/jsx"
54
54
  },
55
55
  "dependencies": {
56
- "@barefootjs/shared": "0.26.2"
56
+ "@barefootjs/shared": "0.26.3"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@barefootjs/client": ">=0.2.0",
@@ -1203,6 +1203,71 @@ hydrate('Example', { init: initExample, template: (_p) => \`<div bf="s1"><!--bf-
1203
1203
  export function Example(_p, __bfKey) { return createComponent('Example', _p, __bfKey) }"
1204
1204
  `;
1205
1205
 
1206
+ exports[`docs/core/rendering/jsx-compatibility.md doc-examples L188 — ❌ BF021 on Go/Mojo — a JS-runtime target (Hono, CSR) runs the comparator 1`] = `
1207
+ "import { $, $t, __bfText, createComponent, createEffect, createSignal, escapeText, hydrate, initChild, mapArray, renderChild } from '@barefootjs/client/runtime'
1208
+
1209
+ export function initTodoItem(__scope, _p = {}) {
1210
+ if (!__scope) return
1211
+ const __scopeId = __scope.getAttribute('bf-s')
1212
+
1213
+ const [_s0] = $t(__scope, 's0')
1214
+
1215
+ let __anchor_s0 = _s0
1216
+ createEffect(() => {
1217
+ const __val = String(_p.todo)
1218
+ __anchor_s0 = __bfText(__anchor_s0, __val)
1219
+ })
1220
+
1221
+ }
1222
+
1223
+ hydrate('TodoItem__b3c36eee', { init: initTodoItem, template: (_p) => \`<li bf="s1"><!--bf:s0-->\${escapeText(String(_p.todo))}<!--/--></li>\` })
1224
+ export function TodoItem(_p, __bfKey) { return createComponent('TodoItem__b3c36eee', _p, __bfKey) }
1225
+ export function initItem(__scope, _p = {}) {
1226
+ if (!__scope) return
1227
+ const __scopeId = __scope.getAttribute('bf-s')
1228
+
1229
+ const [_s0] = $t(__scope, 's0')
1230
+
1231
+ let __anchor_s0 = _s0
1232
+ createEffect(() => {
1233
+ const __val = String(_p.item)
1234
+ __anchor_s0 = __bfText(__anchor_s0, __val)
1235
+ })
1236
+
1237
+ }
1238
+
1239
+ hydrate('Item__b3c36eee', { init: initItem, template: (_p) => \`<li bf="s1"><!--bf:s0-->\${escapeText(String(_p.item))}<!--/--></li>\` })
1240
+ export function Item(_p, __bfKey) { return createComponent('Item__b3c36eee', _p, __bfKey) }
1241
+ function initDashboard() {}
1242
+
1243
+ hydrate('Dashboard__b3c36eee', { init: initDashboard, template: (_p) => \`<div>D</div>\` })
1244
+ export function Dashboard(_p, __bfKey) { return createComponent('Dashboard__b3c36eee', _p, __bfKey) }
1245
+ export function initExample(__scope, _p = {}) {
1246
+ if (!__scope) return
1247
+ const __scopeId = __scope.getAttribute('bf-s')
1248
+
1249
+ const children = _p.children
1250
+ const [count, setCount] = createSignal(0)
1251
+ const [isLoggedIn] = createSignal(false)
1252
+ const [todos] = createSignal([])
1253
+ const [items] = createSignal([])
1254
+ const [filter] = createSignal('all')
1255
+ const [accepted] = createSignal(false)
1256
+ const [text, setText] = createSignal('')
1257
+
1258
+ const [_s1] = $(__scope, 's1')
1259
+
1260
+ mapArray(() => items().sort((a, b) => { let r = 0; r = a.name > b.name ? 1 : -1; return r }), _s1, (item) => String(item.id), (item, __idx, __existing) => {
1261
+ if (__existing) { initChild('Item__b3c36eee', __existing, { get item() { return item() } }); return __existing }
1262
+ return createComponent('Item__b3c36eee', { get item() { return item() } }, item().id)
1263
+ }, 'l0')
1264
+
1265
+ }
1266
+
1267
+ hydrate('Example', { init: initExample, template: (_p) => \`<div bf="s1"><!--bf-loop:l0-->\${([]).sort((a, b) => { let r = 0; r = a.name > b.name ? 1 : -1; return r }).map((item) => \`\${renderChild('Item__b3c36eee', {item: item}, item.id)}\`).join('')}<!--bf-/loop:l0--></div>\` })
1268
+ export function Example(_p, __bfKey) { return createComponent('Example', _p, __bfKey) }"
1269
+ `;
1270
+
1206
1271
  exports[`docs/core/rendering/jsx-compatibility.md doc-examples L193 — ✅ Use /* @client */ 1`] = `
1207
1272
  "import { $, $t, __bfText, createComponent, createEffect, createSignal, escapeText, hydrate, initChild, mapArray, renderChild } from '@barefootjs/client/runtime'
1208
1273
 
@@ -1268,6 +1333,35 @@ hydrate('Example', { init: initExample, template: (_p) => \`<div bf="s1"><!--bf-
1268
1333
  export function Example(_p, __bfKey) { return createComponent('Example', _p, __bfKey) }"
1269
1334
  `;
1270
1335
 
1336
+ exports[`docs/core/rendering/jsx-compatibility.md doc-examples L205 — ❌ BF021 on Go/Mojo — \`byPrice\` is imported, not declared in this file 1`] = `
1337
+ "import { $, $t, createComponent, createEffect, escapeText, hydrate, mapArray, tAfter } from '@barefootjs/client/runtime'
1338
+ import { byPrice } from './comparators'
1339
+
1340
+
1341
+ export function initSortedList(__scope, _p = {}) {
1342
+ if (!__scope) return
1343
+ const __scopeId = __scope.getAttribute('bf-s')
1344
+
1345
+ const items = _p.items
1346
+
1347
+ const [_s1] = $(__scope, 's1')
1348
+
1349
+ const __tpl_l0 = document.createElement('template')
1350
+ __tpl_l0.innerHTML = \`<li data-key=""><!--bf:s0--><!--/--></li>\`
1351
+ mapArray(() => items.sort(byPrice), _s1, (item) => String(item.id), (item, __idx, __existing) => {
1352
+ const __el = __existing ?? __tpl_l0.content.firstElementChild.cloneNode(true)
1353
+ const __p = __existing ? null : [__el.firstChild]
1354
+ { const __rt_s0 = __p ? tAfter(__p[0]) : $t(__el, 's0')[0]
1355
+ if (__rt_s0) createEffect(() => { __rt_s0.textContent = String(item().name) }) }
1356
+ return __el
1357
+ }, 'l0')
1358
+
1359
+ }
1360
+
1361
+ hydrate('SortedList__b3c36eee', { init: initSortedList, template: (_p) => \`<ul bf="s1"><!--bf-loop:l0-->\${_p.items.sort(byPrice).map((item) => \`<li data-key="\${item.id}"><!--bf:s0-->\${escapeText(item.name)}<!--/--></li>\`).join('')}<!--bf-/loop:l0--></ul>\` })
1362
+ export function SortedList(_p, __bfKey) { return createComponent('SortedList__b3c36eee', _p, __bfKey) }"
1363
+ `;
1364
+
1271
1365
  exports[`docs/core/rendering/jsx-compatibility.md doc-examples L211 — ✅ Use /* @client */, or inline / re-declare the comparator locally 1`] = `
1272
1366
  "import { $, $t, __bfText, createComponent, createEffect, createSignal, escapeText, hydrate, initChild, mapArray, renderChild } from '@barefootjs/client/runtime'
1273
1367
 
@@ -1824,11 +1824,11 @@ describe('Client JS generation', () => {
1824
1824
  }
1825
1825
  const loop = findLoop(ir.root)
1826
1826
  expect(loop).toBeTruthy()
1827
- expect(loop.mapPreamble).toBeUndefined()
1827
+ expect(loop.preamble).toBeUndefined()
1828
1828
  }
1829
1829
  })
1830
1830
 
1831
- test('generates mapPreamble in IR', () => {
1831
+ test('generates the structured preamble in IR', () => {
1832
1832
  const source = `
1833
1833
  'use client'
1834
1834
  import { createSignal } from '@barefootjs/client'
@@ -1866,7 +1866,14 @@ describe('Client JS generation', () => {
1866
1866
  }
1867
1867
  const loop = findLoop(ir.root)
1868
1868
  expect(loop).toBeTruthy()
1869
- expect(loop.mapPreamble).toContain('const label = item.name.toUpperCase()')
1869
+ // Structured carrier (Stage 3 root cure): the declaration lands in the
1870
+ // preamble's js segments; the IR stays plain serializable data.
1871
+ const jsText = loop.preamble.segments
1872
+ .filter((s: any) => s.kind === 'js')
1873
+ .map((s: any) => s.text)
1874
+ .join('')
1875
+ expect(jsText).toContain('const label = item.name.toUpperCase()')
1876
+ expect(loop.preamble.declaredNames).toContain('label')
1870
1877
  expect(loop.children).toHaveLength(1)
1871
1878
  expect(loop.children[0].type).toBe('element')
1872
1879
  })
@@ -1115,7 +1115,18 @@ describe('<Async> body error path — sync throw + async reject (#1375)', () =>
1115
1115
  })
1116
1116
 
1117
1117
  describe('createPortal inside .map() — per-item portal owner tracking', () => {
1118
- test('one portal created per loop item', () => {
1118
+ // SURFACED LIMITATION. Passing *inline* JSX as a `createPortal` argument is
1119
+ // an unsupported shape: there is no compiler lowering for it (the JSX is
1120
+ // never compiled to DOM ops) and no runtime per-loop-item portal tracking.
1121
+ // Here the `createPortal(<div>…</div>, …)` sits as a non-return statement in
1122
+ // a `.map()` callback, so as of Stage 3 of spec/callback-fidelity.md the
1123
+ // generic "map body builds JSX in a preamble statement" guard refuses it
1124
+ // with BF021 — replacing what was previously a *silent* leak of the raw
1125
+ // `<div>{it.body}</div>` verbatim into the client bundle. The body below
1126
+ // asserts the intended end state (compiles clean, one portal per item);
1127
+ // drop `.todo` once inline-JSX createPortal (and per-item owner tracking)
1128
+ // is actually implemented.
1129
+ test.todo('one portal created per loop item', () => {
1119
1130
  const src = `
1120
1131
  'use client'
1121
1132
  import { createSignal, createPortal } from '@barefootjs/client'
@@ -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,80 @@
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
+ const src = `
26
+ function F({ items }: { items: { id: string; tags: string[] }[] }) {
27
+ return <ul>{items.flatMap((it) => {
28
+ return it.tags.map((t) => <li key={t}>{t}</li>)
29
+ })}</ul>
30
+ }
31
+ export { F }
32
+ `
33
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
34
+ expect(r.errors).toHaveLength(0)
35
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
36
+ expect(cj).toMatch(/escapeText\(\(t\)\)/)
37
+ expect(cj).not.toMatch(/__BF_JSX_/)
38
+ })
39
+
40
+ test('destructured prop refs rewrite to _p.xxx in the hydrate template', () => {
41
+ // The hydrate registration template is module scope (`template: (_p) =>`),
42
+ // so a bare destructured-prop reference in the flatMap body would be a
43
+ // runtime ReferenceError. Per-segment templateText (rewriteBarePropRefs)
44
+ // + textVariant 'template' at the CSR-template site rewrite it.
45
+ const src = `
46
+ function F({ limit, items }: { limit: number; items: { id: string; tags: string[] }[] }) {
47
+ return <ul>{items.flatMap((it) => {
48
+ if (it.tags.length > limit) return []
49
+ return it.tags.map((t) => <li key={t}>{t}</li>)
50
+ })}</ul>
51
+ }
52
+ export { F }
53
+ `
54
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
55
+ expect(r.errors).toHaveLength(0)
56
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
57
+ const tpl = cj.match(/template: \(_p\) => `[\s\S]*?` \}\)/)?.[0] ?? ''
58
+ expect(tpl).toMatch(/_p\.limit/)
59
+ expect(tpl).not.toMatch(/[^.\w]limit\b/)
60
+ })
61
+
62
+ test('TS type annotations in the block body are stripped from the client bundle', () => {
63
+ const src = `
64
+ function F({ items }: { items: { id: string; labels: string[] }[] }) {
65
+ return <ul>{items.flatMap((it) => {
66
+ const upper: string[] = it.labels
67
+ return upper.map((l) => <li key={l}>{l}</li>)
68
+ })}</ul>
69
+ }
70
+ export { F }
71
+ `
72
+ const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
73
+ expect(r.errors).toHaveLength(0)
74
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
75
+ // The annotation must not survive into plain JS...
76
+ expect(cj).not.toMatch(/upper: string\[\]/)
77
+ // ...while the declaration itself does.
78
+ expect(cj).toMatch(/const upper = it\.labels/)
79
+ })
80
+ })
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Stage 3 (D4 + D5) of spec/callback-fidelity.md — a `.map()` callback whose
3
+ * body *constructs* JSX in a statement before its `return` (an imperative
4
+ * array-builder: `const out = []; for (…) out.push(<td/>); return <tr>{out}</tr>`).
5
+ *
6
+ * On a **JS-runtime** adapter (`acceptsCallbackBody() => true`) the body is now
7
+ * rendered verbatim: each JSX leaf lowers to a template-literal HTML string, the
8
+ * imperative control flow runs as-is, and the `{out}` element-array child is
9
+ * joined into the row's innerHTML. No raw JSX leaks into the plain-JS bundle.
10
+ *
11
+ * On a **DSL** adapter (whose template runtime can't run a callback body) it
12
+ * still refuses with BF021, always naming the `/* @client *\/` escape.
13
+ *
14
+ * D5 (keyFn = hoist): the loop key is evaluated before the body runs, so it must
15
+ * be derivable from the raw item — a key that reads a preamble-computed local is
16
+ * refused (it would compile to an unbound keyFn).
17
+ */
18
+
19
+ import { describe, test, expect } from 'bun:test'
20
+ import { compileJSX } from '../compiler'
21
+ import { TestAdapter } from '../adapters/test-adapter'
22
+
23
+ function compile(source: string, dsl: boolean) {
24
+ const a = new TestAdapter()
25
+ // A DSL adapter's template runtime can't run a callback body verbatim.
26
+ if (dsl) a.acceptsCallbackBody = () => false
27
+ return compileJSX(source, 'List.tsx', { adapter: a })
28
+ }
29
+
30
+ const arrayBuilder = `
31
+ function Table({ rows }: { rows: { id: string; cells: string[] }[] }) {
32
+ return <table><tbody>{rows.map((r) => {
33
+ const out = []
34
+ for (const c of r.cells) out.push(<td>{c}</td>)
35
+ return <tr key={r.id}>{out}</tr>
36
+ })}</tbody></table>
37
+ }
38
+ export { Table }
39
+ `
40
+
41
+ describe('.map() array-builder body — JS-runtime verbatim (Stage 3 / D4)', () => {
42
+ test('compiles with no BF021 on a JS-runtime adapter', () => {
43
+ const r = compile(arrayBuilder, false)
44
+ expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
45
+ })
46
+
47
+ test('no raw JSX leaks into the client bundle; the leaf is a lowered string', () => {
48
+ const r = compile(arrayBuilder, false)
49
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
50
+ // The raw `out.push(<td>…)` must not survive — raw JSX is invalid plain JS.
51
+ expect(cj).not.toMatch(/out\.push\(</)
52
+ // …it lowers to a template-literal HTML string instead.
53
+ expect(cj).toMatch(/out\.push\(`<td>/)
54
+ })
55
+
56
+ test('leaf text interpolations escape like the SSR JSX runtime (parity)', () => {
57
+ // A JSX-runtime SSR adapter auto-escapes `{c}`; the client's HTML-string
58
+ // lowering must escape the same position (renderPreamble's leaf door) or
59
+ // special characters in data would parse as markup client-side only.
60
+ // Byte-level parity is pinned by the map-array-builder-escaping fixture.
61
+ const r = compile(arrayBuilder, false)
62
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
63
+ expect(cj).toMatch(/out\.push\(`<td>\$\{escapeText\(\(c\)\)\}<\/td>`\)/)
64
+ })
65
+
66
+ test('the element-array child {out} is joined into the row', () => {
67
+ const r = compile(arrayBuilder, false)
68
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
69
+ // `out` is an array of HTML strings; the child interpolation must join it,
70
+ // not `String([])`-comma-collapse it.
71
+ expect(cj).toMatch(/Array\.isArray\(out\)\s*\?\s*out\.join\(''\)/)
72
+ })
73
+
74
+ test('keyFn is hoisted from the returned root and derived from the raw item', () => {
75
+ const r = compile(arrayBuilder, false)
76
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
77
+ expect(cj).toMatch(/\(r\)\s*=>\s*String\(r\.id\)/)
78
+ })
79
+
80
+ test('DSL adapter refuses with BF021 naming the /* @client */ escape', () => {
81
+ const r = compile(arrayBuilder, true)
82
+ const bf021 = r.errors.filter(e => e.code === 'BF021')
83
+ expect(bf021).toHaveLength(1)
84
+ expect(bf021[0].suggestion?.message ?? '').toMatch(/@client/)
85
+ })
86
+
87
+ test('a /* @client */-marked array-builder compiles clean on a DSL adapter', () => {
88
+ // The escape: the loop renders client-only (the DSL SSR adapter emits
89
+ // markers, the browser runs the verbatim body), so the DSL refusal lifts.
90
+ const src = `
91
+ function Table({ rows }: { rows: { id: string; cells: string[] }[] }) {
92
+ return <table><tbody>{/* @client */ rows.map((r) => {
93
+ const out = []
94
+ for (const c of r.cells) out.push(<td>{c}</td>)
95
+ return <tr key={r.id}>{out}</tr>
96
+ })}</tbody></table>
97
+ }
98
+ export { Table }
99
+ `
100
+ const r = compile(src, true)
101
+ expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
102
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
103
+ // The browser still runs the verbatim body — the leaf is a lowered string.
104
+ expect(cj).toMatch(/out\.push\(`<td>/)
105
+ })
106
+ })
107
+
108
+ describe('.map() array-builder — {out} join is scoped to the bare identifier', () => {
109
+ test('only the bare {out} child joins; a property access on it does not', () => {
110
+ // `{out}` is the element-array child and must join; `{out.length}` is a
111
+ // number and must NOT get the array-join coercion (which would also
112
+ // double-read the expression).
113
+ const src = `
114
+ function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
115
+ return <table><tbody>{rows.map((r) => {
116
+ const out = []
117
+ for (const c of r.cells) out.push(<td>{c}</td>)
118
+ return <tr key={r.id}>{out}<td>{out.length}</td></tr>
119
+ })}</tbody></table>
120
+ }
121
+ export { T }
122
+ `
123
+ const r = compile(src, false)
124
+ const cj = r.files.find(f => f.type === 'clientJs')!.content
125
+ expect(cj).toMatch(/Array\.isArray\(out\)\s*\?\s*out\.join\(''\)/)
126
+ expect(cj).not.toMatch(/Array\.isArray\(out\.length\)/)
127
+ })
128
+ })
129
+
130
+ describe('.map() array-builder — D5 keyFn hoist contract', () => {
131
+ test('a key derived from a preamble-computed local is refused', () => {
132
+ // `k` is computed in the body; the key runs *before* the body, so it cannot
133
+ // reference `k`. Refuse rather than emit an unbound keyFn.
134
+ const src = `
135
+ function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
136
+ return <table><tbody>{rows.map((r) => {
137
+ const k = r.id + '-row'
138
+ const out = []
139
+ for (const c of r.cells) out.push(<td>{c}</td>)
140
+ return <tr key={k}>{out}</tr>
141
+ })}</tbody></table>
142
+ }
143
+ export { T }
144
+ `
145
+ const r = compile(src, false)
146
+ expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(1)
147
+ })
148
+
149
+ test('a key derived from a destructured preamble local is refused', () => {
150
+ // The guard must see `k` even when it is bound via destructuring — else it
151
+ // would compile to a keyFn referencing an unbound `k`.
152
+ const src = `
153
+ function T({ rows }: { rows: { meta: { id: string }; cells: string[] }[] }) {
154
+ return <table><tbody>{rows.map((r) => {
155
+ const { id: k } = r.meta
156
+ const out = []
157
+ for (const c of r.cells) out.push(<td>{c}</td>)
158
+ return <tr key={k}>{out}</tr>
159
+ })}</tbody></table>
160
+ }
161
+ export { T }
162
+ `
163
+ const r = compile(src, false)
164
+ expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(1)
165
+ })
166
+ })
167
+
168
+ describe('.map() array-builder — leaf scope (D-E: refuse what cannot be wired)', () => {
169
+ test('a leaf with an event handler is refused (no silent divergence)', () => {
170
+ const src = `
171
+ function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
172
+ return <table><tbody>{rows.map((r) => {
173
+ const out = []
174
+ for (const c of r.cells) out.push(<td onClick={() => console.log(c)}>{c}</td>)
175
+ return <tr key={r.id}>{out}</tr>
176
+ })}</tbody></table>
177
+ }
178
+ export { T }
179
+ `
180
+ const r = compile(src, false)
181
+ expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(1)
182
+ })
183
+ })
184
+
185
+ describe('.map() arbitrary body — regression guards (unchanged)', () => {
186
+ test('a value-only const preamble still compiles clean (no false positive)', () => {
187
+ const src = `
188
+ function List({ items }: { items: { id: string; name: string }[] }) {
189
+ return <ul>{items.map((it) => {
190
+ const label = it.name.toUpperCase()
191
+ return <li key={it.id}>{label}</li>
192
+ })}</ul>
193
+ }
194
+ export { List }
195
+ `
196
+ const r = compile(src, false)
197
+ expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
198
+ const cj = r.files.find(f => f.type === 'clientJs')!
199
+ expect(cj.content).toMatch(/const label\b/)
200
+ })
201
+
202
+ test('JSX in unreachable code after the return does not trip BF021', () => {
203
+ const src = `
204
+ function List({ items }: { items: { id: string }[] }) {
205
+ return <ul>{items.map((it) => {
206
+ return <li key={it.id}>{it.id}</li>
207
+ const dead = <span>{it.id}</span>
208
+ })}</ul>
209
+ }
210
+ export { List }
211
+ `
212
+ const r = compile(src, false)
213
+ expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
214
+ })
215
+
216
+ test('a plain single-return JSX body is unaffected on a DSL adapter', () => {
217
+ const src = `
218
+ function List({ items }: { items: { id: string }[] }) {
219
+ return <ul>{items.map((it) => { return <li key={it.id}>{it.id}</li> })}</ul>
220
+ }
221
+ export { List }
222
+ `
223
+ const r = compile(src, true)
224
+ expect(r.errors.filter(e => e.code === 'BF021')).toHaveLength(0)
225
+ })
226
+ })