@barefootjs/jsx 0.4.0 → 0.5.1

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 (67) hide show
  1. package/dist/adapters/interface.d.ts +20 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/test-adapter.d.ts.map +1 -1
  4. package/dist/expression-parser.d.ts +36 -19
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/import-map.d.ts +56 -0
  7. package/dist/import-map.d.ts.map +1 -0
  8. package/dist/import-map.js +18 -0
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +333 -199
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +14 -0
  15. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +0 -14
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/types.d.ts +7 -0
  24. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/utils.d.ts +2 -2
  26. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  27. package/dist/scanner/js-scanner.d.ts +10 -0
  28. package/dist/scanner/js-scanner.d.ts.map +1 -1
  29. package/dist/scanner/js-scanner.js +5 -0
  30. package/dist/types.d.ts +11 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +7 -3
  33. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +438 -190
  34. package/src/__tests__/adapter-output.test.ts +49 -0
  35. package/src/__tests__/child-components-in-map.test.ts +76 -0
  36. package/src/__tests__/client-js-generation.test.ts +5 -2
  37. package/src/__tests__/import-map.test.ts +75 -0
  38. package/src/__tests__/inline-jsx-callback.test.ts +95 -0
  39. package/src/__tests__/ir-jsx-props.test.ts +5 -2
  40. package/src/__tests__/ir-sort-comparator.test.ts +212 -9
  41. package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
  42. package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
  43. package/src/__tests__/missing-key-in-list.test.ts +49 -0
  44. package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
  45. package/src/__tests__/token-contains-ident.test.ts +27 -0
  46. package/src/__tests__/unsupported-expression.test.ts +42 -13
  47. package/src/adapters/interface.ts +20 -0
  48. package/src/adapters/test-adapter.ts +16 -1
  49. package/src/expression-parser.ts +265 -50
  50. package/src/import-map.ts +72 -0
  51. package/src/index.ts +5 -1
  52. package/src/ir-to-client-js/collect-elements.ts +3 -0
  53. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
  54. package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
  55. package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
  56. package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
  57. package/src/ir-to-client-js/emit-reactive.ts +12 -3
  58. package/src/ir-to-client-js/html-template.ts +29 -3
  59. package/src/ir-to-client-js/imports.ts +2 -2
  60. package/src/ir-to-client-js/reactivity.ts +17 -1
  61. package/src/ir-to-client-js/stringify/static-array-child-init.ts +8 -4
  62. package/src/ir-to-client-js/types.ts +7 -0
  63. package/src/ir-to-client-js/utils.ts +31 -116
  64. package/src/jsx-to-ir.ts +161 -12
  65. package/src/preprocess-inline-jsx-callbacks.ts +28 -10
  66. package/src/scanner/js-scanner.ts +16 -1
  67. package/src/types.ts +12 -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
  })
@@ -729,4 +729,80 @@ describe('child components inside .map() (#344)', () => {
729
729
  expect(content).toContain('children[__idx]')
730
730
  expect(content).not.toContain('children[__idx + ')
731
731
  })
732
+
733
+ test('nested .map() with multiple inner components emits unique __compEl bindings (#1664)', () => {
734
+ const source = `
735
+ 'use client'
736
+
737
+ export function Picker() {
738
+ const GROUPS = [
739
+ { id: 'a', items: [{ id: 'x', label: 'X' }] },
740
+ ]
741
+ return (
742
+ <div>
743
+ {GROUPS.map(group => (
744
+ <div key={group.id}>
745
+ {group.items.map(it => (
746
+ <div key={it.id}>
747
+ <SelectItem value={it.id}>{it.label}</SelectItem>
748
+ <SelectIcon name={it.id} />
749
+ </div>
750
+ ))}
751
+ </div>
752
+ ))}
753
+ </div>
754
+ )
755
+ }
756
+ `
757
+ const result = compileJSX(source, 'Picker.tsx', { adapter })
758
+ expect(result.errors).toHaveLength(0)
759
+
760
+ const clientJs = result.files.find(f => f.type === 'clientJs')
761
+ expect(clientJs).toBeDefined()
762
+ const content = clientJs!.content
763
+
764
+ // Both inner-loop components must be initialised.
765
+ expect(content).toContain("initChild('SelectItem'")
766
+ expect(content).toContain("initChild('SelectIcon'")
767
+
768
+ // No re-declaration of `__compEl` in the shared inner-forEach scope:
769
+ // each comp must use a uniquely-suffixed binding.
770
+ expect(content).toContain('__compEl0')
771
+ expect(content).toContain('__compEl1')
772
+
773
+ // The bug threw "Identifier '__compEl' has already been declared" — the
774
+ // unsuffixed binding must not appear when multiple comps share a scope.
775
+ expect(content).not.toContain('const __compEl =')
776
+ })
777
+
778
+ test('nested .map() with a single inner component keeps the plain __compEl binding (#1664)', () => {
779
+ const source = `
780
+ 'use client'
781
+
782
+ export function Picker() {
783
+ const GROUPS = [
784
+ { id: 'a', items: [{ id: 'x', label: 'X' }] },
785
+ ]
786
+ return (
787
+ <div>
788
+ {GROUPS.map(group => (
789
+ <div key={group.id}>
790
+ {group.items.map(it => (
791
+ <SelectItem key={it.id} value={it.id}>{it.label}</SelectItem>
792
+ ))}
793
+ </div>
794
+ ))}
795
+ </div>
796
+ )
797
+ }
798
+ `
799
+ const result = compileJSX(source, 'Picker.tsx', { adapter })
800
+ expect(result.errors).toHaveLength(0)
801
+
802
+ const content = result.files.find(f => f.type === 'clientJs')!.content
803
+ expect(content).toContain("initChild('SelectItem'")
804
+ // Single comp keeps the unsuffixed name.
805
+ expect(content).toContain('const __compEl =')
806
+ expect(content).not.toContain('__compEl0')
807
+ })
732
808
  })
@@ -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
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * renderImportMapHtml tests
3
+ *
4
+ * The shared importmap-snippet renderer turns a parsed `barefoot-externals.json`
5
+ * into the `<script type="importmap">` (+ `<link rel="modulepreload">`) HTML that
6
+ * `bf build` emits as `barefoot-importmap.html` for template-string adapters
7
+ * (issue #1644). This is the single source of truth for that snippet.
8
+ */
9
+ import { describe, test, expect } from 'bun:test'
10
+ import { renderImportMapHtml } from '../import-map'
11
+
12
+ function parseImportMap(html: string): Record<string, string> {
13
+ const match = html.match(/<script type="importmap">(.*?)<\/script>/s)
14
+ if (!match) throw new Error(`no importmap in: ${html}`)
15
+ // Decode the < escape the renderer applies before parsing.
16
+ return JSON.parse(match[1]).imports
17
+ }
18
+
19
+ describe('renderImportMapHtml', () => {
20
+ test('emits the manifest importmap imports verbatim', () => {
21
+ const html = renderImportMapHtml({
22
+ importmap: {
23
+ imports: {
24
+ '@barefootjs/client': '/components/barefoot.js',
25
+ '@barefootjs/client/runtime': '/components/barefoot.js',
26
+ zod: 'https://esm.sh/zod@4.4.3',
27
+ },
28
+ },
29
+ preloads: [],
30
+ })
31
+ expect(parseImportMap(html)).toEqual({
32
+ '@barefootjs/client': '/components/barefoot.js',
33
+ '@barefootjs/client/runtime': '/components/barefoot.js',
34
+ zod: 'https://esm.sh/zod@4.4.3',
35
+ })
36
+ expect(html).not.toContain('modulepreload')
37
+ })
38
+
39
+ test('emits modulepreload links with crossorigin for manifest preloads (#1648)', () => {
40
+ const html = renderImportMapHtml({
41
+ importmap: { imports: {} },
42
+ preloads: ['/components/form.js', 'https://esm.sh/zod@4.4.3'],
43
+ })
44
+ expect(html).toContain('<link rel="modulepreload" href="/components/form.js" crossorigin>')
45
+ expect(html).toContain('<link rel="modulepreload" href="https://esm.sh/zod@4.4.3" crossorigin>')
46
+ })
47
+
48
+ test('reads defensively from a partial manifest', () => {
49
+ expect(parseImportMap(renderImportMapHtml({}))).toEqual({})
50
+ expect(renderImportMapHtml({})).not.toContain('modulepreload')
51
+ })
52
+
53
+ test('ends with a trailing newline (template-include friendly)', () => {
54
+ expect(renderImportMapHtml({ importmap: { imports: {} } }).endsWith('\n')).toBe(true)
55
+ })
56
+
57
+ test('escapes < in the importmap JSON so a URL cannot break out of the script', () => {
58
+ const html = renderImportMapHtml({
59
+ importmap: { imports: { evil: 'https://x/</script><script>alert(1)</script>' } },
60
+ })
61
+ // The literal closing tag must not appear before the importmap's own.
62
+ const importmapClose = html.indexOf('</script>')
63
+ expect(html.slice(0, importmapClose)).not.toContain('</script>')
64
+ // But the value still round-trips through JSON.parse.
65
+ expect(parseImportMap(html).evil).toBe('https://x/</script><script>alert(1)</script>')
66
+ })
67
+
68
+ test('escapes double quotes and angle brackets in preload hrefs', () => {
69
+ const html = renderImportMapHtml({
70
+ preloads: ['/components/"onerror=alert(1).js'],
71
+ })
72
+ expect(html).not.toContain('"onerror=alert(1)')
73
+ expect(html).toContain('&quot;onerror=alert(1)')
74
+ })
75
+ })
@@ -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
  })
@@ -31,9 +31,10 @@ describe('sort().map() / toSorted().map()', () => {
31
31
  expect(loop).toBeDefined()
32
32
  if (loop?.type === 'loop') {
33
33
  expect(loop.sortComparator).toBeDefined()
34
- expect(loop.sortComparator!.key).toEqual({ kind: 'field', field: 'price' })
35
- expect(loop.sortComparator!.type).toBe('numeric')
36
- expect(loop.sortComparator!.direction).toBe('asc')
34
+ expect(loop.sortComparator!.keys).toHaveLength(1)
35
+ expect(loop.sortComparator!.keys[0].key).toEqual({ kind: 'field', field: 'price' })
36
+ expect(loop.sortComparator!.keys[0].type).toBe('numeric')
37
+ expect(loop.sortComparator!.keys[0].direction).toBe('asc')
37
38
  expect(loop.sortComparator!.method).toBe('sort')
38
39
  expect(loop.sortComparator!.paramA).toBe('a')
39
40
  expect(loop.sortComparator!.paramB).toBe('b')
@@ -69,9 +70,10 @@ describe('sort().map() / toSorted().map()', () => {
69
70
  expect(loop).toBeDefined()
70
71
  if (loop?.type === 'loop') {
71
72
  expect(loop.sortComparator).toBeDefined()
72
- expect(loop.sortComparator!.key).toEqual({ kind: 'field', field: 'price' })
73
- expect(loop.sortComparator!.type).toBe('numeric')
74
- expect(loop.sortComparator!.direction).toBe('desc')
73
+ expect(loop.sortComparator!.keys).toHaveLength(1)
74
+ expect(loop.sortComparator!.keys[0].key).toEqual({ kind: 'field', field: 'price' })
75
+ expect(loop.sortComparator!.keys[0].type).toBe('numeric')
76
+ expect(loop.sortComparator!.keys[0].direction).toBe('desc')
75
77
  expect(loop.sortComparator!.method).toBe('toSorted')
76
78
  }
77
79
  }
@@ -105,9 +107,10 @@ describe('sort().map() / toSorted().map()', () => {
105
107
  expect(loop.filterPredicate).toBeDefined()
106
108
  expect(loop.filterPredicate!.param).toBe('t')
107
109
  expect(loop.sortComparator).toBeDefined()
108
- expect(loop.sortComparator!.key).toEqual({ kind: 'field', field: 'priority' })
109
- expect(loop.sortComparator!.type).toBe('numeric')
110
- expect(loop.sortComparator!.direction).toBe('asc')
110
+ expect(loop.sortComparator!.keys).toHaveLength(1)
111
+ expect(loop.sortComparator!.keys[0].key).toEqual({ kind: 'field', field: 'priority' })
112
+ expect(loop.sortComparator!.keys[0].type).toBe('numeric')
113
+ expect(loop.sortComparator!.keys[0].direction).toBe('asc')
111
114
  expect(loop.chainOrder).toBe('filter-sort')
112
115
  expect(loop.array).toBe('todos()')
113
116
  }
@@ -147,6 +150,206 @@ describe('sort().map() / toSorted().map()', () => {
147
150
  }
148
151
  })
149
152
 
153
+ test('multi-key (||-chain) produces one SortKey per operand', () => {
154
+ const source = `
155
+ 'use client'
156
+ import { createSignal } from '@barefootjs/client'
157
+
158
+ export function ProductList() {
159
+ const [products, setProducts] = createSignal<any[]>([])
160
+ return (
161
+ <ul>
162
+ {products().sort((a, b) => b.price - a.price || a.name.localeCompare(b.name)).map(p => (
163
+ <li>{p.name}</li>
164
+ ))}
165
+ </ul>
166
+ )
167
+ }
168
+ `
169
+
170
+ const ctx = analyzeComponent(source, 'ProductList.tsx')
171
+ const ir = jsxToIR(ctx)
172
+
173
+ expect(ir).not.toBeNull()
174
+ if (ir!.type === 'element') {
175
+ const loop = ir!.children.find(c => c.type === 'loop')
176
+ expect(loop?.type).toBe('loop')
177
+ if (loop?.type === 'loop') {
178
+ expect(loop.sortComparator).toBeDefined()
179
+ expect(loop.sortComparator!.keys).toEqual([
180
+ { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'desc' },
181
+ { key: { kind: 'field', field: 'name' }, type: 'string', direction: 'asc' },
182
+ ])
183
+ }
184
+ }
185
+ })
186
+
187
+ test('relational ternary comparator lowers to an auto key', () => {
188
+ const source = `
189
+ 'use client'
190
+ import { createSignal } from '@barefootjs/client'
191
+
192
+ export function ProductList() {
193
+ const [products, setProducts] = createSignal<any[]>([])
194
+ return (
195
+ <ul>
196
+ {products().toSorted((a, b) => a.rank > b.rank ? 1 : -1).map(p => (
197
+ <li>{p.name}</li>
198
+ ))}
199
+ </ul>
200
+ )
201
+ }
202
+ `
203
+
204
+ const ctx = analyzeComponent(source, 'ProductList.tsx')
205
+ const ir = jsxToIR(ctx)
206
+
207
+ expect(ir).not.toBeNull()
208
+ if (ir!.type === 'element') {
209
+ const loop = ir!.children.find(c => c.type === 'loop')
210
+ if (loop?.type === 'loop') {
211
+ expect(loop.sortComparator).toBeDefined()
212
+ expect(loop.sortComparator!.keys).toEqual([
213
+ { key: { kind: 'field', field: 'rank' }, type: 'auto', direction: 'asc' },
214
+ ])
215
+ }
216
+ }
217
+ })
218
+
219
+ test('3-way ternary comparator derives direction from the outer comparison', () => {
220
+ const source = `
221
+ 'use client'
222
+ import { createSignal } from '@barefootjs/client'
223
+
224
+ export function NumList() {
225
+ const [nums, setNums] = createSignal<number[]>([])
226
+ return (
227
+ <ul>
228
+ {nums().sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map(n => (
229
+ <li>{n}</li>
230
+ ))}
231
+ </ul>
232
+ )
233
+ }
234
+ `
235
+
236
+ const ctx = analyzeComponent(source, 'NumList.tsx')
237
+ const ir = jsxToIR(ctx)
238
+
239
+ expect(ir).not.toBeNull()
240
+ if (ir!.type === 'element') {
241
+ const loop = ir!.children.find(c => c.type === 'loop')
242
+ if (loop?.type === 'loop') {
243
+ expect(loop.sortComparator).toBeDefined()
244
+ expect(loop.sortComparator!.keys).toEqual([
245
+ { key: { kind: 'self' }, type: 'auto', direction: 'asc' },
246
+ ])
247
+ }
248
+ }
249
+ })
250
+
251
+ test('arrow block body with single return unwraps to the comparator', () => {
252
+ const source = `
253
+ 'use client'
254
+ import { createSignal } from '@barefootjs/client'
255
+
256
+ export function ProductList() {
257
+ const [products, setProducts] = createSignal<any[]>([])
258
+ return (
259
+ <ul>
260
+ {products().sort((a, b) => { return a.price - b.price }).map(p => (
261
+ <li>{p.name}</li>
262
+ ))}
263
+ </ul>
264
+ )
265
+ }
266
+ `
267
+
268
+ const ctx = analyzeComponent(source, 'ProductList.tsx')
269
+ const ir = jsxToIR(ctx)
270
+
271
+ expect(ir).not.toBeNull()
272
+ if (ir!.type === 'element') {
273
+ const loop = ir!.children.find(c => c.type === 'loop')
274
+ if (loop?.type === 'loop') {
275
+ expect(loop.sortComparator).toBeDefined()
276
+ expect(loop.sortComparator!.keys).toEqual([
277
+ { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'asc' },
278
+ ])
279
+ // block body unwraps to the returned expression, keeping the
280
+ // @client fallback's synthetic `(a, b) => raw` arrow valid.
281
+ expect(loop.sortComparator!.raw).toBe('a.price - b.price')
282
+ }
283
+ }
284
+ })
285
+
286
+ test('leading-equality 3-way ternary (a.f === b.f ? 0 : …) lowers to an auto key', () => {
287
+ const source = `
288
+ 'use client'
289
+ import { createSignal } from '@barefootjs/client'
290
+
291
+ export function ProductList() {
292
+ const [products, setProducts] = createSignal<any[]>([])
293
+ return (
294
+ <ul>
295
+ {products().toSorted((a, b) => a.rank === b.rank ? 0 : a.rank > b.rank ? 1 : -1).map(p => (
296
+ <li>{p.name}</li>
297
+ ))}
298
+ </ul>
299
+ )
300
+ }
301
+ `
302
+
303
+ const ctx = analyzeComponent(source, 'ProductList.tsx')
304
+ const ir = jsxToIR(ctx)
305
+
306
+ expect(ir).not.toBeNull()
307
+ if (ir!.type === 'element') {
308
+ const loop = ir!.children.find(c => c.type === 'loop')
309
+ if (loop?.type === 'loop') {
310
+ expect(loop.sortComparator).toBeDefined()
311
+ // The `=== ? 0` arm is a tie; direction comes from the inner
312
+ // relational ternary (a.rank > b.rank ? 1 : -1 → ascending).
313
+ expect(loop.sortComparator!.keys).toEqual([
314
+ { key: { kind: 'field', field: 'rank' }, type: 'auto', direction: 'asc' },
315
+ ])
316
+ }
317
+ }
318
+ })
319
+
320
+ test('multi-key mixing a numeric leaf and an auto (ternary) leaf', () => {
321
+ const source = `
322
+ 'use client'
323
+ import { createSignal } from '@barefootjs/client'
324
+
325
+ export function ProductList() {
326
+ const [products, setProducts] = createSignal<any[]>([])
327
+ return (
328
+ <ul>
329
+ {products().sort((a, b) => a.price - b.price || (a.rank > b.rank ? 1 : -1)).map(p => (
330
+ <li>{p.name}</li>
331
+ ))}
332
+ </ul>
333
+ )
334
+ }
335
+ `
336
+
337
+ const ctx = analyzeComponent(source, 'ProductList.tsx')
338
+ const ir = jsxToIR(ctx)
339
+
340
+ expect(ir).not.toBeNull()
341
+ if (ir!.type === 'element') {
342
+ const loop = ir!.children.find(c => c.type === 'loop')
343
+ if (loop?.type === 'loop') {
344
+ expect(loop.sortComparator).toBeDefined()
345
+ expect(loop.sortComparator!.keys).toEqual([
346
+ { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'asc' },
347
+ { key: { kind: 'field', field: 'rank' }, type: 'auto', direction: 'asc' },
348
+ ])
349
+ }
350
+ }
351
+ })
352
+
150
353
  test('complex sort comparator with @client keeps sort in array', () => {
151
354
  const source = `
152
355
  'use client'
@@ -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
+ })