@barefootjs/jsx 0.5.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/test-adapter.d.ts.map +1 -1
- package/dist/index.js +179 -37
- 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/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +310 -188
- package/src/__tests__/adapter-output.test.ts +49 -0
- package/src/__tests__/client-js-generation.test.ts +5 -2
- package/src/__tests__/inline-jsx-callback.test.ts +95 -0
- package/src/__tests__/ir-jsx-props.test.ts +5 -2
- 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/adapters/test-adapter.ts +16 -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/types.ts +7 -0
- package/src/ir-to-client-js/utils.ts +2 -1
- package/src/jsx-to-ir.ts +161 -12
- package/src/preprocess-inline-jsx-callbacks.ts +28 -10
- 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
|
})
|
|
@@ -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
|
|
|
@@ -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
|
})
|
|
@@ -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) {
|
|
@@ -231,6 +231,7 @@ export function collectInnerLoops(
|
|
|
231
231
|
key: n.key,
|
|
232
232
|
markerId: n.markerId,
|
|
233
233
|
bodyIsMultiRoot: n.bodyIsMultiRoot,
|
|
234
|
+
bodyIsItemConditional: n.bodyIsItemConditional,
|
|
234
235
|
iterationShape: n.iterationShape,
|
|
235
236
|
containerSlotId: scope.parentSlotId,
|
|
236
237
|
template,
|
|
@@ -564,6 +565,7 @@ export function collectElements(
|
|
|
564
565
|
key: l.key,
|
|
565
566
|
markerId: l.markerId,
|
|
566
567
|
bodyIsMultiRoot: l.bodyIsMultiRoot,
|
|
568
|
+
bodyIsItemConditional: l.bodyIsItemConditional,
|
|
567
569
|
iterationShape: l.iterationShape,
|
|
568
570
|
template,
|
|
569
571
|
staticItemTemplate,
|
|
@@ -898,6 +900,7 @@ function collectBranchLoops(
|
|
|
898
900
|
key: n.key,
|
|
899
901
|
markerId: n.markerId,
|
|
900
902
|
bodyIsMultiRoot: n.bodyIsMultiRoot,
|
|
903
|
+
bodyIsItemConditional: n.bodyIsItemConditional,
|
|
901
904
|
iterationShape: n.iterationShape,
|
|
902
905
|
template: childTemplate,
|
|
903
906
|
containerSlotId: containerSlot,
|
|
@@ -56,6 +56,15 @@ export interface BuildLoopPlanOptions {
|
|
|
56
56
|
* described above and returns the discriminated `LoopPlan`.
|
|
57
57
|
*/
|
|
58
58
|
export function buildLoopPlan(elem: TopLevelLoop, opts: BuildLoopPlanOptions): LoopPlan {
|
|
59
|
+
// Whole-item conditional bodies (#1665) render 0-or-1 element per item, so
|
|
60
|
+
// they need anchored `mapArrayAnchored` emission regardless of whether the
|
|
61
|
+
// array is static or dynamic. Routing both through the plain (anchored)
|
|
62
|
+
// path keeps `const arr` and `signal()` behaviour identical — a static
|
|
63
|
+
// array's per-item conditional still toggles reactively instead of freezing
|
|
64
|
+
// in the SSR-time `forEach` (which has no conditional handling at all).
|
|
65
|
+
if (elem.bodyIsItemConditional) {
|
|
66
|
+
return buildPlainLoopPlan(elem)
|
|
67
|
+
}
|
|
59
68
|
if (elem.isStaticArray) {
|
|
60
69
|
return buildStaticLoopPlan(elem, opts.unsafeLocalNames)
|
|
61
70
|
}
|
|
@@ -92,6 +101,14 @@ export function buildPlainLoopPlan(elem: TopLevelLoop): PlainLoopPlan {
|
|
|
92
101
|
reactiveEffects: hasReactive ? buildLoopReactiveEffectsPlan(elem) : null,
|
|
93
102
|
childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
|
|
94
103
|
bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
|
|
104
|
+
anchored: elem.bodyIsItemConditional ?? false,
|
|
105
|
+
// Fall back to the iteration index when the loop has no key. A whole-item
|
|
106
|
+
// conditional without a key is a BF023 error, but the emitted client JS
|
|
107
|
+
// must still parse — an empty `anchorKeyExpr` would produce
|
|
108
|
+
// `createComment(`bf-loop-i:${}`)` (a SyntaxError that breaks the whole
|
|
109
|
+
// bundle). `elem.index || '__idx'` matches `indexParam` above, so the
|
|
110
|
+
// anchor value stays consistent with the renderItem's own index param.
|
|
111
|
+
anchorKeyExpr: elem.key ? wrap(elem.key) : (elem.index || '__idx'),
|
|
95
112
|
}
|
|
96
113
|
}
|
|
97
114
|
|
|
@@ -95,6 +95,20 @@ interface PlainLoopVariant extends DynamicLoopCommon {
|
|
|
95
95
|
* `<!--bf-loop-i-->` marker emission, and `qsaItem` slot lookups (#1212).
|
|
96
96
|
*/
|
|
97
97
|
bodyIsMultiRoot: boolean
|
|
98
|
+
/**
|
|
99
|
+
* True when the loop body is a whole-item conditional (#1665). Switches
|
|
100
|
+
* emission to `mapArrayAnchored`: the renderItem returns a fragment headed
|
|
101
|
+
* by a `<!--bf-loop-i:KEY-->` anchor and seeded with the conditional's
|
|
102
|
+
* markers, and `insert(anchor, …)` (not `insert(__el, …)`) owns the
|
|
103
|
+
* possibly-empty content.
|
|
104
|
+
*/
|
|
105
|
+
anchored: boolean
|
|
106
|
+
/**
|
|
107
|
+
* Key expression wrapped as a loop-param accessor (`t().id`), used to bake
|
|
108
|
+
* the per-item `bf-loop-i:KEY` anchor value inside the anchored renderItem.
|
|
109
|
+
* Empty when the loop has no key (only meaningful when `anchored`).
|
|
110
|
+
*/
|
|
111
|
+
anchorKeyExpr: string
|
|
98
112
|
}
|
|
99
113
|
|
|
100
114
|
/**
|
|
@@ -163,10 +163,15 @@ function emitArmBody(
|
|
|
163
163
|
|
|
164
164
|
for (const te of body.textEffects) {
|
|
165
165
|
const v = varSlotId(te.slotId)
|
|
166
|
-
|
|
166
|
+
// Route through `__bfText` so a JSX-valued expression (`{cond && logo(id)}`)
|
|
167
|
+
// re-splices the live element by identity instead of stringifying it to
|
|
168
|
+
// "[object HTMLElement]" — the branch template already spliced it via
|
|
169
|
+
// `__bfSlot`, and this effect re-renders it when its deps change (#1663).
|
|
170
|
+
// The `let` tracker carries the replaced node across reactive re-runs.
|
|
171
|
+
lines.push(`${indent}let __anchor_${v} = $t(__branchScope, '${te.slotId}')[0]`)
|
|
167
172
|
lines.push(`${indent}__disposers.push(createDisposableEffect(() => {`)
|
|
168
173
|
lines.push(`${indent} const __val = ${te.expression}`)
|
|
169
|
-
lines.push(`${indent}
|
|
174
|
+
lines.push(`${indent} __anchor_${v} = __bfText(__anchor_${v}, __val)`)
|
|
170
175
|
lines.push(`${indent}}))`)
|
|
171
176
|
}
|
|
172
177
|
|