@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.
- package/dist/adapters/interface.d.ts +20 -0
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/adapters/test-adapter.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +36 -19
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/import-map.d.ts +56 -0
- package/dist/import-map.d.ts.map +1 -0
- package/dist/import-map.js +18 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +333 -199
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +14 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +0 -14
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +7 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +2 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/scanner/js-scanner.d.ts +10 -0
- package/dist/scanner/js-scanner.d.ts.map +1 -1
- package/dist/scanner/js-scanner.js +5 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +438 -190
- package/src/__tests__/adapter-output.test.ts +49 -0
- package/src/__tests__/child-components-in-map.test.ts +76 -0
- package/src/__tests__/client-js-generation.test.ts +5 -2
- package/src/__tests__/import-map.test.ts +75 -0
- package/src/__tests__/inline-jsx-callback.test.ts +95 -0
- package/src/__tests__/ir-jsx-props.test.ts +5 -2
- package/src/__tests__/ir-sort-comparator.test.ts +212 -9
- package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
- package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
- package/src/__tests__/missing-key-in-list.test.ts +49 -0
- package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
- package/src/__tests__/token-contains-ident.test.ts +27 -0
- package/src/__tests__/unsupported-expression.test.ts +42 -13
- package/src/adapters/interface.ts +20 -0
- package/src/adapters/test-adapter.ts +16 -1
- package/src/expression-parser.ts +265 -50
- package/src/import-map.ts +72 -0
- package/src/index.ts +5 -1
- package/src/ir-to-client-js/collect-elements.ts +3 -0
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
- package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
- package/src/ir-to-client-js/emit-reactive.ts +12 -3
- package/src/ir-to-client-js/html-template.ts +29 -3
- package/src/ir-to-client-js/imports.ts +2 -2
- package/src/ir-to-client-js/reactivity.ts +17 -1
- package/src/ir-to-client-js/stringify/static-array-child-init.ts +8 -4
- package/src/ir-to-client-js/types.ts +7 -0
- package/src/ir-to-client-js/utils.ts +31 -116
- package/src/jsx-to-ir.ts +161 -12
- package/src/preprocess-inline-jsx-callbacks.ts +28 -10
- package/src/scanner/js-scanner.ts +16 -1
- 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
|
|
2304
|
-
|
|
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('"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
|
|
363
|
-
|
|
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!.
|
|
35
|
-
expect(loop.sortComparator!.
|
|
36
|
-
expect(loop.sortComparator!.
|
|
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!.
|
|
73
|
-
expect(loop.sortComparator!.
|
|
74
|
-
expect(loop.sortComparator!.
|
|
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!.
|
|
109
|
-
expect(loop.sortComparator!.
|
|
110
|
-
expect(loop.sortComparator!.
|
|
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
|
+
})
|