@barefootjs/jsx 0.5.0 → 0.5.2

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 (57) hide show
  1. package/dist/adapters/test-adapter.d.ts.map +1 -1
  2. package/dist/analyzer-context.d.ts +8 -1
  3. package/dist/analyzer-context.d.ts.map +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +250 -59
  9. package/dist/ir-to-client-js/collect-elements.d.ts +11 -1
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +14 -0
  13. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/html-template.d.ts +0 -14
  17. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/imports.d.ts +2 -2
  19. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/types.d.ts +7 -0
  22. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/utils.d.ts +2 -2
  24. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  25. package/dist/types.d.ts +17 -0
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +310 -188
  29. package/src/__tests__/adapter-output.test.ts +49 -0
  30. package/src/__tests__/child-components-in-map.test.ts +43 -0
  31. package/src/__tests__/client-js-generation.test.ts +5 -2
  32. package/src/__tests__/inline-jsx-callback.test.ts +95 -0
  33. package/src/__tests__/ir-jsx-props.test.ts +5 -2
  34. package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
  35. package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
  36. package/src/__tests__/missing-key-in-list.test.ts +49 -0
  37. package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
  38. package/src/adapters/test-adapter.ts +16 -1
  39. package/src/analyzer-context.ts +59 -13
  40. package/src/analyzer.ts +8 -0
  41. package/src/expression-parser.ts +16 -1
  42. package/src/index.ts +2 -0
  43. package/src/ir-to-client-js/collect-elements.ts +37 -15
  44. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
  45. package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
  46. package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
  47. package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
  48. package/src/ir-to-client-js/emit-reactive.ts +12 -3
  49. package/src/ir-to-client-js/html-template.ts +29 -3
  50. package/src/ir-to-client-js/imports.ts +2 -2
  51. package/src/ir-to-client-js/reactivity.ts +17 -1
  52. package/src/ir-to-client-js/types.ts +7 -0
  53. package/src/ir-to-client-js/utils.ts +2 -1
  54. package/src/jsx-to-ir.ts +161 -12
  55. package/src/preprocess-inline-jsx-callbacks.ts +28 -10
  56. package/src/scanner/__tests__/js-scanner.fuzz.test.ts +202 -0
  57. package/src/types.ts +18 -0
@@ -653,4 +653,53 @@ describe('Adapter output', () => {
653
653
  expect(template.content).not.toMatch(/\b0\s+as\s+number\b/)
654
654
  })
655
655
  })
656
+
657
+ // The no-arg props default (`= {}`) makes a JSX-returning arrow hoisted from
658
+ // an object-literal value renderable at SSR (#1663). `hasRequiredProps`
659
+ // treats a prop with a destructuring default as non-required, but the
660
+ // declared props type may still mark that field required — so a bare `= {}`
661
+ // would fail `tsc` ("Property 'x' is missing in type '{}'..."). The default
662
+ // must be asserted to the param's annotated type (`{} as T`).
663
+ describe('no-arg props default is type-safe for typed required props (#1663 follow-up)', () => {
664
+ const cases = [
665
+ ['TestAdapter', () => new TestAdapter()],
666
+ ['HonoAdapter', () => new HonoAdapter()],
667
+ ] as const
668
+
669
+ for (const [label, make] of cases) {
670
+ test(`typed required prop with a default emits \`= {} as T\` (${label})`, () => {
671
+ // `label` is required in the type but carries a destructuring default,
672
+ // the exact shape that made a bare `= {}` fail tsc.
673
+ const source = `
674
+ export function Badge({ label = 'x' }: { label: string }) {
675
+ return <span>{label}</span>
676
+ }
677
+ `
678
+ const result = compileJSX(source, 'Badge.tsx', { adapter: make() })
679
+ expect(result.errors).toHaveLength(0)
680
+
681
+ const template = result.files.find(f => f.type === 'markedTemplate')!
682
+ const sig = template.content.split('\n').find(l => l.includes('function Badge'))!
683
+ // Asserted to the generated props type, never a bare `= {}`.
684
+ expect(sig).toContain('= {} as BadgePropsWithHydration')
685
+ expect(sig).not.toMatch(/=\s*\{\}\s*\)/)
686
+ })
687
+ }
688
+
689
+ test('untyped props still default to a bare `= {} as <hydration type>` (TestAdapter)', () => {
690
+ const source = `
691
+ export function Plain() {
692
+ return <span>hi</span>
693
+ }
694
+ `
695
+ const result = compileJSX(source, 'Plain.tsx', { adapter: new TestAdapter() })
696
+ expect(result.errors).toHaveLength(0)
697
+
698
+ const template = result.files.find(f => f.type === 'markedTemplate')!
699
+ const sig = template.content.split('\n').find(l => l.includes('function Plain'))!
700
+ // No declared props type → default asserted to the hydration-only type.
701
+ expect(sig).toContain('= {} as {')
702
+ expect(sig).toContain('__instanceId')
703
+ })
704
+ })
656
705
  })
@@ -730,6 +730,49 @@ describe('child components inside .map() (#344)', () => {
730
730
  expect(content).not.toContain('children[__idx + ')
731
731
  })
732
732
 
733
+ test('static array inside a component container with a preceding static sibling uses siblingOffset (#1688)', () => {
734
+ // The loop is a direct child of the <Portaled> component (not an
735
+ // element), with a static <span> sibling before it. Before #1688
736
+ // computeLoopSiblingOffsets only counted siblings under element
737
+ // parents, so the offset was silently zero and the first item's
738
+ // nested child component (Counter) was resolved against the wrong
739
+ // children[idx] — dropping it during hydration.
740
+ const source = `
741
+ 'use client'
742
+
743
+ function Portaled(props: { children?: any }) {
744
+ return <div>{props.children}</div>
745
+ }
746
+ function Wrapper(props: { children?: any }) {
747
+ return <div class="wrapper">{props.children}</div>
748
+ }
749
+ function Counter(props: { id: string }) {
750
+ const [n, setN] = createSignal(0)
751
+ return <button data-testid={props.id} onClick={() => setN(v => v + 1)}>{n()}</button>
752
+ }
753
+ export function Repro() {
754
+ return (
755
+ <Portaled>
756
+ <span>static sibling</span>
757
+ {['a', 'b'].map(id => (
758
+ <Wrapper key={id}><Counter id={id} /></Wrapper>
759
+ ))}
760
+ </Portaled>
761
+ )
762
+ }
763
+ `
764
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
765
+ expect(result.errors).toHaveLength(0)
766
+
767
+ const clientJs = result.files.find(f => f.type === 'clientJs')
768
+ expect(clientJs).toBeDefined()
769
+ const content = clientJs!.content
770
+
771
+ // The nested Counter lookup must skip the preceding static <span>.
772
+ expect(content).toContain('children[__idx + 1]')
773
+ expect(content).not.toContain('children[__idx]')
774
+ })
775
+
733
776
  test('nested .map() with multiple inner components emits unique __compEl bindings (#1664)', () => {
734
777
  const source = `
735
778
  'use client'
@@ -2300,8 +2300,11 @@ describe('Client JS generation', () => {
2300
2300
  expect(clientJs).toBeDefined()
2301
2301
  const content = clientJs!.content
2302
2302
 
2303
- // Text node assignment must guard against nullish values
2304
- expect(content).toContain("String(__val ?? '')")
2303
+ // Text node updates route through `__bfText`, which renders nullish
2304
+ // values as '' (not the string "undefined") and also splices live
2305
+ // Nodes by identity (#1663). The nullish guard moved from the inline
2306
+ // `String(__val ?? '')` assignment into that helper.
2307
+ expect(content).toContain('__bfText(')
2305
2308
  expect(content).not.toMatch(/\.nodeValue = String\(__val\)(?! )/)
2306
2309
  })
2307
2310
 
@@ -367,3 +367,98 @@ export function Demo() {
367
367
  expect(sharedJs).toMatch(/hydrate\(\s*['"]BFInlineJsxCallback1(?:__|['"])/)
368
368
  })
369
369
  })
370
+
371
+ /**
372
+ * Inline JSX as an object-literal arrow value (#1663).
373
+ *
374
+ * A `Record<K, () => JSX>` lookup map (`{ piconic: () => <BrandLogo/> }`)
375
+ * left the JSX untransformed: a module-level map had its const dropped from
376
+ * the emitted module (`ReferenceError: THEME_LOGOS is not defined` at SSR),
377
+ * and a function-local map leaked raw `<...>` into the client bundle
378
+ * (`SyntaxError: Unexpected token '<'`). Both arrow values must be hoisted
379
+ * into synthesized components, exactly like an arrow in attribute position.
380
+ */
381
+ describe('inline JSX as object-literal arrow value (#1663)', () => {
382
+ function markedTemplate(source: string, fileName = 'Header.tsx'): string {
383
+ const result = compileJSX(source, fileName, { adapter })
384
+ expect(result.errors).toEqual([])
385
+ const file = result.files.find(f => f.type === 'markedTemplate')
386
+ expect(file).toBeDefined()
387
+ return file!.content
388
+ }
389
+
390
+ test('module-level lookup map: const is preserved and JSX is lowered (repro A)', () => {
391
+ const source = `
392
+ 'use client'
393
+ import { BrandLogo } from './brand-logo'
394
+
395
+ const THEME_LOGOS: Record<string, () => unknown> = {
396
+ piconic: () => <BrandLogo name="piconic" />,
397
+ other: () => <BrandLogo name="other" />,
398
+ }
399
+ function themeLogo(id: string) { return THEME_LOGOS[id]() }
400
+
401
+ export function Header(props: { id: string }) {
402
+ return <div class="hdr">{themeLogo(props.id)}</div>
403
+ }
404
+ `
405
+ const js = clientJs(source)
406
+ const tmpl = markedTemplate(source)
407
+
408
+ // The arrow bodies must not survive as live JSX in either output.
409
+ expect(js).not.toMatch(/=>\s*<BrandLogo\b/)
410
+ expect(tmpl).not.toMatch(/=>\s*<BrandLogo\b/)
411
+
412
+ // The arrow values are replaced by synthesized component references, so
413
+ // the THEME_LOGOS const survives (it is no longer dropped → no SSR
414
+ // ReferenceError).
415
+ expect(js).toContain('THEME_LOGOS')
416
+ expect(js).toMatch(/piconic:\s*BFInlineJsxCallback1\b/)
417
+ expect(js).toMatch(/hydrate\(\s*['"]BFInlineJsxCallback1(?:__|['"])/)
418
+ expect(tmpl).toContain('THEME_LOGOS')
419
+ })
420
+
421
+ test('function-local lookup map: JSX is lowered, not leaked to client (repro B)', () => {
422
+ const source = `
423
+ 'use client'
424
+ import { BrandLogo } from './brand-logo'
425
+
426
+ export function Header(props: { id: string }) {
427
+ function themeLogo(id: string) {
428
+ const logos: Record<string, () => unknown> = {
429
+ piconic: () => <BrandLogo name="piconic" />,
430
+ }
431
+ return logos[id]()
432
+ }
433
+ return <div class="hdr">{themeLogo(props.id)}</div>
434
+ }
435
+ `
436
+ const js = clientJs(source)
437
+ // No raw JSX in the client bundle (the #1663 SyntaxError).
438
+ expect(js).not.toMatch(/=>\s*<BrandLogo\b/)
439
+ expect(js).not.toMatch(/return\s*<BrandLogo\b/)
440
+ expect(js).toMatch(/hydrate\(\s*['"]BFInlineJsxCallback1(?:__|['"])/)
441
+ })
442
+
443
+ test('dynamic JSX-returning call routes the slot through __bfText', () => {
444
+ // The child expression `{themeLogo(props.id)}` evaluates to a live
445
+ // component element on the client; the slot update must splice it in by
446
+ // identity via __bfText rather than stringify it into "[object …]".
447
+ const source = `
448
+ 'use client'
449
+ import { BrandLogo } from './brand-logo'
450
+
451
+ const THEME_LOGOS: Record<string, () => unknown> = {
452
+ piconic: () => <BrandLogo name="piconic" />,
453
+ }
454
+ function themeLogo(id: string) { return THEME_LOGOS[id]() }
455
+
456
+ export function Header(props: { id: string }) {
457
+ return <div class="hdr">{themeLogo(props.id)}</div>
458
+ }
459
+ `
460
+ const js = clientJs(source)
461
+ expect(js).toContain('__bfText(')
462
+ expect(js).toMatch(/__bfText[^']*from '@barefootjs\/client\/runtime'/)
463
+ })
464
+ })
@@ -359,8 +359,11 @@ describe('JSX props (#559)', () => {
359
359
 
360
360
  const clientJs = result.files.find(f => f.type === 'clientJs')
361
361
  expect(clientJs).toBeDefined()
362
- // The generated effect should check __isSlot before updating nodeValue
363
- expect(clientJs!.content).toContain('__isSlot')
362
+ // The text update routes through `__bfText`, which preserves the
363
+ // server-rendered DOM for `__isSlot` values (and splices live Nodes
364
+ // by identity) instead of the old inline `nodeValue = String(...)`
365
+ // assignment. The `__isSlot` guard now lives inside that helper (#1663).
366
+ expect(clientJs!.content).toContain('__bfText')
364
367
  })
365
368
  })
366
369
  })
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Codegen-routing tests for whole-item loop conditionals (#1665).
3
+ *
4
+ * Two concerns:
5
+ * 1. Detection boundary — which `.map()` body shapes route to the anchored
6
+ * `mapArrayAnchored` path (an item may render 0-or-1 element) vs. stay on
7
+ * the legacy element-tracking `mapArray` path (always exactly one element).
8
+ * 2. Keyless robustness — a whole-item conditional without a key is a BF023
9
+ * error, but the emitted client JS must still be syntactically valid (an
10
+ * empty key once produced `createComment(`bf-loop-i:${}`)`, a SyntaxError
11
+ * that breaks the whole bundle).
12
+ */
13
+ import { describe, test, expect } from 'bun:test'
14
+ import { compileJSX } from '../compiler'
15
+ import { TestAdapter } from '../adapters/test-adapter'
16
+ import { ErrorCodes } from '../errors'
17
+
18
+ const adapter = new TestAdapter()
19
+
20
+ function clientJsFor(body: string, withKey = true): { js: string; errorCodes: string[] } {
21
+ const key = withKey ? ' key={t.id}' : ''
22
+ const source = `
23
+ 'use client'
24
+ import { createSignal } from '@barefootjs/client'
25
+ export function C() {
26
+ const [items] = createSignal([{ id: 'a', on: true }])
27
+ const [sel] = createSignal('a')
28
+ return <ul>{items().map(t => ${body.replace('KEY', key)})}</ul>
29
+ }
30
+ `
31
+ const result = compileJSX(source, 'C.tsx', { adapter })
32
+ return {
33
+ js: result.files.find((f) => f.type === 'clientJs')?.content ?? '',
34
+ errorCodes: result.errors.map((e) => e.code),
35
+ }
36
+ }
37
+
38
+ const usesAnchored = (js: string) => js.includes('mapArrayAnchored(')
39
+ const usesLegacy = (js: string) => js.includes('mapArray(') && !js.includes('mapArrayAnchored(')
40
+
41
+ describe('#1665 — anchored-vs-legacy routing for .map() bodies', () => {
42
+ test('logical && JSX body routes to mapArrayAnchored', () => {
43
+ expect(usesAnchored(clientJsFor('sel() === t.id && <li KEY>{t.id}</li>').js)).toBe(true)
44
+ })
45
+
46
+ test('ternary with a null branch routes to mapArrayAnchored', () => {
47
+ expect(usesAnchored(clientJsFor('sel() === t.id ? <li KEY>{t.id}</li> : null').js)).toBe(true)
48
+ })
49
+
50
+ test('logical || JSX body routes to mapArrayAnchored', () => {
51
+ expect(usesAnchored(clientJsFor('t.on || <li KEY>{t.id}</li>').js)).toBe(true)
52
+ })
53
+
54
+ test('ternary with a scalar branch routes to mapArrayAnchored', () => {
55
+ // The element-less side renders text, never a tracked element, so the item
56
+ // is still 0-or-1 element across states — anchored, not legacy.
57
+ expect(usesAnchored(clientJsFor("sel() === t.id ? <li KEY>{t.id}</li> : 'none'").js)).toBe(true)
58
+ })
59
+
60
+ test('ternary with two element branches stays on the legacy mapArray path', () => {
61
+ // Always exactly one element, so element tracking is sufficient — must NOT
62
+ // pay the anchored-emission cost.
63
+ const { js } = clientJsFor('sel() === t.id ? <li KEY>A</li> : <span KEY>B</span>')
64
+ expect(usesLegacy(js)).toBe(true)
65
+ expect(usesAnchored(js)).toBe(false)
66
+ })
67
+ })
68
+
69
+ describe('#1665 — keyless whole-item conditional emits valid JS (BF023)', () => {
70
+ test('logical && without a key raises BF023 but still emits parseable JS', () => {
71
+ const { js, errorCodes } = clientJsFor('sel() === t.id && <li>{t.id}</li>', /* withKey */ false)
72
+ expect(errorCodes).toContain(ErrorCodes.MISSING_KEY_IN_LIST)
73
+ // No empty template-literal interpolation, and the anchor falls back to the
74
+ // iteration index so the comment value is well-formed.
75
+ expect(js).not.toContain('${}')
76
+ expect(js).toContain('bf-loop-i:${__idx}')
77
+ // The whole client module must parse.
78
+ const body = js.replace(/^import[^\n]*\n/gm, '').replace(/^export /gm, '')
79
+ expect(() => new Function(body)).not.toThrow()
80
+ })
81
+ })
@@ -0,0 +1,159 @@
1
+ /**
2
+ * BarefootJS Compiler — `.map()` callback whose body is a logical
3
+ * expression (`&&` / `||` / `??`) that renders JSX.
4
+ *
5
+ * Regression for #1665: calling a module-level JSX helper from inside a
6
+ * reactive `.map()` child (`THEMES.map(t => sel === t.id && themeLogo(t.id))`)
7
+ * threw `ReferenceError: themeLogo is not defined` at hydration.
8
+ *
9
+ * Root cause: `transformMapCall` only recognised JSX-element, ternary,
10
+ * parenthesized, flatMap-array, and block callback bodies. A logical-`&&`
11
+ * body fell through, leaving `children` empty, so the whole `.map(...)`
12
+ * was emitted verbatim as a reactive-text expression — the inline JSX was
13
+ * never compiled and the JSX helper was never inlined nor declared,
14
+ * producing a ReferenceError.
15
+ *
16
+ * The fix routes a JSX-rendering logical callback body through the shared
17
+ * JSX expression transformer, which lowers it into an IRConditional and
18
+ * inlines any JSX helper (the same path the ternary form already used).
19
+ * The routing is scoped to logical operators so the bare-call body
20
+ * (`map(t => renderItem(t))`, owned by #546) and scalar logical bodies
21
+ * (`t.active && t.label`) keep their existing reactive-text behaviour.
22
+ */
23
+
24
+ import { describe, test, expect } from 'bun:test'
25
+ import { compileJSX } from '../compiler'
26
+ import { TestAdapter } from '../adapters/test-adapter'
27
+
28
+ const adapter = new TestAdapter()
29
+
30
+ describe('.map() with logical / JSX-helper-call body (#1665)', () => {
31
+ test('module-level JSX helper in a `&&` map body is inlined, not left as a bare call', () => {
32
+ const source = `
33
+ 'use client'
34
+ const THEMES = [{ id: 'piconic' }, { id: 'hono' }]
35
+ function themeLogo(id: string) {
36
+ if (id === 'hono') return <span>hono</span>
37
+ return <span>piconic</span>
38
+ }
39
+ export function Header(props: { sel: string }) {
40
+ return <div>{THEMES.map(t => props.sel === t.id && themeLogo(t.id))}</div>
41
+ }
42
+ `
43
+ const result = compileJSX(source, 'Header.tsx', { adapter })
44
+ expect(result.errors).toHaveLength(0)
45
+
46
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
47
+
48
+ // The helper must NOT survive as a runtime call in the client bundle —
49
+ // it is inlined. A surviving `themeLogo(` call with no declaration is
50
+ // the exact ReferenceError the issue reported.
51
+ expect(clientJs).not.toMatch(/themeLogo\s*\(/)
52
+
53
+ // The map is compiled into a loop, and the helper's JSX is inlined.
54
+ expect(clientJs).toContain('<!--bf-loop')
55
+ expect(clientJs).toContain('hono')
56
+ expect(clientJs).toContain('piconic')
57
+ })
58
+
59
+ test('parenthesized `&&` with a single-return JSX helper is inlined', () => {
60
+ const source = `
61
+ 'use client'
62
+ const THEMES = [{ id: 'piconic' }, { id: 'hono' }]
63
+ function themeLogo(id: string) { return <span>{id}</span> }
64
+ export function Header(props: { sel: string }) {
65
+ return <div>{THEMES.map(t => (props.sel === t.id && themeLogo(t.id)))}</div>
66
+ }
67
+ `
68
+ const result = compileJSX(source, 'Header.tsx', { adapter })
69
+ expect(result.errors).toHaveLength(0)
70
+
71
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
72
+
73
+ expect(clientJs).not.toMatch(/themeLogo\s*\(/)
74
+ expect(clientJs).toContain('<!--bf-loop')
75
+ expect(clientJs).toContain('<span')
76
+ })
77
+
78
+ test('`??` JSX-helper fallback in a map body is inlined', () => {
79
+ const source = `
80
+ 'use client'
81
+ const THEMES = [{ id: 'piconic' }, { id: 'hono' }]
82
+ function themeLogo(id: string) { return <span>{id}</span> }
83
+ export function Header(props: { current?: string }) {
84
+ return <div>{THEMES.map(t => props.current ?? themeLogo(t.id))}</div>
85
+ }
86
+ `
87
+ const result = compileJSX(source, 'Header.tsx', { adapter })
88
+ expect(result.errors).toHaveLength(0)
89
+
90
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
91
+
92
+ // `transformJsxExpression` now routes a `||`/`??` whose right operand
93
+ // renders JSX via a tracked helper through the nullish transformer, so
94
+ // the helper is inlined rather than left as a verbatim call.
95
+ expect(clientJs).not.toMatch(/themeLogo\s*\(/)
96
+ expect(clientJs).toContain('<span')
97
+ })
98
+
99
+ test('bare JSX-helper call body keeps its #546 reactive-text behaviour', () => {
100
+ // Boundary guard: the narrowed fix must NOT re-route a bare call body
101
+ // into the conditional path — that remains #546 territory. A local
102
+ // arrow helper is declared in init scope, so the call survives.
103
+ const source = `
104
+ 'use client'
105
+ import { createSignal } from '@barefootjs/client'
106
+ export function List() {
107
+ const [items, _set] = createSignal([{ id: '1' }])
108
+ const renderItem = (item: any) => <li>{item.id}</li>
109
+ return <ul>{items().map(item => renderItem(item))}</ul>
110
+ }
111
+ `
112
+ const result = compileJSX(source, 'List.tsx', { adapter })
113
+ expect(result.errors).toHaveLength(0)
114
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
115
+ // The bare call is preserved (not inlined into a conditional loop).
116
+ expect(clientJs).toMatch(/renderItem\s*\(/)
117
+ })
118
+
119
+ test('inline JSX in a `&&` map body compiles instead of emitting raw JSX verbatim', () => {
120
+ // `key` is required on the `&&` JSX operand (#1665 / BF023): a whole-item
121
+ // conditional renders 0-or-1 element per iteration and needs a stable key
122
+ // for correct reconciliation, exactly like a ternary branch.
123
+ const source = `
124
+ 'use client'
125
+ const THEMES = [{ id: 'piconic' }, { id: 'hono' }]
126
+ export function Header(props: { sel: string }) {
127
+ return <div>{THEMES.map(t => props.sel === t.id && <span key={t.id}>{t.id}</span>)}</div>
128
+ }
129
+ `
130
+ const result = compileJSX(source, 'Header.tsx', { adapter })
131
+ expect(result.errors).toHaveLength(0)
132
+
133
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
134
+ // Compiled into a loop with a conditional branch — not the raw,
135
+ // un-compiled `<span>{t.id}</span>` JSX literal the buggy path left
136
+ // inside the client-bundle template string.
137
+ expect(clientJs).toContain('<!--bf-loop')
138
+ expect(clientJs).not.toContain('<span>{t.id}</span>')
139
+ })
140
+
141
+ test('scalar `&&` map body keeps its plain reactive-text behaviour', () => {
142
+ // Guard: a non-JSX logical body must NOT be re-routed into the
143
+ // conditional-control-flow path. It stays a verbatim map expression.
144
+ const source = `
145
+ 'use client'
146
+ import { createSignal } from '@barefootjs/client'
147
+ export function Labels() {
148
+ const [items, _set] = createSignal([{ active: true, name: 'A' }])
149
+ return <div>{items().map(t => t.active && t.name)}</div>
150
+ }
151
+ `
152
+ const result = compileJSX(source, 'Labels.tsx', { adapter })
153
+ expect(result.errors).toHaveLength(0)
154
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
155
+ // Still rendered as the raw map expression (no loop control flow).
156
+ expect(clientJs).not.toContain('<!--bf-loop')
157
+ expect(clientJs).toContain('.map(')
158
+ })
159
+ })
@@ -224,6 +224,55 @@ describe('BF023 — ternary .map() callback', () => {
224
224
  })
225
225
  })
226
226
 
227
+ // ---------------------------------------------------------------------------
228
+ // BF023 — logical (&&, ||, ??) .map() callback (#1665)
229
+ //
230
+ // A whole-item conditional (`cond && <li>`) renders 0-or-1 element per
231
+ // iteration. The same key reconciliation applies — without a key, toggling
232
+ // items collapses or misorders them — so the JSX element inside a logical
233
+ // expression must carry a key, just like a ternary branch.
234
+ // ---------------------------------------------------------------------------
235
+
236
+ describe('BF023 — logical && / || .map() callback (#1665)', () => {
237
+ test('&& body without key raises BF023', () => {
238
+ const source = `
239
+ interface Item { active: boolean; id: string }
240
+ export function List({ items }: { items: Item[] }) {
241
+ return <ul>{items.map(item => item.active && <li>{item.id}</li>)}</ul>
242
+ }
243
+ `
244
+ const errs = errorsFor(ErrorCodes.MISSING_KEY_IN_LIST, source)
245
+ expect(errs).toHaveLength(1)
246
+ expect(errs[0].severity).toBe('error')
247
+ })
248
+
249
+ test('&& body with key — no error', () => {
250
+ const source = `
251
+ interface Item { active: boolean; id: string }
252
+ export function List({ items }: { items: Item[] }) {
253
+ return <ul>{items.map(item => item.active && <li key={item.id}>{item.id}</li>)}</ul>
254
+ }
255
+ `
256
+ const result = compile(source)
257
+ const errs = result.errors.filter(
258
+ (e) => e.code === ErrorCodes.MISSING_KEY_IN_LIST || e.code === ErrorCodes.MISSING_KEY_IN_NESTED_LIST,
259
+ )
260
+ expect(errs).toHaveLength(0)
261
+ })
262
+
263
+ test('|| body without key raises BF023', () => {
264
+ const source = `
265
+ interface Item { hidden: boolean; id: string }
266
+ export function List({ items }: { items: Item[] }) {
267
+ return <ul>{items.map(item => item.hidden || <li>{item.id}</li>)}</ul>
268
+ }
269
+ `
270
+ const errs = errorsFor(ErrorCodes.MISSING_KEY_IN_LIST, source)
271
+ expect(errs).toHaveLength(1)
272
+ expect(errs[0].severity).toBe('error')
273
+ })
274
+ })
275
+
227
276
  // ---------------------------------------------------------------------------
228
277
  // BF024 — nested map
229
278
  // ---------------------------------------------------------------------------
@@ -236,6 +236,47 @@ describe('reactive attributes inside .map() callbacks', () => {
236
236
  expect(forEachMatches.length).toBe(1)
237
237
  })
238
238
 
239
+ test('per-item attr reading an outer signal via helper+index gets a createEffect (#1673)', () => {
240
+ // A per-item attribute whose value reads the source array signal through
241
+ // a helper indexed by position (`widthAt(i)` → `items()[i].w`) used to
242
+ // emit NO reactive effect — the value was baked into the item template
243
+ // once and froze at its SSR value. The identical binding on a top-level
244
+ // element works, because the top-level path wraps opaque function calls
245
+ // via the Solid-style AST-flag fallback (#940). This test pins that the
246
+ // loop-child path now applies the same fallback.
247
+ const source = `
248
+ 'use client'
249
+ import { createSignal } from '@barefootjs/client'
250
+
251
+ export function ReproLoopAttr() {
252
+ const [items, setItems] = createSignal([{ id: 'a', w: 20 }, { id: 'b', w: 50 }])
253
+ const widthAt = (i) => items()[i].w
254
+ return (
255
+ <ul>
256
+ {items().map((it, i) => (
257
+ <li key={it.id} data-b style={\`width:\${widthAt(i)}%\`}>{it.id}</li>
258
+ ))}
259
+ </ul>
260
+ )
261
+ }
262
+ `
263
+ const result = compileJSX(source, 'ReproLoopAttr.tsx', { adapter })
264
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
265
+
266
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
267
+ // The per-item style binding must be wired through a createEffect that
268
+ // re-reads the helper and writes the style attribute, so it tracks the
269
+ // `items()` signal and updates after hydration. Pin the helper call
270
+ // *inside* the createEffect alongside the style write — `widthAt(` also
271
+ // appears in the static template clone, so asserting it independently
272
+ // could pass even if the effect was missing (the exact regression here).
273
+ // The index resolves to the loop's renderItem index param, in scope
274
+ // inside the factory.
275
+ expect(clientJs).toMatch(
276
+ /createEffect\(\(\)\s*=>\s*\{[\s\S]*?widthAt\([\s\S]*?setAttribute\('style'/,
277
+ )
278
+ })
279
+
239
280
  test('keyed loop: `key` prop is not emitted as a reactive DOM attribute', () => {
240
281
  // Regression guard for the "key duplicate emission" bug:
241
282
  // `<li key={item.id}>` used to be both rendered as `data-key=` in the
@@ -138,11 +138,26 @@ export class TestAdapter extends JsxAdapter {
138
138
  }
139
139
  const fullPropsDestructure = `{ ${parts.join(', ')} }`
140
140
 
141
+ // Default the props param to `{}` when the component has no required
142
+ // props, so a bare no-arg call (`Foo()`) doesn't crash on destructuring
143
+ // `undefined`. This is what makes a JSX-returning arrow hoisted from an
144
+ // object-literal value (e.g. `THEME_LOGOS[id]()`) renderable at SSR
145
+ // (#1663). `hasRequiredProps` ignores props that carry a destructuring
146
+ // default, but the declared props type may still mark that field
147
+ // required — so a bare `= {}` would fail `tsc` ("Property 'x' is missing
148
+ // in type '{}'..."). Assert the default to the param's own annotated type
149
+ // (`{} as T`); the destructuring defaults supply the values at runtime.
150
+ const hasRequiredProps = ir.metadata.propsParams.some(
151
+ (p: ParamInfo) => !p.optional && p.defaultValue === undefined && !p.isRest,
152
+ )
153
+ const propsTypeExpr = typeAnnotation.replace(/^:\s*/, '')
154
+ const noArgDefault = hasRequiredProps ? '' : ` = {} as ${propsTypeExpr}`
155
+
141
156
  const lines: string[] = []
142
157
  // Module-export keyword belongs to the adapter: it knows the target language
143
158
  // and whether the source declared the component as exported.
144
159
  const exportPrefix = ir.metadata.isExported === false ? '' : 'export '
145
- lines.push(`${exportPrefix}function ${name}(${fullPropsDestructure}${typeAnnotation}) {`)
160
+ lines.push(`${exportPrefix}function ${name}(${fullPropsDestructure}${typeAnnotation}${noArgDefault}) {`)
146
161
 
147
162
  // Generate scope ID
148
163
  if (hasClientInteractivity) {