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