@barefootjs/jsx 0.17.1 → 0.18.0

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 (79) hide show
  1. package/dist/adapters/interface.d.ts +20 -4
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer-context.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/augment-inherited-props.d.ts +19 -0
  8. package/dist/augment-inherited-props.d.ts.map +1 -1
  9. package/dist/compiler.d.ts.map +1 -1
  10. package/dist/expression-parser.d.ts +1 -0
  11. package/dist/expression-parser.d.ts.map +1 -1
  12. package/dist/index.d.ts +19 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +815 -251
  15. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  19. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  24. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  26. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/types.d.ts +22 -1
  28. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  29. package/dist/jsx-to-ir.d.ts.map +1 -1
  30. package/dist/loop-destructure.d.ts +55 -18
  31. package/dist/loop-destructure.d.ts.map +1 -1
  32. package/dist/lowering-registry.d.ts +13 -0
  33. package/dist/lowering-registry.d.ts.map +1 -1
  34. package/dist/relocate.d.ts +28 -0
  35. package/dist/relocate.d.ts.map +1 -1
  36. package/dist/ssr-defaults.d.ts.map +1 -1
  37. package/dist/types.d.ts +68 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +2 -2
  40. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  41. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  42. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  43. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  44. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  45. package/src/__tests__/destructured-map-params.test.ts +11 -1
  46. package/src/__tests__/expression-parser.test.ts +63 -2
  47. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  48. package/src/__tests__/loop-destructure.test.ts +313 -0
  49. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  50. package/src/__tests__/props-destructuring.test.ts +110 -0
  51. package/src/__tests__/serialize-parsed-expr.test.ts +70 -3
  52. package/src/__tests__/ssr-defaults.test.ts +20 -0
  53. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  54. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  55. package/src/__tests__/unsupported-expression.test.ts +194 -7
  56. package/src/adapters/interface.ts +20 -4
  57. package/src/adapters/parsed-expr-emitter.ts +19 -2
  58. package/src/analyzer-context.ts +20 -0
  59. package/src/analyzer.ts +74 -1
  60. package/src/augment-inherited-props.ts +139 -9
  61. package/src/compiler.ts +4 -0
  62. package/src/expression-parser.ts +237 -56
  63. package/src/index.ts +23 -1
  64. package/src/ir-to-client-js/collect-elements.ts +15 -1
  65. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  66. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  67. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  68. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  69. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  70. package/src/ir-to-client-js/emit-registration.ts +4 -2
  71. package/src/ir-to-client-js/html-template.ts +198 -1
  72. package/src/ir-to-client-js/index.ts +1 -0
  73. package/src/ir-to-client-js/types.ts +22 -0
  74. package/src/jsx-to-ir.ts +409 -24
  75. package/src/loop-destructure.ts +89 -36
  76. package/src/lowering-registry.ts +16 -0
  77. package/src/relocate.ts +201 -14
  78. package/src/ssr-defaults.ts +34 -32
  79. package/src/types.ts +65 -0
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Tests for #2087 Phase A: `isLowerableLoopDestructure` (née
3
+ * `isLowerableObjectRestDestructure`) — the gate template adapters use to
4
+ * decide whether a `.map()` callback's destructure param can be lowered
5
+ * natively, versus falling back to the BF104 diagnostic.
6
+ *
7
+ * Compiles a small component to IR (`analyzeComponent` + `jsxToIR`, the same
8
+ * helper pattern as `ir-sort-comparator.test.ts`), locates the `IRLoop`
9
+ * node, and asserts the gate's verdict plus (where relevant) the structured
10
+ * `segments` path each binding carries.
11
+ */
12
+
13
+ import { describe, test, expect } from 'bun:test'
14
+ import { analyzeComponent } from '../analyzer'
15
+ import { jsxToIR } from '../jsx-to-ir'
16
+ import { isLowerableLoopDestructure, isLowerableObjectRestDestructure } from '../loop-destructure'
17
+ import { ErrorCodes } from '../errors'
18
+ import type { IRLoop, IRNode } from '../types'
19
+
20
+ function findFirstLoop(node: IRNode): IRLoop {
21
+ if (node.type === 'loop') return node
22
+ if (node.type === 'element' || node.type === 'fragment') {
23
+ for (const child of node.children) {
24
+ if (child.type === 'loop') return child
25
+ if (child.type === 'element' || child.type === 'fragment') {
26
+ try {
27
+ return findFirstLoop(child)
28
+ } catch {
29
+ // keep scanning siblings
30
+ }
31
+ }
32
+ }
33
+ }
34
+ throw new Error('no loop node found in IR')
35
+ }
36
+
37
+ function compileLoop(
38
+ source: string,
39
+ targetComponentName?: string,
40
+ filename = 'Test.tsx',
41
+ ): { loop: IRLoop; errors: ReturnType<typeof analyzeComponent>['errors'] } {
42
+ const ctx = analyzeComponent(source, filename, targetComponentName)
43
+ const ir = jsxToIR(ctx)
44
+ expect(ir).not.toBeNull()
45
+ return { loop: findFirstLoop(ir!), errors: ctx.errors }
46
+ }
47
+
48
+ describe('isLowerableLoopDestructure (#2087)', () => {
49
+ test('simple .field bindings + object-rest read via member access → true (existing behavior)', () => {
50
+ const { loop } = compileLoop(`
51
+ 'use client'
52
+ import { createSignal } from '@barefootjs/client'
53
+
54
+ type Task = { id: string; title: string; flag: string }
55
+ export function RestObject() {
56
+ const [tasks, setTasks] = createSignal<Task[]>([])
57
+ return (
58
+ <ul onClick={() => setTasks(t => t)}>
59
+ {tasks().map(({ id, title, ...rest }) => (
60
+ <li key={id}>{title}:{rest.flag}</li>
61
+ ))}
62
+ </ul>
63
+ )
64
+ }
65
+ `)
66
+ expect(isLowerableLoopDestructure(loop)).toBe(true)
67
+ })
68
+
69
+ test('array-index tuple destructure ([k, v]) → true (NEW)', () => {
70
+ const { loop } = compileLoop(`
71
+ 'use client'
72
+ import { createSignal } from '@barefootjs/client'
73
+
74
+ type Row = readonly [string, string]
75
+ export function IndexPairs() {
76
+ const [rows, setRows] = createSignal<Row[]>([])
77
+ return (
78
+ <ul onClick={() => setRows(r => r)}>
79
+ {rows().map(([k, v]) => (
80
+ <li key={k}>{k}:{v}</li>
81
+ ))}
82
+ </ul>
83
+ )
84
+ }
85
+ `)
86
+ expect(isLowerableLoopDestructure(loop)).toBe(true)
87
+ })
88
+
89
+ test('nested { id, cells: [head] } → true (NEW)', () => {
90
+ const { loop } = compileLoop(`
91
+ 'use client'
92
+ import { createSignal } from '@barefootjs/client'
93
+
94
+ type Row = { id: string; cells: readonly string[] }
95
+ export function NestedHead() {
96
+ const [rows, setRows] = createSignal<Row[]>([])
97
+ return (
98
+ <ul onClick={() => setRows(r => r)}>
99
+ {rows().map(({ id, cells: [head] }) => (
100
+ <li key={id}>{head}</li>
101
+ ))}
102
+ </ul>
103
+ )
104
+ }
105
+ `)
106
+ expect(isLowerableLoopDestructure(loop)).toBe(true)
107
+ })
108
+
109
+ test('array-rest [first, ...tail] with tail.length read → true (NEW)', () => {
110
+ const { loop } = compileLoop(`
111
+ 'use client'
112
+ import { createSignal } from '@barefootjs/client'
113
+
114
+ export function RestTuple() {
115
+ const [rows, setRows] = createSignal<string[][]>([])
116
+ return (
117
+ <ul onClick={() => setRows(r => r)}>
118
+ {rows().map(([first, ...tail]) => (
119
+ <li key={first}>{first} (+{tail.length})</li>
120
+ ))}
121
+ </ul>
122
+ )
123
+ }
124
+ `)
125
+ expect(isLowerableLoopDestructure(loop)).toBe(true)
126
+ })
127
+
128
+ test('object-rest {...rest} spread onto the loop-item root <li> → true (NEW)', () => {
129
+ const { loop } = compileLoop(`
130
+ 'use client'
131
+ import { createSignal } from '@barefootjs/client'
132
+
133
+ type Task = { id: string; title: string; flag: string }
134
+ export function RestSpread() {
135
+ const [tasks, setTasks] = createSignal<Task[]>([])
136
+ return (
137
+ <ul onClick={() => setTasks(t => t)}>
138
+ {tasks().map(({ id, title, ...rest }) => (
139
+ <li key={id} {...rest}>{title}</li>
140
+ ))}
141
+ </ul>
142
+ )
143
+ }
144
+ `)
145
+ expect(isLowerableLoopDestructure(loop)).toBe(true)
146
+ })
147
+
148
+ test('object-rest spread on a COMPONENT (<Child {...rest} />) → false', () => {
149
+ const { loop } = compileLoop(`
150
+ 'use client'
151
+ import { createSignal } from '@barefootjs/client'
152
+
153
+ type Task = { id: string; title: string; flag: string }
154
+ function Child(props: { title: string; flag: string }) {
155
+ return <span>{props.title}</span>
156
+ }
157
+ export function RestOntoComponent() {
158
+ const [tasks, setTasks] = createSignal<Task[]>([])
159
+ return (
160
+ <ul onClick={() => setTasks(t => t)}>
161
+ {tasks().map(({ id, ...rest }) => (
162
+ <li key={id}><Child {...rest} /></li>
163
+ ))}
164
+ </ul>
165
+ )
166
+ }
167
+ `, 'RestOntoComponent')
168
+ expect(isLowerableLoopDestructure(loop)).toBe(false)
169
+ })
170
+
171
+ test('object-rest bare value use in a text expression ({String(rest)}) → false', () => {
172
+ const { loop } = compileLoop(`
173
+ 'use client'
174
+ import { createSignal } from '@barefootjs/client'
175
+
176
+ type Task = { id: string; title: string; flag: string }
177
+ export function RestBareText() {
178
+ const [tasks, setTasks] = createSignal<Task[]>([])
179
+ return (
180
+ <ul onClick={() => setTasks(t => t)}>
181
+ {tasks().map(({ id, ...rest }) => (
182
+ <li key={id}>{String(rest)}</li>
183
+ ))}
184
+ </ul>
185
+ )
186
+ }
187
+ `)
188
+ expect(isLowerableLoopDestructure(loop)).toBe(false)
189
+ })
190
+
191
+ test('object-rest bare value use in an event handler (onClick={() => fn(rest)}) → false', () => {
192
+ const { loop } = compileLoop(`
193
+ 'use client'
194
+ import { createSignal } from '@barefootjs/client'
195
+
196
+ type Task = { id: string; title: string; flag: string }
197
+ export function RestBareHandler() {
198
+ const [tasks, setTasks] = createSignal<Task[]>([])
199
+ const fn = (t: unknown) => {}
200
+ return (
201
+ <ul>
202
+ {tasks().map(({ id, ...rest }) => (
203
+ <li key={id} onClick={() => fn(rest)}>{id}</li>
204
+ ))}
205
+ </ul>
206
+ )
207
+ }
208
+ `)
209
+ expect(isLowerableLoopDestructure(loop)).toBe(false)
210
+ })
211
+
212
+ test('.filter(...).map(({a}) => ...) chain → false', () => {
213
+ const { loop } = compileLoop(`
214
+ 'use client'
215
+ import { createSignal } from '@barefootjs/client'
216
+
217
+ type Task = { id: string; done: boolean }
218
+ export function FilterChain() {
219
+ const [tasks, setTasks] = createSignal<Task[]>([])
220
+ return (
221
+ <ul onClick={() => setTasks(t => t)}>
222
+ {tasks().filter(t => !t.done).map(({ id }) => (
223
+ <li key={id}>{id}</li>
224
+ ))}
225
+ </ul>
226
+ )
227
+ }
228
+ `)
229
+ expect(isLowerableLoopDestructure(loop)).toBe(false)
230
+ })
231
+
232
+ test('binding named __bf_item → false', () => {
233
+ const { loop } = compileLoop(`
234
+ 'use client'
235
+ import { createSignal } from '@barefootjs/client'
236
+
237
+ export function ReservedName() {
238
+ const [rows, setRows] = createSignal<{ __bf_item: string }[]>([])
239
+ return (
240
+ <ul onClick={() => setRows(r => r)}>
241
+ {rows().map(({ __bf_item }) => (
242
+ <li key={__bf_item}>{__bf_item}</li>
243
+ ))}
244
+ </ul>
245
+ )
246
+ }
247
+ `)
248
+ expect(isLowerableLoopDestructure(loop)).toBe(false)
249
+ })
250
+
251
+ test('computed property key still raises BF025 and produces no paramBindings (unchanged)', () => {
252
+ const { loop, errors } = compileLoop(`
253
+ 'use client'
254
+ import { createSignal } from '@barefootjs/client'
255
+
256
+ const KEY = 'a' as const
257
+ export function Computed() {
258
+ const [items, setItems] = createSignal<Record<string, number>[]>([])
259
+ return (
260
+ <ul onClick={() => setItems(i => i)}>
261
+ {items().map(({ [KEY]: v }) => <li key={v}>{v}</li>)}
262
+ </ul>
263
+ )
264
+ }
265
+ `)
266
+ const bf025 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_DESTRUCTURE_REST)
267
+ expect(bf025.length).toBe(1)
268
+ expect(bf025[0].severity).toBe('error')
269
+ expect(loop.paramBindings).toBeUndefined()
270
+ expect(isLowerableLoopDestructure(loop)).toBe(false)
271
+ })
272
+
273
+ test('deprecated alias isLowerableObjectRestDestructure is the same function', () => {
274
+ expect(isLowerableObjectRestDestructure).toBe(isLowerableLoopDestructure)
275
+ })
276
+
277
+ test('segments: nested { id, cells: [head, ...rest] } pins field/index/non-ident classification', () => {
278
+ const { loop } = compileLoop(`
279
+ 'use client'
280
+ import { createSignal } from '@barefootjs/client'
281
+
282
+ type Row = { id: string; cells: readonly string[]; 'data-priority': string }
283
+ export function NestedSegments() {
284
+ const [rows, setRows] = createSignal<Row[]>([])
285
+ return (
286
+ <ul onClick={() => setRows(r => r)}>
287
+ {rows().map(({ id, cells: [head, ...rest], 'data-priority': prio }) => (
288
+ <li key={id}>{head}:{String(rest.length)}:{prio}</li>
289
+ ))}
290
+ </ul>
291
+ )
292
+ }
293
+ `)
294
+ const bindings = loop.paramBindings
295
+ expect(bindings).toBeDefined()
296
+
297
+ const idBinding = bindings!.find(b => b.name === 'id')
298
+ expect(idBinding?.segments).toEqual([{ kind: 'field', key: 'id', isIdent: true }])
299
+
300
+ const headBinding = bindings!.find(b => b.name === 'head')
301
+ expect(headBinding?.segments).toEqual([
302
+ { kind: 'field', key: 'cells', isIdent: true },
303
+ { kind: 'index', index: 0 },
304
+ ])
305
+
306
+ const restBinding = bindings!.find(b => b.name === 'rest')
307
+ expect(restBinding?.rest?.kind).toBe('array')
308
+ expect(restBinding?.segments).toEqual([{ kind: 'field', key: 'cells', isIdent: true }])
309
+
310
+ const prioBinding = bindings!.find(b => b.name === 'prio')
311
+ expect(prioBinding?.segments).toEqual([{ kind: 'field', key: 'data-priority', isIdent: false }])
312
+ })
313
+ })
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Hoisted shared-template fast path for `mapArray` loop bodies (perf).
3
+ *
4
+ * Background: the legacy emission builds a fresh `document.createElement
5
+ * ('template')` + interpolated `innerHTML` per row inside `renderItem`,
6
+ * re-parsing the same HTML shape on every iteration and re-computing
7
+ * `escapeText`/`escapeAttr` for values a loop-child `createEffect` is about
8
+ * to overwrite anyway on its eager first run (double work). When the loop
9
+ * body is a statically-analyzable single-root element tree whose dynamic
10
+ * parts are text/attribute slots already covered by a loop-child effect,
11
+ * `buildLoopSkeletonTemplate` (html-template.ts) builds a STATIC-ONLY
12
+ * skeleton once, and the stringifier (`control-flow/stringify/loop.ts`)
13
+ * declares it before the `mapArray` call and clones from it per row instead.
14
+ *
15
+ * These tests pin:
16
+ * - the fast path fires for the classic js-framework-benchmark row shape
17
+ * (reactive class attr + two reactive text slots + static cells)
18
+ * - the hoisted template contains no `${...}` interpolations and no
19
+ * escapeText/escapeAttr calls
20
+ * - the fast path refuses (falls back to the legacy per-row emission) for
21
+ * an inline ternary/conditional body
22
+ * - the fast path refuses for a loop body carrying a `{...spread}` attr
23
+ * - an SVG loop body either takes the fast path with the correct
24
+ * `<svg>`-wrapped clone, or falls back — never silently mis-namespaces
25
+ * - the SSR-mirror `template:` lambda is unaffected either way (still
26
+ * carries the fully interpolated per-row template with escaping)
27
+ */
28
+
29
+ import { describe, test, expect } from 'bun:test'
30
+ import { compileJSX } from '../compiler'
31
+ import { TestAdapter } from '../adapters/test-adapter'
32
+
33
+ const adapter = new TestAdapter()
34
+
35
+ function compile(source: string, filename: string): { clientJs: string; template: string } {
36
+ const result = compileJSX(source, filename, { adapter })
37
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
38
+ const clientJs = result.files.find(f => f.type === 'clientJs')
39
+ const template = result.files.find(f => f.type === 'markedTemplate')
40
+ expect(clientJs).toBeDefined()
41
+ expect(template).toBeDefined()
42
+ return { clientJs: clientJs!.content, template: template!.content }
43
+ }
44
+
45
+ describe('hoisted loop-body skeleton template (perf)', () => {
46
+ test('fast path applies to the classic benchmark row shape', () => {
47
+ const source = `
48
+ 'use client'
49
+ import { createSignal } from '@barefootjs/client'
50
+
51
+ interface RowData { id: number; label: string }
52
+
53
+ export function Bench() {
54
+ const [rows, setRows] = createSignal<RowData[]>([])
55
+ const [selected, setSelected] = createSignal<number>(0)
56
+ return (
57
+ <table>
58
+ <tbody>
59
+ {rows().map(row => (
60
+ <tr key={row.id} className={selected() === row.id ? 'danger' : ''}>
61
+ <td className="col-md-1">{row.id}</td>
62
+ <td className="col-md-4">
63
+ <a className="lbl" onClick={() => setSelected(row.id)}>{row.label}</a>
64
+ </td>
65
+ <td className="col-md-6"></td>
66
+ </tr>
67
+ ))}
68
+ </tbody>
69
+ </table>
70
+ )
71
+ }
72
+ `
73
+ const { clientJs, template } = compile(source, 'Bench.tsx')
74
+
75
+ // Hoisted declaration present, before the mapArray call.
76
+ const declIdx = clientJs.indexOf('.innerHTML = `<tr')
77
+ const mapArrayIdx = clientJs.indexOf('mapArray(')
78
+ expect(declIdx).toBeGreaterThan(-1)
79
+ expect(mapArrayIdx).toBeGreaterThan(-1)
80
+ expect(declIdx).toBeLessThan(mapArrayIdx)
81
+
82
+ // renderItem clones from the hoisted template instead of re-parsing
83
+ // innerHTML per row.
84
+ expect(clientJs).toMatch(/const __el = __existing \?\? __tpl_\w+\.content\.firstElementChild\.cloneNode\(true\)/)
85
+
86
+ // The hoisted template itself carries no interpolations and no dynamic
87
+ // `class` attribute (that's covered by the reactive-attr createEffect).
88
+ const declMatch = clientJs.match(/\.innerHTML = `(<tr[^\n]*<\/tr>)`/)
89
+ expect(declMatch).not.toBeNull()
90
+ const skeleton = declMatch![1]
91
+ expect(skeleton).not.toContain('${')
92
+ // The `<tr>` root's own reactive `class` attr is omitted entirely
93
+ // (covered by the reactive-attr createEffect) — only its static
94
+ // descendants (`<td class="col-md-1">`, `<a class="lbl">`) keep theirs.
95
+ expect(skeleton).toMatch(/^<tr data-key="" bf="\w+">/)
96
+ expect(skeleton).not.toContain('danger')
97
+ expect(skeleton).toContain('data-key=""')
98
+ expect(skeleton).toContain('<!--bf:')
99
+ // Text slots are empty between their markers.
100
+ expect(skeleton).toMatch(/<!--bf:s\d+--><!--\/-->/)
101
+
102
+ // Static content (the empty col-md-6 cell, the "x"-less <a> shell) is
103
+ // still present verbatim.
104
+ expect(skeleton).toContain('col-md-6')
105
+
106
+ // No escaping helpers in the `init`/renderItem body now that the per-row
107
+ // interpolation is gone (only the reactive createEffects remain, which
108
+ // use textContent/setAttribute — inherently safe). The CSR-mount
109
+ // `template:` lambda passed to `hydrate(...)` is a SEPARATE SSR-mirror
110
+ // string (unchanged by this optimization — see below), so scope the
111
+ // assertion to the code before that lambda.
112
+ const initBody = clientJs.slice(0, clientJs.indexOf('hydrate('))
113
+ expect(initBody).not.toContain('escapeAttr(')
114
+ expect(initBody).not.toContain('escapeText(')
115
+
116
+ // SSR/adapter markedTemplate output is untouched by this optimization —
117
+ // still the reactive `className={...}` expression verbatim (the
118
+ // TestAdapter emits JSX, not a string template, so there's no
119
+ // escapeAttr/escapeText call here — that's a CSR-string-template-only
120
+ // concern, checked below).
121
+ expect(template).toContain("className={`${selected() === row.id ? 'danger' : ''}`}")
122
+
123
+ // The `template:` lambda embedded in the CSR bundle (used for
124
+ // from-scratch, no-existing-DOM mounts) is a separate SSR-mirror string
125
+ // and stays fully interpolated + escaped, unaffected by the hoisted
126
+ // renderItem clone path above.
127
+ const csrMirror = clientJs.slice(clientJs.indexOf('hydrate('))
128
+ expect(csrMirror).toContain('escapeAttr(')
129
+ expect(csrMirror).toContain('escapeText(')
130
+ })
131
+
132
+ test('falls back to per-row emission for an inline conditional/ternary body', () => {
133
+ const source = `
134
+ 'use client'
135
+ import { createSignal } from '@barefootjs/client'
136
+
137
+ export function List() {
138
+ const [items, setItems] = createSignal<string[]>([])
139
+ return (
140
+ <ul onClick={() => setItems(prev => [...prev, 'x'])}>
141
+ {items().map(item => (
142
+ <li key={item}>
143
+ {item.length > 3 ? <b>{item}</b> : <i>{item}</i>}
144
+ </li>
145
+ ))}
146
+ </ul>
147
+ )
148
+ }
149
+ `
150
+ const { clientJs } = compile(source, 'List.tsx')
151
+ expect(clientJs).toContain('mapArray(')
152
+ // No hoisted-template declaration — the fast path refused.
153
+ expect(clientJs).not.toMatch(/__tpl_\w+ = document\.createElement\('template'\)/)
154
+ })
155
+
156
+ test('falls back to per-row emission for a spread attribute in the loop body', () => {
157
+ const source = `
158
+ 'use client'
159
+ import { createSignal } from '@barefootjs/client'
160
+
161
+ interface Item { id: string; extra: Record<string, string> }
162
+
163
+ export function List() {
164
+ const [items, setItems] = createSignal<Item[]>([])
165
+ return (
166
+ <ul onClick={() => setItems([])}>
167
+ {items().map(item => (
168
+ <li key={item.id} {...item.extra}>{item.id}</li>
169
+ ))}
170
+ </ul>
171
+ )
172
+ }
173
+ `
174
+ const { clientJs } = compile(source, 'SpreadList.tsx')
175
+ expect(clientJs).toContain('mapArray(')
176
+ expect(clientJs).not.toMatch(/__tpl_\w+ = document\.createElement\('template'\)/)
177
+ })
178
+
179
+ test('SVG loop body either hoists with the correct <svg> wrap, or falls back — never mis-namespaced', () => {
180
+ const source = `
181
+ 'use client'
182
+ import { createSignal } from '@barefootjs/client'
183
+
184
+ interface Point { id: string; x: number; y: number }
185
+
186
+ export function Dots() {
187
+ const [points, setPoints] = createSignal<Point[]>([])
188
+ return (
189
+ <svg onClick={() => setPoints([])}>
190
+ {points().map(p => (
191
+ <circle key={p.id} cx={p.x} cy={p.y} r="4" />
192
+ ))}
193
+ </svg>
194
+ )
195
+ }
196
+ `
197
+ const { clientJs } = compile(source, 'Dots.tsx')
198
+ expect(clientJs).toContain('mapArray(')
199
+ // A `<circle>` root with only dynamic attrs (all covered by reactive-attr
200
+ // createEffects) and a literal `r="4"` is exactly the shape the fast path
201
+ // proves safe — this DOES hoist, and it must wrap in a synthetic `<svg>`
202
+ // (mirroring `templateRootIsSvg`, #135/#1088) so the cloned node keeps the
203
+ // SVG namespace instead of becoming an inert `HTMLUnknownElement`.
204
+ const hoisted = /__tpl_\w+\.innerHTML = `<svg>(.*?)<\/svg>`/.exec(clientJs)
205
+ expect(hoisted).not.toBeNull()
206
+ expect(hoisted![1]).not.toContain('${')
207
+ expect(hoisted![1]).toContain('<circle')
208
+ expect(clientJs).toMatch(/\.content\.firstElementChild\.firstElementChild\.cloneNode\(true\)/)
209
+ })
210
+
211
+ test('fast path refuses (conservative fallback) whenever it cannot prove the wrap safe', () => {
212
+ // Defensive net: whatever shape a future fixture exercises, the compiler
213
+ // must never emit an un-wrapped hoisted template for an SVG root (that
214
+ // would silently mis-namespace the clone) — either it hoists WITH the
215
+ // `<svg>` wrap, or it doesn't hoist at all.
216
+ const source = `
217
+ 'use client'
218
+ import { createSignal } from '@barefootjs/client'
219
+ interface Seg { id: string; d: string }
220
+ export function Paths() {
221
+ const [segs, setSegs] = createSignal<Seg[]>([])
222
+ return (
223
+ <svg onClick={() => setSegs([])}>
224
+ {segs().map(s => (
225
+ <path key={s.id} d={s.d} />
226
+ ))}
227
+ </svg>
228
+ )
229
+ }
230
+ `
231
+ const { clientJs } = compile(source, 'Paths.tsx')
232
+ const unwrappedHoist = /__tpl_\w+\.innerHTML = `<path/.test(clientJs)
233
+ expect(unwrappedHoist).toBe(false)
234
+ })
235
+ })
@@ -278,3 +278,113 @@ describe('Props Destructuring Warning (BF043)', () => {
278
278
  expect(propsWarnings).toHaveLength(0)
279
279
  })
280
280
  })
281
+
282
+ describe('Destructured props keep their declared types (BF043 fixture #2150)', () => {
283
+ const typeOf = (ctx: ReturnType<typeof analyzeComponent>, name: string) =>
284
+ ctx.propsParams.find((p) => p.name === name)?.type
285
+
286
+ test('resolves scalar member types from a type-alias annotation', () => {
287
+ const source = `
288
+ type Props = {
289
+ value: number
290
+ label: string
291
+ }
292
+
293
+ export function Component({ value, label }: Props) {
294
+ return <div>{label}: {value}</div>
295
+ }
296
+ `
297
+
298
+ const ctx = analyzeComponent(source, 'Component.tsx')
299
+
300
+ expect(typeOf(ctx, 'value')).toEqual({ kind: 'primitive', primitive: 'number', raw: 'number' })
301
+ expect(typeOf(ctx, 'label')).toEqual({ kind: 'primitive', primitive: 'string', raw: 'string' })
302
+ })
303
+
304
+ test('resolves member types from an inline type literal', () => {
305
+ const source = `
306
+ export function Component({ value }: { value: number }) {
307
+ return <div>{value}</div>
308
+ }
309
+ `
310
+
311
+ const ctx = analyzeComponent(source, 'Component.tsx')
312
+
313
+ expect(typeOf(ctx, 'value')).toEqual({ kind: 'primitive', primitive: 'number', raw: 'number' })
314
+ })
315
+
316
+ test('resolves member types from an interface annotation', () => {
317
+ const source = `
318
+ interface Props {
319
+ value: number
320
+ }
321
+
322
+ export function Component({ value }: Props) {
323
+ return <div>{value}</div>
324
+ }
325
+ `
326
+
327
+ const ctx = analyzeComponent(source, 'Component.tsx')
328
+
329
+ expect(typeOf(ctx, 'value')).toEqual({ kind: 'primitive', primitive: 'number', raw: 'number' })
330
+ })
331
+
332
+ test('keys the lookup on the source property name for aliased bindings', () => {
333
+ const source = `
334
+ type Props = { value: number }
335
+
336
+ export function Component({ value: v }: Props) {
337
+ return <div>{v}</div>
338
+ }
339
+ `
340
+
341
+ const ctx = analyzeComponent(source, 'Component.tsx')
342
+
343
+ // The local binding is 'v', but its type comes from the 'value' member.
344
+ expect(typeOf(ctx, 'v')).toEqual({ kind: 'primitive', primitive: 'number', raw: 'number' })
345
+ })
346
+
347
+ test('falls back to unknown when the param has no type annotation', () => {
348
+ const source = `
349
+ export function Component({ value }) {
350
+ return <div>{value}</div>
351
+ }
352
+ `
353
+
354
+ const ctx = analyzeComponent(source, 'Component.tsx')
355
+
356
+ expect(typeOf(ctx, 'value')).toEqual({ kind: 'unknown', raw: 'unknown' })
357
+ })
358
+
359
+ // Deliberate scope: only required primitives are resolved. Optional and
360
+ // non-primitive members stay `unknown` so typed adapters keep their existing
361
+ // interface{}-based lowering (attribute omission, bf_flat/spread/bf_json).
362
+ test('leaves OPTIONAL members as unknown (preserves nillable omission)', () => {
363
+ const source = `
364
+ type Props = { value?: number }
365
+
366
+ export function Component({ value }: Props) {
367
+ return <div>{value}</div>
368
+ }
369
+ `
370
+
371
+ const ctx = analyzeComponent(source, 'Component.tsx')
372
+
373
+ expect(typeOf(ctx, 'value')).toEqual({ kind: 'unknown', raw: 'unknown' })
374
+ })
375
+
376
+ test('leaves NON-primitive members (arrays/objects) as unknown', () => {
377
+ const source = `
378
+ type Props = { rows: number[][]; meta: { id: string } }
379
+
380
+ export function Component({ rows, meta }: Props) {
381
+ return <div>{rows.length}{meta.id}</div>
382
+ }
383
+ `
384
+
385
+ const ctx = analyzeComponent(source, 'Component.tsx')
386
+
387
+ expect(typeOf(ctx, 'rows')).toEqual({ kind: 'unknown', raw: 'unknown' })
388
+ expect(typeOf(ctx, 'meta')).toEqual({ kind: 'unknown', raw: 'unknown' })
389
+ })
390
+ })