@barefootjs/jsx 0.32.0 → 0.33.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/dangerous-inner-html.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +22 -0
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +29 -6
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +274 -80
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +8 -0
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +12 -0
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -0
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.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/phases/provider-and-child-inits.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +21 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/query-href-lowering.d.ts.map +1 -1
- package/dist/ssr-seed-plan.d.ts.map +1 -1
- package/dist/static-literal.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +2 -4
- package/src/__tests__/child-components-in-map.test.ts +11 -3
- package/src/__tests__/client-js-generation.test.ts +37 -1
- package/src/__tests__/expression-parser.test.ts +43 -6
- package/src/__tests__/inline-jsx-callback.test.ts +55 -0
- package/src/__tests__/ir-jsx-props.test.ts +148 -0
- package/src/__tests__/issue-2705-branch-inner-loop-container.test.ts +91 -0
- package/src/__tests__/markup-prop-brand.test.ts +49 -0
- package/src/__tests__/nested-loop-conditional.test.ts +20 -11
- package/src/__tests__/return-through-local-var.test.ts +269 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +17 -3
- package/src/__tests__/ssr-defaults.test.ts +41 -0
- package/src/__tests__/ssr-seed-plan.test.ts +12 -2
- package/src/adapters/dangerous-inner-html.ts +1 -0
- package/src/adapters/parsed-expr-emitter.ts +49 -3
- package/src/analyzer.ts +71 -0
- package/src/errors.ts +17 -1
- package/src/expression-parser.ts +200 -89
- package/src/index.ts +2 -2
- package/src/ir-to-client-js/collect-elements.ts +31 -20
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +19 -0
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +22 -3
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
- package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +12 -0
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +9 -0
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +7 -1
- package/src/ir-to-client-js/element-refs.ts +8 -0
- package/src/ir-to-client-js/emit-reactive.ts +91 -23
- package/src/ir-to-client-js/imports.ts +5 -0
- package/src/ir-to-client-js/index.ts +4 -0
- package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
- package/src/ir-to-client-js/types.ts +21 -0
- package/src/jsx-to-ir.ts +161 -9
- package/src/query-href-lowering.ts +4 -0
- package/src/rich-type-refusal.ts +1 -1
- package/src/ssr-defaults.ts +51 -2
- package/src/ssr-seed-plan.ts +20 -3
- package/src/static-literal.ts +15 -1
- package/src/to-locale-date-lowering.ts +4 -0
|
@@ -367,4 +367,152 @@ describe('JSX props (#559)', () => {
|
|
|
367
367
|
expect(clientJs!.content).toContain("kind: 'markup'")
|
|
368
368
|
})
|
|
369
369
|
})
|
|
370
|
+
|
|
371
|
+
// #2703 (Copilot review on #2667's PR): the naked ternary/array wrapper
|
|
372
|
+
// refusal, and the direct-JSX classification it sits beside, both used
|
|
373
|
+
// to unwrap only `ParenthesizedExpression` — a JSX-wrapping ternary/
|
|
374
|
+
// array (or a direct JSX element) additionally wrapped in a transparent
|
|
375
|
+
// TS annotation (`as`, `satisfies`, postfix non-null `!`) slipped past
|
|
376
|
+
// both checks and still spliced raw JSX into the emitted client JS.
|
|
377
|
+
describe('#2667/#2703: transparent TS wrappers around JSX in prop position', () => {
|
|
378
|
+
test('ternary wrapping `as`-cast JSX branches still refuses with BF021', () => {
|
|
379
|
+
const source = `
|
|
380
|
+
'use client'
|
|
381
|
+
import { createSignal } from '@barefootjs/client'
|
|
382
|
+
export function App() {
|
|
383
|
+
const [cond, setCond] = createSignal(true)
|
|
384
|
+
return (
|
|
385
|
+
<Layout header={cond() ? (<a>x</a> as any) : (<b>y</b> as any)} />
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
`
|
|
389
|
+
const result = compileJSX(source, 'App.tsx', { adapter })
|
|
390
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
391
|
+
expect(errors).toHaveLength(1)
|
|
392
|
+
expect(errors[0].code).toBe('BF021')
|
|
393
|
+
// No raw JSX syntax leaked into the client bundle.
|
|
394
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
395
|
+
expect(clientJs?.content ?? '').not.toMatch(/<a>x<\/a>|<b>y<\/b>/)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('ternary wrapping `satisfies`-annotated JSX branches still refuses with BF021', () => {
|
|
399
|
+
const source = `
|
|
400
|
+
'use client'
|
|
401
|
+
import { createSignal } from '@barefootjs/client'
|
|
402
|
+
export function App() {
|
|
403
|
+
const [cond, setCond] = createSignal(true)
|
|
404
|
+
return (
|
|
405
|
+
<Layout header={cond() ? (<a>x</a> satisfies any) : (<b>y</b> satisfies any)} />
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
`
|
|
409
|
+
const result = compileJSX(source, 'App.tsx', { adapter })
|
|
410
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
411
|
+
expect(errors).toHaveLength(1)
|
|
412
|
+
expect(errors[0].code).toBe('BF021')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('ternary wrapping non-null-asserted JSX branches still refuses with BF021', () => {
|
|
416
|
+
// `!` must trail the CLOSING paren, not sit inside it: TS's parser
|
|
417
|
+
// mis-parses `(<a/>!)` (non-null touching the JSX closing tag
|
|
418
|
+
// directly, inside the parens) — confirmed by direct AST dump
|
|
419
|
+
// during #2703's investigation, unrelated to this fix.
|
|
420
|
+
const source = `
|
|
421
|
+
'use client'
|
|
422
|
+
import { createSignal } from '@barefootjs/client'
|
|
423
|
+
export function App() {
|
|
424
|
+
const [cond, setCond] = createSignal(true)
|
|
425
|
+
return (
|
|
426
|
+
<Layout header={cond() ? (<a>x</a>)! : (<b>y</b>)!} />
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
`
|
|
430
|
+
const result = compileJSX(source, 'App.tsx', { adapter })
|
|
431
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
432
|
+
expect(errors).toHaveLength(1)
|
|
433
|
+
expect(errors[0].code).toBe('BF021')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('array literal wrapping `as`-cast JSX elements still refuses with BF021', () => {
|
|
437
|
+
const source = `
|
|
438
|
+
export function App() {
|
|
439
|
+
return (
|
|
440
|
+
<Layout header={[<a>x</a> as any, <b>y</b> as any]} />
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
`
|
|
444
|
+
const result = compileJSX(source, 'App.tsx', { adapter })
|
|
445
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
446
|
+
expect(errors).toHaveLength(1)
|
|
447
|
+
expect(errors[0].code).toBe('BF021')
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('a bare `as`-cast JSX element (no ternary) still classifies as jsx-children, not a refusal', () => {
|
|
451
|
+
const source = `
|
|
452
|
+
export function App() {
|
|
453
|
+
return <Layout header={<a>x</a> as any} />
|
|
454
|
+
}
|
|
455
|
+
`
|
|
456
|
+
const ctx = analyzeComponent(source, 'App.tsx')
|
|
457
|
+
const ir = jsxToIR(ctx)
|
|
458
|
+
expect(ir).not.toBeNull()
|
|
459
|
+
const layout = findComponent(ir!, 'Layout')
|
|
460
|
+
const headerProp = layout!.props.find(p => p.name === 'header')
|
|
461
|
+
expect(headerProp!.value.kind).toBe('jsx-children')
|
|
462
|
+
|
|
463
|
+
// Phase 2: the compiled client JS brands it (#2651), matching the
|
|
464
|
+
// bare `<a>x</a>` form byte-for-byte — `as any` is erased, not
|
|
465
|
+
// spliced as source text.
|
|
466
|
+
const result = compileJSX(source, 'App.tsx', { adapter })
|
|
467
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
468
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
469
|
+
expect(clientJs!.content).toMatch(/bfMarkup\(`<a>x<\/a>`\)/)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('a bare `satisfies`-annotated JSX element (no ternary) still classifies as jsx-children', () => {
|
|
473
|
+
const source = `
|
|
474
|
+
export function App() {
|
|
475
|
+
return <Layout header={<a>x</a> satisfies any} />
|
|
476
|
+
}
|
|
477
|
+
`
|
|
478
|
+
const ctx = analyzeComponent(source, 'App.tsx')
|
|
479
|
+
const ir = jsxToIR(ctx)
|
|
480
|
+
const layout = findComponent(ir!, 'Layout')
|
|
481
|
+
const headerProp = layout!.props.find(p => p.name === 'header')
|
|
482
|
+
expect(headerProp!.value.kind).toBe('jsx-children')
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
test('a bare non-null-asserted JSX element (no ternary) still classifies as jsx-children', () => {
|
|
486
|
+
// `!` must trail the closing paren (`(<a/>)!`), not touch the JSX
|
|
487
|
+
// closing tag directly (`<a/>!`) — the latter does not reliably
|
|
488
|
+
// parse as a NonNullExpression at all inside a JSX attribute
|
|
489
|
+
// expression (confirmed by direct AST dump during #2703's
|
|
490
|
+
// investigation: the bare form leaves stray tokens outside the
|
|
491
|
+
// JsxExpression entirely, so it never reaches this code path as a
|
|
492
|
+
// NonNullExpression in the first place — unrelated to this fix).
|
|
493
|
+
const source = `
|
|
494
|
+
export function App() {
|
|
495
|
+
return <Layout header={(<a>x</a>)!} />
|
|
496
|
+
}
|
|
497
|
+
`
|
|
498
|
+
const ctx = analyzeComponent(source, 'App.tsx')
|
|
499
|
+
const ir = jsxToIR(ctx)
|
|
500
|
+
const layout = findComponent(ir!, 'Layout')
|
|
501
|
+
const headerProp = layout!.props.find(p => p.name === 'header')
|
|
502
|
+
expect(headerProp!.value.kind).toBe('jsx-children')
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
test('a ternary with NO JSX inside, wrapped in `as`, is unaffected (ordinary expression, no refusal)', () => {
|
|
506
|
+
const source = `
|
|
507
|
+
'use client'
|
|
508
|
+
import { createSignal } from '@barefootjs/client'
|
|
509
|
+
export function App() {
|
|
510
|
+
const [cond, setCond] = createSignal(true)
|
|
511
|
+
return <Layout disabled={(cond() ? true : false) as any} />
|
|
512
|
+
}
|
|
513
|
+
`
|
|
514
|
+
const result = compileJSX(source, 'App.tsx', { adapter })
|
|
515
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
516
|
+
})
|
|
517
|
+
})
|
|
370
518
|
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiler-unit regression pin for #2705 (fixed).
|
|
3
|
+
*
|
|
4
|
+
* A loop nested inside a loop-row conditional's branch, behind a wrapper
|
|
5
|
+
* element the branch's own IR subtree never sees (`<article>{cond &&
|
|
6
|
+
* items.map(...)}</article>`), gets no `containerSlotId` of its own
|
|
7
|
+
* (`collectInnerLoops`'s branch walk in ir-to-client-js/collect-elements.ts
|
|
8
|
+
* never observes an ancestor outside the branch). `buildBranchInnerLoopsPlan`
|
|
9
|
+
* (control-flow/plan/build-loop-child-arm.ts) must fall back to
|
|
10
|
+
* `findCondContainer(__branchScope, '<condSlotId>')` — NOT the raw
|
|
11
|
+
* `__branchScope` (the whole conditional's bind scope, several elements
|
|
12
|
+
* wider than the loop's actual container; the pre-fix behavior that caused
|
|
13
|
+
* the runtime defect in issue-2705-nested-loop-branch-container-slot.test.ts).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, test, expect } from 'bun:test'
|
|
16
|
+
import { compileJSX } from '../compiler'
|
|
17
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
18
|
+
|
|
19
|
+
const adapter = new TestAdapter()
|
|
20
|
+
|
|
21
|
+
describe('#2705 — branch inner-loop container fallback', () => {
|
|
22
|
+
test('inner loop behind a wrapped loop-row `&&` conditional resolves its container via findCondContainer', () => {
|
|
23
|
+
const source = `
|
|
24
|
+
"use client";
|
|
25
|
+
import { createSignal } from '@barefootjs/client'
|
|
26
|
+
type Item = { id: string; label: string }
|
|
27
|
+
type Group = { id: string; show: boolean; items: Item[] }
|
|
28
|
+
export function Repro() {
|
|
29
|
+
const [groups] = createSignal<Group[]>([])
|
|
30
|
+
return (
|
|
31
|
+
<div>
|
|
32
|
+
{groups().map((group) => (
|
|
33
|
+
<div key={group.id}>
|
|
34
|
+
<article>
|
|
35
|
+
Group
|
|
36
|
+
{group.show &&
|
|
37
|
+
group.items.map((item) => (
|
|
38
|
+
<section key={item.id}>{item.label}</section>
|
|
39
|
+
))}
|
|
40
|
+
</article>
|
|
41
|
+
</div>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
`
|
|
47
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
48
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
49
|
+
const content = result.files.find(f => f.type === 'clientJs')!.content
|
|
50
|
+
|
|
51
|
+
// The conditional's own slot id feeds `findCondContainer` — never a raw
|
|
52
|
+
// `__branchScope` container for this inner mapArray.
|
|
53
|
+
expect(content).toMatch(/const __bic\w+ = findCondContainer\(__branchScope, '(s\d+)'\)/)
|
|
54
|
+
expect(content).not.toMatch(/const __bic\w+ = __branchScope\s*$/m)
|
|
55
|
+
expect(content).toContain('findCondContainer')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('an existing (non-null containerSlotId) inner-loop container is unaffected', () => {
|
|
59
|
+
// Control: a loop DIRECTLY inside a component whose own element carries
|
|
60
|
+
// a slot id continues to resolve via the qsa/bf-h fallback chain, not
|
|
61
|
+
// `findCondContainer` — the fix only changes the previously-null path.
|
|
62
|
+
const source = `
|
|
63
|
+
"use client";
|
|
64
|
+
import { createSignal } from '@barefootjs/client'
|
|
65
|
+
type TreeNode = { id: number; name: string; type: 'file' | 'folder'; expanded: boolean; children: TreeNode[] }
|
|
66
|
+
export function FileBrowser() {
|
|
67
|
+
const [tree] = createSignal<TreeNode[]>([])
|
|
68
|
+
return (
|
|
69
|
+
<div>
|
|
70
|
+
{tree().map(node => (
|
|
71
|
+
<div key={node.id}>
|
|
72
|
+
{node.expanded ? (
|
|
73
|
+
<div>
|
|
74
|
+
{node.children.map(child => (
|
|
75
|
+
<div key={child.id}>{child.name}</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
) : null}
|
|
79
|
+
</div>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
`
|
|
85
|
+
const result = compileJSX(source, 'FileBrowser.tsx', { adapter })
|
|
86
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
87
|
+
const content = result.files.find(f => f.type === 'clientJs')!.content
|
|
88
|
+
expect(content).not.toContain('findCondContainer')
|
|
89
|
+
expect(content).toMatch(/\.querySelector\('\[bf="s\d+"\]'\)/)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -158,4 +158,53 @@ describe('markup-prop brand (#2651)', () => {
|
|
|
158
158
|
// `__slot(...)` live-node wrapping — not itself re-wrapped in bfMarkup.
|
|
159
159
|
expect(clientJs).toMatch(/get header\(\)\s*\{\s*return __slot\(/)
|
|
160
160
|
})
|
|
161
|
+
|
|
162
|
+
// KNOWN BUG (#2702) — pins the CURRENT (broken) emission, not the
|
|
163
|
+
// correct one. `isSingleElementJsxChildren` (`ir-to-client-js/collect-
|
|
164
|
+
// elements.ts`) only brands the single-element jsx-children shape;
|
|
165
|
+
// #2651's PR body enumerated "conditional-in-fragment" as deliberately
|
|
166
|
+
// out of that PR's scope, and #2702 confirms it is actually broken, not
|
|
167
|
+
// merely unbranded-but-harmless: the child's own `escapeTextOrNode`
|
|
168
|
+
// reactive effect re-escapes the chosen branch's HTML as literal text
|
|
169
|
+
// the moment it first runs (real DOM corruption, verified directly —
|
|
170
|
+
// see `jsx-element-prop-fragment-conditional`'s fixture docstring,
|
|
171
|
+
// `packages/adapter-tests/fixtures/`, for the full mechanism and why no
|
|
172
|
+
// other shared conformance suite observes it: SSR is genuinely correct,
|
|
173
|
+
// and CSR conformance's `createEffect` mock never runs the effect).
|
|
174
|
+
//
|
|
175
|
+
// Graduation: once `bfMarkup()` branding is extended to this multi-part
|
|
176
|
+
// shape, this assertion starts failing — replace it with a positive
|
|
177
|
+
// `bfMarkup(...)`-wrapped assertion (mirroring test (b) above) and
|
|
178
|
+
// delete this comment block; that is the fixture's own graduation
|
|
179
|
+
// trigger too (its `expectedHtml` is already the correct SSR output, so
|
|
180
|
+
// no fixture change is needed, only this pin's removal and #2667's
|
|
181
|
+
// BF021 suggestion regaining the fragment-wrap escape).
|
|
182
|
+
test('(h) KNOWN BUG (#2702): a conditional-in-fragment jsx-children prop reaches initChild UNbranded', () => {
|
|
183
|
+
const source = `
|
|
184
|
+
'use client'
|
|
185
|
+
import { createSignal } from '@barefootjs/client'
|
|
186
|
+
export function Card(props: { header?: any; children?: any }) {
|
|
187
|
+
return (
|
|
188
|
+
<section>
|
|
189
|
+
<header>{props.header}</header>
|
|
190
|
+
<div>{props.children}</div>
|
|
191
|
+
</section>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
export function JsxElementPropFragmentConditional() {
|
|
195
|
+
const [cond, setCond] = createSignal(true)
|
|
196
|
+
return (
|
|
197
|
+
<Card header={<>{cond() ? <a>x</a> : <b>y</b>}</>}>
|
|
198
|
+
<p>body text</p>
|
|
199
|
+
</Card>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
`
|
|
203
|
+
const clientJs = clientJsFor(source)
|
|
204
|
+
// Bug: the getter returns a bare template-literal ternary — NOT
|
|
205
|
+
// wrapped in bfMarkup(...). If this ever starts matching bfMarkup(...)
|
|
206
|
+
// the bug is fixed; see the graduation note above.
|
|
207
|
+
expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*`<a>x<\/a>`\s*:\s*`<b>y<\/b>`\s*\}/)
|
|
208
|
+
expect(clientJs).not.toMatch(/get header\(\)\s*\{\s*return bfMarkup\(/)
|
|
209
|
+
})
|
|
161
210
|
})
|
|
@@ -134,14 +134,15 @@ describe('nested loops/conditionals inside mapArray (#830, #839)', () => {
|
|
|
134
134
|
expect(mapArrayCount).toBeGreaterThanOrEqual(3)
|
|
135
135
|
})
|
|
136
136
|
|
|
137
|
-
test('reactive text inside conditional inside inner loop
|
|
138
|
-
//
|
|
139
|
-
// insert()
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
137
|
+
test('reactive text inside conditional inside inner loop gets a real insert() (#840, fixed by #2706)', () => {
|
|
138
|
+
// A per-item conditional living inside a NESTED (inner) loop's row now
|
|
139
|
+
// gets full insert() parity with a top-level loop's row conditional
|
|
140
|
+
// (#2706) — the condition is a real reactive `createEffect` dependency,
|
|
141
|
+
// not baked once at row creation, and the branch's reactive text binds
|
|
142
|
+
// through the arm's own `lazySlots(__branchScope, ...)` +
|
|
143
|
+
// `createDisposableEffect` (same shape `stringifyLoopChildArm` emits for
|
|
144
|
+
// any other insert() arm), never a bare-claimSlots reclaim racing
|
|
145
|
+
// against a marker the active branch may not have rendered.
|
|
145
146
|
const source = `
|
|
146
147
|
'use client'
|
|
147
148
|
import { createSignal } from '@barefootjs/client'
|
|
@@ -176,9 +177,17 @@ describe('nested loops/conditionals inside mapArray (#830, #839)', () => {
|
|
|
176
177
|
expect(clientJs).toBeDefined()
|
|
177
178
|
const content = clientJs!.content
|
|
178
179
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
expect(content).toMatch(/
|
|
180
|
+
// A real insert() over the row's own element, keyed to the conditional's
|
|
181
|
+
// slot id and condition — not a static bake.
|
|
182
|
+
expect(content).toMatch(/insert\(__innerEl\w*, 's\d+', \(\) => child\(\)\.type === 'text', \{/)
|
|
183
|
+
|
|
184
|
+
// The branch's reactive text binds inside `bindEvents` via the standard
|
|
185
|
+
// arm-text shape (`lazySlots` + `createDisposableEffect`), scoped to
|
|
186
|
+
// `__branchScope` — the node insert() actually mounts for this branch —
|
|
187
|
+
// never a bare `claimSlots` reclaim against `__innerEl<uid>` racing the
|
|
188
|
+
// branch's own mount/unmount.
|
|
189
|
+
expect(content).toMatch(/const __bfw_s\d+ = lazySlots\(__branchScope, \[\{ id: 's\d+', kind: 'markup', path: \[\] \}\]\)/)
|
|
190
|
+
expect(content).toMatch(/createDisposableEffect\(\(\) => \{ __bfw_s\d+\('s\d+', escapeTextOrNode\(child\(\)\.label\)\) \}\)/)
|
|
182
191
|
})
|
|
183
192
|
|
|
184
193
|
test('event handler inside conditional branch of loop item appears in bindEvents (#839)', () => {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for #2720: a component whose render is bound to a local
|
|
3
|
+
* const and returned by name —
|
|
4
|
+
*
|
|
5
|
+
* export function Button() {
|
|
6
|
+
* const __root = (<button>Go</button>)
|
|
7
|
+
* return __root
|
|
8
|
+
* }
|
|
9
|
+
*
|
|
10
|
+
* — previously produced `{files: [], errors: []}`: neither sound (nothing
|
|
11
|
+
* emitted) nor loud (nothing reported). Two structural variants of this
|
|
12
|
+
* shape need two separate detectors, both landing here:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Flat** (statements are direct children of the component body, as
|
|
15
|
+
* written above): `ctx.jsxReturn` DOES get set (to the `__root`
|
|
16
|
+
* Identifier — `visitComponentBody`'s return handler captures any
|
|
17
|
+
* return expression, not just syntactic JSX), but return position never
|
|
18
|
+
* resolves an identifier through its initializer the way JSX-child
|
|
19
|
+
* position does via `jsxConstants` / `inlineableJsxConsts` (#547 /
|
|
20
|
+
* #1409) — so `transformJsxExpression`'s scalar-leaf case returns `null`
|
|
21
|
+
* and `buildIRRoot` (`jsx-to-ir.ts`) drops the component silently. Fixed
|
|
22
|
+
* there: recognize a bare Identifier at return position that names a
|
|
23
|
+
* local already proven to hold JSX by those two maps, and report BF027
|
|
24
|
+
* instead of dropping it.
|
|
25
|
+
*
|
|
26
|
+
* 2. **Nested-block** (`{ const __root = <jsx/>; return __root }` as a
|
|
27
|
+
* single block statement — the exact shape the #2481 mutation sweep's
|
|
28
|
+
* `block-body` mutation produces by wrapping the ORIGINAL return
|
|
29
|
+
* statement): the block is a direct child of the component body, so
|
|
30
|
+
* `visitComponentBody`'s opaque-block preservation (#930 — "a bare
|
|
31
|
+
* block at the top of a component body is inert side-effect scoping,
|
|
32
|
+
* preserve it verbatim, don't recurse") swallows it whole. Neither
|
|
33
|
+
* `jsxConstants` nor `jsxReturn` are EVER set, so the flat-case fix
|
|
34
|
+
* above never runs. Fixed in `analyzer.ts`'s `visitComponentBody`:
|
|
35
|
+
* before preserving such a block, `findBlockBodyReturnedJsxLocalName`
|
|
36
|
+
* checks whether it is exactly this "name the JSX, then return the
|
|
37
|
+
* name" shape and reports BF027 directly.
|
|
38
|
+
*
|
|
39
|
+
* The "faithful" fix (resolving the identifier through its initializer so
|
|
40
|
+
* the component actually compiles, for either variant) is tracked
|
|
41
|
+
* separately by #2720 and not implemented here — this PR is the loud
|
|
42
|
+
* stopgap only.
|
|
43
|
+
*
|
|
44
|
+
* Found by the #2481 mutation sweep's `block-body` mutation
|
|
45
|
+
* (`packages/adapter-tests/mutation/mutations.ts`, the nested-block shape
|
|
46
|
+
* above): 41/41 corpus fixtures reproduced this identically before this
|
|
47
|
+
* fix, classified `broken` with `refused` at 0. This fix flips them to
|
|
48
|
+
* `refused` (a pass under the sound-or-loud trichotomy).
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { describe, test, expect } from 'bun:test'
|
|
52
|
+
import { compileJSX } from '../compiler'
|
|
53
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
54
|
+
|
|
55
|
+
const adapter = new TestAdapter()
|
|
56
|
+
|
|
57
|
+
describe('BF027: return-through-local-variable is not recognized as JSX (#2720)', () => {
|
|
58
|
+
test('function component: `const __root = (<jsx/>); return __root` reports BF027 instead of silently emitting nothing', () => {
|
|
59
|
+
const source = `
|
|
60
|
+
export function Button() {
|
|
61
|
+
const __root = (<button>Go</button>)
|
|
62
|
+
return __root
|
|
63
|
+
}
|
|
64
|
+
`
|
|
65
|
+
const result = compileJSX(source, 'Button.tsx', { adapter })
|
|
66
|
+
|
|
67
|
+
// Neither silent-drop nor silent-emit: no files, but a loud diagnostic.
|
|
68
|
+
expect(result.files).toHaveLength(0)
|
|
69
|
+
const bf027 = result.errors.find(e => e.code === 'BF027')
|
|
70
|
+
expect(bf027).toBeDefined()
|
|
71
|
+
expect(bf027!.severity).toBe('error')
|
|
72
|
+
expect(bf027!.message).toContain('Button')
|
|
73
|
+
expect(bf027!.message).toMatch(/not recognized as JSX/)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('arrow component: same shape via `export const Button = () => {...}`', () => {
|
|
77
|
+
const source = `
|
|
78
|
+
export const Button = () => {
|
|
79
|
+
const __root = <button>Go</button>
|
|
80
|
+
return __root
|
|
81
|
+
}
|
|
82
|
+
`
|
|
83
|
+
const result = compileJSX(source, 'Button.tsx', { adapter })
|
|
84
|
+
expect(result.files).toHaveLength(0)
|
|
85
|
+
expect(result.errors.find(e => e.code === 'BF027')).toBeDefined()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('non-root JSX initializer (ternary) through a local also reports BF027', () => {
|
|
89
|
+
const source = `
|
|
90
|
+
export function Button({ ok }: { ok: boolean }) {
|
|
91
|
+
const __root = ok ? <button>Go</button> : <span>No</span>
|
|
92
|
+
return __root
|
|
93
|
+
}
|
|
94
|
+
`
|
|
95
|
+
const result = compileJSX(source, 'Button.tsx', { adapter })
|
|
96
|
+
expect(result.files).toHaveLength(0)
|
|
97
|
+
expect(result.errors.find(e => e.code === 'BF027')).toBeDefined()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('nested-block shape (the actual mutation-sweep output): `{ const __root = <jsx/>; return __root }` reports BF027', () => {
|
|
101
|
+
// This is the shape `packages/adapter-tests/mutation/mutations.ts`'s
|
|
102
|
+
// `blockBody` mutation actually produces (it wraps the ORIGINAL return
|
|
103
|
+
// statement in a new block rather than splicing the const/return in as
|
|
104
|
+
// top-level statements) — structurally distinct from the flat case
|
|
105
|
+
// above because the block is opaque to `visitComponentBody` (#930).
|
|
106
|
+
const source = `
|
|
107
|
+
export function Button() {
|
|
108
|
+
{
|
|
109
|
+
const __root = <button>Go</button>
|
|
110
|
+
return __root
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
`
|
|
114
|
+
const result = compileJSX(source, 'Button.tsx', { adapter })
|
|
115
|
+
expect(result.files).toHaveLength(0)
|
|
116
|
+
const bf027 = result.errors.find(e => e.code === 'BF027')
|
|
117
|
+
expect(bf027).toBeDefined()
|
|
118
|
+
expect(bf027!.message).toContain('Button')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('nested-block shape with a ternary JSX initializer also reports BF027', () => {
|
|
122
|
+
const source = `
|
|
123
|
+
export function Button({ ok }: { ok: boolean }) {
|
|
124
|
+
{
|
|
125
|
+
const __root = ok ? <button>Go</button> : <span>No</span>
|
|
126
|
+
return __root
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
`
|
|
130
|
+
const result = compileJSX(source, 'Button.tsx', { adapter })
|
|
131
|
+
expect(result.files).toHaveLength(0)
|
|
132
|
+
expect(result.errors.find(e => e.code === 'BF027')).toBeDefined()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('nested-block shape with a `.map()`-with-JSX-callback initializer also reports BF027', () => {
|
|
136
|
+
// `initializerShapeContainsJsx` stops at arrow boundaries, so this
|
|
137
|
+
// variant needs the same `isMapLikeCallWithJsx` check `collectConstant`
|
|
138
|
+
// uses (#1554) — without it this shape slipped back into the silent
|
|
139
|
+
// drop even after BF027 landed (Copilot review on #2726).
|
|
140
|
+
const source = `
|
|
141
|
+
export function List() {
|
|
142
|
+
{
|
|
143
|
+
const __root = ['a', 'b'].map((item) => <div>{item}</div>)
|
|
144
|
+
return __root
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
`
|
|
148
|
+
const result = compileJSX(source, 'List.tsx', { adapter })
|
|
149
|
+
expect(result.files).toHaveLength(0)
|
|
150
|
+
const bf027 = result.errors.find(e => e.code === 'BF027')
|
|
151
|
+
expect(bf027).toBeDefined()
|
|
152
|
+
expect(bf027!.message).toContain('List')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('flat shape with a `.map()`-with-JSX-callback initializer reports BF027 (via inlineableJsxConsts)', () => {
|
|
156
|
+
const source = `
|
|
157
|
+
export function List() {
|
|
158
|
+
const __root = ['a', 'b'].map((item) => <div>{item}</div>)
|
|
159
|
+
return __root
|
|
160
|
+
}
|
|
161
|
+
`
|
|
162
|
+
const result = compileJSX(source, 'List.tsx', { adapter })
|
|
163
|
+
expect(result.files).toHaveLength(0)
|
|
164
|
+
expect(result.errors.find(e => e.code === 'BF027')).toBeDefined()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('multi-component file: the broken sibling is flagged but the good sibling still compiles', () => {
|
|
168
|
+
const source = `
|
|
169
|
+
export function Good() { return <div>ok</div> }
|
|
170
|
+
export function Bad() {
|
|
171
|
+
const __root = <button>Go</button>
|
|
172
|
+
return __root
|
|
173
|
+
}
|
|
174
|
+
`
|
|
175
|
+
const result = compileJSX(source, 'Multi.tsx', { adapter })
|
|
176
|
+
const bf027 = result.errors.find(e => e.code === 'BF027')
|
|
177
|
+
expect(bf027).toBeDefined()
|
|
178
|
+
expect(bf027!.message).toContain('Bad')
|
|
179
|
+
// Good still produces output despite Bad's failure.
|
|
180
|
+
expect(result.files.length).toBeGreaterThan(0)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('control: direct JSX return keeps compiling clean', () => {
|
|
184
|
+
test('function component returning JSX directly has no BF027 and produces files', () => {
|
|
185
|
+
const source = `
|
|
186
|
+
export function Button() {
|
|
187
|
+
return (<button>Go</button>)
|
|
188
|
+
}
|
|
189
|
+
`
|
|
190
|
+
const result = compileJSX(source, 'Button.tsx', { adapter })
|
|
191
|
+
expect(result.errors.find(e => e.code === 'BF027')).toBeUndefined()
|
|
192
|
+
expect(result.files.length).toBeGreaterThan(0)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('no false positive: PascalCase exports that legitimately do not return JSX stay silent', () => {
|
|
197
|
+
test('a PascalCase function returning a plain object is untouched (not a component at all)', () => {
|
|
198
|
+
const source = `
|
|
199
|
+
export function CreateUser() {
|
|
200
|
+
return { name: 'x' }
|
|
201
|
+
}
|
|
202
|
+
`
|
|
203
|
+
const result = compileJSX(source, 'CreateUser.tsx', { adapter })
|
|
204
|
+
// Pre-existing behaviour for a non-component PascalCase export:
|
|
205
|
+
// no files, no errors. BF027 must not fire here — there is no local
|
|
206
|
+
// proven to hold JSX anywhere in this function.
|
|
207
|
+
expect(result.files).toHaveLength(0)
|
|
208
|
+
expect(result.errors.find(e => e.code === 'BF027')).toBeUndefined()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('render-nothing literals (null / <></> / false) returned directly stay clean', () => {
|
|
212
|
+
const source = `
|
|
213
|
+
export function ReturnsNull() { return null }
|
|
214
|
+
export function ReturnsFragment() { return <></> }
|
|
215
|
+
export function ReturnsFalse(): any { return false }
|
|
216
|
+
`
|
|
217
|
+
const result = compileJSX(source, 'ReturnsNull.tsx', { adapter })
|
|
218
|
+
expect(result.errors.find(e => e.code === 'BF027')).toBeUndefined()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('a local const unrelated to JSX does not spuriously trip BF027', () => {
|
|
222
|
+
const source = `
|
|
223
|
+
export function Button() {
|
|
224
|
+
const count = 1
|
|
225
|
+
return <button>{count}</button>
|
|
226
|
+
}
|
|
227
|
+
`
|
|
228
|
+
const result = compileJSX(source, 'Button.tsx', { adapter })
|
|
229
|
+
expect(result.errors.find(e => e.code === 'BF027')).toBeUndefined()
|
|
230
|
+
expect(result.files.length).toBeGreaterThan(0)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('an ordinary top-level scoping block with no returned local is untouched', () => {
|
|
234
|
+
// A bare block used for legitimate imperative scoping ahead of the
|
|
235
|
+
// real render — #930's opaque-block preservation path — must not be
|
|
236
|
+
// mistaken for the #2720 shape just because SOME block sits at the
|
|
237
|
+
// top of the component body.
|
|
238
|
+
const source = `
|
|
239
|
+
export function Button() {
|
|
240
|
+
{
|
|
241
|
+
const x = 1
|
|
242
|
+
console.log(x)
|
|
243
|
+
}
|
|
244
|
+
return <button>Go</button>
|
|
245
|
+
}
|
|
246
|
+
`
|
|
247
|
+
const result = compileJSX(source, 'Button.tsx', { adapter })
|
|
248
|
+
expect(result.errors.find(e => e.code === 'BF027')).toBeUndefined()
|
|
249
|
+
expect(result.files.length).toBeGreaterThan(0)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('a nested block whose returned identifier is not locally JSX-initialized stays silent', () => {
|
|
253
|
+
// The block's last statement returns `result`, but nothing in the
|
|
254
|
+
// block declares `result` as JSX — e.g. it is a prop or an outer
|
|
255
|
+
// local. Must not false-positive just because the shape ends in
|
|
256
|
+
// `return <identifier>`.
|
|
257
|
+
const source = `
|
|
258
|
+
export function Widget({ result }: { result: number }) {
|
|
259
|
+
{
|
|
260
|
+
const other = 1
|
|
261
|
+
return result
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
`
|
|
265
|
+
const compileResult = compileJSX(source, 'Widget.tsx', { adapter })
|
|
266
|
+
expect(compileResult.errors.find(e => e.code === 'BF027')).toBeUndefined()
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -98,12 +98,26 @@ describe('serializeParsedExpr', () => {
|
|
|
98
98
|
})
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
-
test('map body: object literal carries key + value (no keyKind / shorthand)', () => {
|
|
101
|
+
test('map body: object literal carries kind + key + value (no keyKind / shorthand)', () => {
|
|
102
|
+
// #2696 Step 2: each entry now carries its own `kind` tag ('prop' here —
|
|
103
|
+
// no source spread in this body) so the evaluator can distinguish a
|
|
104
|
+
// `prop` from a `spread` entry without re-deriving it from field shape.
|
|
102
105
|
expect(evalJSON('({ id: item.id, n: item.n })')).toEqual({
|
|
103
106
|
kind: 'object-literal',
|
|
104
107
|
properties: [
|
|
105
|
-
{ key: 'id', value: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'id' } },
|
|
106
|
-
{ key: 'n', value: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'n' } },
|
|
108
|
+
{ kind: 'prop', key: 'id', value: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'id' } },
|
|
109
|
+
{ kind: 'prop', key: 'n', value: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'n' } },
|
|
110
|
+
],
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('map body: object literal carries a spread entry as `{ kind: "spread", expr }`', () => {
|
|
115
|
+
// The todo-app motivating shape (#2696): `{ ...t, editing: false }`.
|
|
116
|
+
expect(evalJSON("({ ...t, editing: false })")).toEqual({
|
|
117
|
+
kind: 'object-literal',
|
|
118
|
+
properties: [
|
|
119
|
+
{ kind: 'spread', expr: { kind: 'identifier', name: 't' } },
|
|
120
|
+
{ kind: 'prop', key: 'editing', value: { kind: 'literal', value: false } },
|
|
107
121
|
],
|
|
108
122
|
})
|
|
109
123
|
})
|