@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.
Files changed (72) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -1
  2. package/dist/adapters/parsed-expr-emitter.d.ts +22 -0
  3. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/errors.d.ts +1 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/expression-parser.d.ts +29 -6
  8. package/dist/expression-parser.d.ts.map +1 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +274 -80
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +8 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +12 -0
  18. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -0
  20. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/imports.d.ts +2 -2
  25. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/types.d.ts +21 -0
  28. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  29. package/dist/query-href-lowering.d.ts.map +1 -1
  30. package/dist/ssr-seed-plan.d.ts.map +1 -1
  31. package/dist/static-literal.d.ts.map +1 -1
  32. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +2 -4
  35. package/src/__tests__/child-components-in-map.test.ts +11 -3
  36. package/src/__tests__/client-js-generation.test.ts +37 -1
  37. package/src/__tests__/expression-parser.test.ts +43 -6
  38. package/src/__tests__/inline-jsx-callback.test.ts +55 -0
  39. package/src/__tests__/ir-jsx-props.test.ts +148 -0
  40. package/src/__tests__/issue-2705-branch-inner-loop-container.test.ts +91 -0
  41. package/src/__tests__/markup-prop-brand.test.ts +49 -0
  42. package/src/__tests__/nested-loop-conditional.test.ts +20 -11
  43. package/src/__tests__/return-through-local-var.test.ts +269 -0
  44. package/src/__tests__/serialize-parsed-expr.test.ts +17 -3
  45. package/src/__tests__/ssr-defaults.test.ts +41 -0
  46. package/src/__tests__/ssr-seed-plan.test.ts +12 -2
  47. package/src/adapters/dangerous-inner-html.ts +1 -0
  48. package/src/adapters/parsed-expr-emitter.ts +49 -3
  49. package/src/analyzer.ts +71 -0
  50. package/src/errors.ts +17 -1
  51. package/src/expression-parser.ts +200 -89
  52. package/src/index.ts +2 -2
  53. package/src/ir-to-client-js/collect-elements.ts +31 -20
  54. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +19 -0
  55. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +22 -3
  56. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  57. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +12 -0
  58. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +9 -0
  59. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +7 -1
  60. package/src/ir-to-client-js/element-refs.ts +8 -0
  61. package/src/ir-to-client-js/emit-reactive.ts +91 -23
  62. package/src/ir-to-client-js/imports.ts +5 -0
  63. package/src/ir-to-client-js/index.ts +4 -0
  64. package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
  65. package/src/ir-to-client-js/types.ts +21 -0
  66. package/src/jsx-to-ir.ts +161 -9
  67. package/src/query-href-lowering.ts +4 -0
  68. package/src/rich-type-refusal.ts +1 -1
  69. package/src/ssr-defaults.ts +51 -2
  70. package/src/ssr-seed-plan.ts +20 -3
  71. package/src/static-literal.ts +15 -1
  72. 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 uses re-claim pattern (#840)', () => {
138
- // When a reactive text is inside a conditional branch inside a nested (inner) loop,
139
- // insert() may replace the SSR element after the text node is captured.
140
- // Slot unification A3: the generated code must re-claim on EVERY run via
141
- // a fresh `claimSlots(...).write(...)` call inside the effect body not
142
- // a `lazySlots` writer built once outside it, which would go stale once
143
- // insert() swaps the branch's DOM (see `stringifyInnerLoops`' docstring
144
- // in `ir-to-client-js/control-flow/stringify/inner-loop.ts`).
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
- // Re-claim pattern: a fresh `claimSlots(...)` call inside createEffect so
180
- // it always resolves against the live node, never a cached stale ref.
181
- expect(content).toMatch(/createEffect\(\(\) => \{ claimSlots\(__innerEl\w*, \[\{ id: 's\d+', kind: 'text', path: \[\] \}\]\)\.write\('s\d+', String\(/)
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
  })