@barefootjs/jsx 0.26.1 → 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.
- package/dist/adapters/interface.d.ts +29 -0
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/adapters/jsx-adapter.d.ts +9 -0
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/loop-bound-names.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +0 -10
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/adapters/test-adapter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +11 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts +40 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.js +662 -223
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +2 -0
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +12 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +19 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +4 -4
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/loop-destructure.d.ts.map +1 -1
- package/dist/strip-types.d.ts +18 -0
- package/dist/strip-types.d.ts.map +1 -1
- package/dist/types.d.ts +96 -32
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +94 -0
- package/src/__tests__/client-js-generation.test.ts +10 -3
- package/src/__tests__/compiler-stress-1244.test.ts +12 -1
- package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
- package/src/__tests__/flatmap-segments.test.ts +80 -0
- package/src/__tests__/map-arbitrary-body.test.ts +226 -0
- package/src/__tests__/map-body-no-silent-divergence.test.ts +360 -0
- package/src/__tests__/map-multi-return-body.test.ts +172 -0
- package/src/__tests__/unsupported-expression.test.ts +20 -2
- package/src/adapters/interface.ts +40 -0
- package/src/adapters/jsx-adapter.ts +10 -0
- package/src/adapters/loop-bound-names.ts +6 -2
- package/src/adapters/parsed-expr-emitter.ts +5 -10
- package/src/adapters/test-adapter.ts +10 -0
- package/src/analyzer-context.ts +35 -1
- package/src/analyzer.ts +162 -24
- package/src/compiler.ts +2 -2
- package/src/expression-parser.ts +27 -19
- package/src/ir-to-client-js/build-references.ts +3 -2
- package/src/ir-to-client-js/collect-elements.ts +3 -3
- package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +2 -0
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +7 -2
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +4 -0
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +15 -2
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +16 -3
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +26 -3
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -2
- package/src/ir-to-client-js/control-flow/plan/loop.ts +12 -0
- package/src/ir-to-client-js/control-flow/shared.ts +2 -1
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +9 -2
- package/src/ir-to-client-js/control-flow.ts +11 -0
- package/src/ir-to-client-js/html-template.ts +133 -37
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
- package/src/ir-to-client-js/types.ts +4 -3
- package/src/jsx-to-ir.ts +690 -164
- package/src/loop-destructure.ts +9 -5
- package/src/rich-type-refusal.ts +6 -2
- package/src/strip-types.ts +47 -0
- 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.
|
|
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.
|
|
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.
|
|
1827
|
+
expect(loop.preamble).toBeUndefined()
|
|
1828
1828
|
}
|
|
1829
1829
|
})
|
|
1830
1830
|
|
|
1831
|
-
test('generates
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|