@barefootjs/jsx 0.17.0 → 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 (88) hide show
  1. package/dist/adapters/env-signal.d.ts +42 -7
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/adapters/interface.d.ts +20 -4
  4. package/dist/adapters/interface.d.ts.map +1 -1
  5. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  6. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  7. package/dist/analyzer-context.d.ts.map +1 -1
  8. package/dist/analyzer.d.ts.map +1 -1
  9. package/dist/augment-inherited-props.d.ts +19 -0
  10. package/dist/augment-inherited-props.d.ts.map +1 -1
  11. package/dist/compiler.d.ts.map +1 -1
  12. package/dist/expression-parser.d.ts +48 -2
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.d.ts +24 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1728 -969
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  26. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/types.d.ts +22 -1
  30. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  31. package/dist/jsx-to-ir.d.ts.map +1 -1
  32. package/dist/loop-destructure.d.ts +55 -18
  33. package/dist/loop-destructure.d.ts.map +1 -1
  34. package/dist/lowering-registry.d.ts +13 -0
  35. package/dist/lowering-registry.d.ts.map +1 -1
  36. package/dist/relocate.d.ts +28 -0
  37. package/dist/relocate.d.ts.map +1 -1
  38. package/dist/ssr-defaults.d.ts.map +1 -1
  39. package/dist/ssr-seed-plan.d.ts +84 -0
  40. package/dist/ssr-seed-plan.d.ts.map +1 -0
  41. package/dist/types.d.ts +79 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +2 -2
  44. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  45. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  46. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  47. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  48. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  49. package/src/__tests__/destructured-map-params.test.ts +11 -1
  50. package/src/__tests__/expression-parser.test.ts +74 -3
  51. package/src/__tests__/free-identifiers.test.ts +55 -0
  52. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  53. package/src/__tests__/loop-destructure.test.ts +313 -0
  54. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  55. package/src/__tests__/materialize-getter-calls.test.ts +58 -0
  56. package/src/__tests__/props-destructuring.test.ts +110 -0
  57. package/src/__tests__/serialize-parsed-expr.test.ts +88 -2
  58. package/src/__tests__/ssr-defaults.test.ts +20 -0
  59. package/src/__tests__/ssr-seed-plan.test.ts +212 -0
  60. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  61. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  62. package/src/__tests__/unsupported-expression.test.ts +194 -7
  63. package/src/adapters/env-signal.ts +57 -9
  64. package/src/adapters/interface.ts +20 -4
  65. package/src/adapters/parsed-expr-emitter.ts +19 -2
  66. package/src/analyzer-context.ts +20 -0
  67. package/src/analyzer.ts +74 -1
  68. package/src/augment-inherited-props.ts +139 -9
  69. package/src/compiler.ts +10 -1
  70. package/src/expression-parser.ts +421 -50
  71. package/src/index.ts +30 -3
  72. package/src/ir-to-client-js/collect-elements.ts +15 -1
  73. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  74. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  75. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  76. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  77. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  78. package/src/ir-to-client-js/emit-registration.ts +4 -2
  79. package/src/ir-to-client-js/html-template.ts +198 -1
  80. package/src/ir-to-client-js/index.ts +1 -0
  81. package/src/ir-to-client-js/types.ts +22 -0
  82. package/src/jsx-to-ir.ts +409 -24
  83. package/src/loop-destructure.ts +89 -36
  84. package/src/lowering-registry.ts +16 -0
  85. package/src/relocate.ts +201 -14
  86. package/src/ssr-defaults.ts +34 -32
  87. package/src/ssr-seed-plan.ts +146 -0
  88. package/src/types.ts +76 -0
@@ -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
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { materializeGetterCalls, parseExpression, stringifyParsedExpr } from '../expression-parser'
3
+
4
+ /**
5
+ * `materializeGetterCalls` rewrites a zero-arg getter call (`tag()`) into the
6
+ * bare identifier (`tag`) wherever the callee name is in the caller-supplied
7
+ * set — the SSR seed/constructor-context reduction described on the function
8
+ * (a getter call reads the already-seeded value, so `tag()` IS `tag` there).
9
+ * These tests pin: the rewrite itself, the two guards that must NOT rewrite
10
+ * (a call with args; a name outside the set), and that it recurses into a
11
+ * realistic `.filter` predicate without touching the unrelated `.includes`
12
+ * receiver path. (#2076 follow-up — Package A)
13
+ */
14
+
15
+ describe('materializeGetterCalls', () => {
16
+ test('rewrites a zero-arg call whose callee is in `names` to a bare identifier', () => {
17
+ const rewritten = materializeGetterCalls(parseExpression('tag()'), new Set(['tag']))
18
+ expect(rewritten).toEqual({ kind: 'identifier', name: 'tag' })
19
+ })
20
+
21
+ test('leaves a call WITH args untouched, even when the callee is in `names`', () => {
22
+ const rewritten = materializeGetterCalls(parseExpression('tag(1)'), new Set(['tag']))
23
+ expect(stringifyParsedExpr(rewritten)).toBe('tag(1)')
24
+ })
25
+
26
+ test('leaves a call untouched when the callee name is not in `names`', () => {
27
+ const rewritten = materializeGetterCalls(parseExpression('tag()'), new Set(['other']))
28
+ expect(stringifyParsedExpr(rewritten)).toBe('tag()')
29
+ })
30
+
31
+ test('recurses into a filter predicate: both `tag()` occurrences rewrite, `p.tags.includes(...)` receiver is untouched', () => {
32
+ const body = parseExpression('!tag() || p.tags.includes(tag())')
33
+ const rewritten = materializeGetterCalls(body, new Set(['tag']))
34
+ expect(rewritten).toEqual({
35
+ kind: 'logical',
36
+ op: '||',
37
+ left: { kind: 'unary', op: '!', argument: { kind: 'identifier', name: 'tag' } },
38
+ right: {
39
+ kind: 'array-method',
40
+ method: 'includes',
41
+ object: {
42
+ kind: 'member',
43
+ object: { kind: 'identifier', name: 'p' },
44
+ property: 'tags',
45
+ computed: false,
46
+ },
47
+ args: [{ kind: 'identifier', name: 'tag' }],
48
+ },
49
+ })
50
+ })
51
+
52
+ test('does not mutate the input tree', () => {
53
+ const original = parseExpression('tag()')
54
+ const snapshot = JSON.stringify(original)
55
+ materializeGetterCalls(original, new Set(['tag']))
56
+ expect(JSON.stringify(original)).toBe(snapshot)
57
+ })
58
+ })
@@ -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
+ })
@@ -121,8 +121,10 @@ describe('serializeParsedExpr', () => {
121
121
  test('purity gate: a folded method call in the body → null', () => {
122
122
  // `.toUpperCase()` folds to `array-method`, outside the evaluator surface.
123
123
  expect(serializeParsedExpr(parseExpression('item.name.toUpperCase()'))).toBeNull()
124
- // A higher-order call (`.filter`) likewise.
125
- expect(serializeParsedExpr(parseExpression('item.tags.filter(t => t)'))).toBeNull()
124
+ // A higher-order call OTHER than `.map`/`.filter` (#2094 widens only
125
+ // those two) still folds — `.some` returns a boolean-from-search, not a
126
+ // per-element transform, so it has no evaluator lowering.
127
+ expect(serializeParsedExpr(parseExpression('item.tags.some(t => t)'))).toBeNull()
126
128
  // An unsupported shape.
127
129
  expect(serializeParsedExpr(parseExpression('a instanceof B'))).toBeNull()
128
130
  })
@@ -149,6 +151,85 @@ describe('serializeParsedExpr', () => {
149
151
  expect(serializeParsedExpr(parseExpression('Boolean(item.s)'))).not.toBeNull()
150
152
  })
151
153
 
154
+ test('`.includes(x)` array-method serializes (the one array-method the evaluator executes)', () => {
155
+ expect(evalJSON("item.tags.includes('go')")).toEqual({
156
+ kind: 'array-method',
157
+ method: 'includes',
158
+ object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
159
+ args: [{ kind: 'literal', value: 'go' }],
160
+ })
161
+ })
162
+
163
+ test('every other array-method still folds outside the evaluator surface', () => {
164
+ // `.join` is now serializable (#2094) — see the `.join` describe block
165
+ // below. `.slice` / `.flat` are not.
166
+ expect(serializeParsedExpr(parseExpression('item.tags.slice(0, 1)'))).toBeNull()
167
+ expect(serializeParsedExpr(parseExpression('item.tags.flat()'))).toBeNull()
168
+ })
169
+
170
+ // ----- #2094: nested `.map` / `.filter` / `.join` inside a callback body --
171
+ test('`.join(sep?)` array-method serializes', () => {
172
+ expect(evalJSON('item.tags.join(",")')).toEqual({
173
+ kind: 'array-method',
174
+ method: 'join',
175
+ object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
176
+ args: [{ kind: 'literal', value: ',' }],
177
+ })
178
+ // No-arg form keeps an empty `args` (the evaluator defaults the separator).
179
+ expect(evalJSON('item.tags.join()')).toEqual({
180
+ kind: 'array-method',
181
+ method: 'join',
182
+ object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
183
+ args: [],
184
+ })
185
+ })
186
+
187
+ test('nested `.map(cb)` serializes as an ordinary call/member/arrow — NOT a bespoke wrapper', () => {
188
+ // Deliberately the SAME shape a top-level `.filter(t => t)` parses to —
189
+ // this is what lets the `eval-vectors.json` corpus (which carries the
190
+ // genuine `ParsedExpr`, unfiltered by `toEvalNode`) exercise the exact
191
+ // encoding the compiled-template embedded body uses.
192
+ expect(evalJSON("item.tags.map(t => '#' + t)")).toEqual({
193
+ kind: 'call',
194
+ callee: {
195
+ kind: 'member',
196
+ object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
197
+ property: 'map',
198
+ computed: false,
199
+ },
200
+ args: [
201
+ {
202
+ kind: 'arrow',
203
+ params: ['t'],
204
+ body: {
205
+ kind: 'binary',
206
+ op: '+',
207
+ left: { kind: 'literal', value: '#' },
208
+ right: { kind: 'identifier', name: 't' },
209
+ },
210
+ },
211
+ ],
212
+ })
213
+ })
214
+
215
+ test('nested `.filter(cb)` composed with `.length` and a comparison (the doc/#2038 shape) serializes', () => {
216
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => t.active).length > 0'))).not.toBeNull()
217
+ })
218
+
219
+ test('a 2-param nested arrow `(item, index)` serializes', () => {
220
+ expect(serializeParsedExpr(parseExpression('item.tags.map((t, i) => t.n + i)'))).not.toBeNull()
221
+ })
222
+
223
+ test('nested `.some`/`.find`/`.every`/`.sort`/`.reduce`/`.flat`/`.flatMap` still fold to null', () => {
224
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.some(p => p === t))'))).toBeNull()
225
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.find(p => p === t))'))).toBeNull()
226
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.every(p => p === t))'))).toBeNull()
227
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.sort((a, b) => a - b).length > 0)'))).toBeNull()
228
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.reduce((a, b) => a + b, 0) > 0)'))).toBeNull()
229
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.flat().length > 0)'))).toBeNull()
230
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.flatMap(p => p).length > 0)'))).toBeNull()
231
+ })
232
+
152
233
  test('a computed member value carries `computed: true` (plain access omits it)', () => {
153
234
  // `row['price']` folds to a computed `member`; the flag is preserved so a
154
235
  // computed member stays distinguishable. (`row.price` carries no `computed`.)
@@ -201,4 +282,9 @@ describe('freeVarsInBody', () => {
201
282
  const body = parseExpression('Math.max(a, factor) + Number(label) + String(x) + Boolean(flag)')
202
283
  expect(freeVarsInBody(body, new Set(['a']))).toEqual(['factor', 'flag', 'label', 'x'])
203
284
  })
285
+
286
+ test('`.includes(x)` array-method: both the receiver and the needle are free vars', () => {
287
+ const body = parseExpression('!tag || p.tags.includes(tag)')
288
+ expect(freeVarsInBody(body, new Set())).toEqual(['p', 'tag'])
289
+ })
204
290
  })
@@ -82,6 +82,26 @@ describe('extractSsrDefaults', () => {
82
82
  expect(defaults?.initial).toEqual({ propName: 'initial', value: null })
83
83
  })
84
84
 
85
+ test('seeds every declared bare-props prop, including direct template reads (#2126)', () => {
86
+ // Template-stash adapters flatten `props.label` to the bare scalar
87
+ // `$label` even when no signal/memo references it (`<%= $label %>` in
88
+ // the body), so a prop the caller forgets to pass must still exist in
89
+ // the stash. Every prop declared on the props type gets an entry —
90
+ // not just the ones a signal / memo initializer reads.
91
+ const metadata = metadataFor(`
92
+ 'use client'
93
+ import { createSignal } from '@barefootjs/client'
94
+ function Greeting(props: { label?: string }) {
95
+ const [n, setN] = createSignal(0)
96
+ return <div><p>{props.label}</p><button onClick={() => setN(n() + 1)}>{n()}</button></div>
97
+ }
98
+ `)
99
+
100
+ const defaults = extractSsrDefaults(metadata)
101
+ expect(defaults?.label).toEqual({ propName: 'label', value: null })
102
+ expect(defaults?.n).toEqual({ value: 0 })
103
+ })
104
+
85
105
  test('memo derived from a signal evaluates through the chain', () => {
86
106
  const metadata = metadataFor(`
87
107
  'use client'