@barefootjs/jsx 0.33.0 → 0.33.2

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 (75) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/errors.d.ts +1 -0
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/expression-parser.d.ts +14 -0
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +268 -79
  9. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +8 -0
  12. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +12 -0
  15. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -0
  17. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/html-template.d.ts +7 -7
  23. package/dist/ir-to-client-js/html-template.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/index.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/prop-handling.d.ts +33 -0
  29. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/reactivity.d.ts +5 -0
  31. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/rewrite-props-object.d.ts +36 -8
  33. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  34. package/dist/ir-to-client-js/types.d.ts +21 -0
  35. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  36. package/dist/types.d.ts +14 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +23 -6
  40. package/src/__tests__/binding-scope-ratchet.test.ts +5 -1
  41. package/src/__tests__/child-components-in-map.test.ts +11 -3
  42. package/src/__tests__/client-js-generation.test.ts +48 -1
  43. package/src/__tests__/inline-jsx-callback.test.ts +55 -0
  44. package/src/__tests__/ir-jsx-props.test.ts +148 -0
  45. package/src/__tests__/issue-2705-branch-inner-loop-container.test.ts +91 -0
  46. package/src/__tests__/issue-2723-prop-alias-reactivity.test.ts +124 -0
  47. package/src/__tests__/markup-prop-brand.test.ts +49 -0
  48. package/src/__tests__/nested-loop-conditional.test.ts +20 -11
  49. package/src/__tests__/return-through-local-var.test.ts +269 -0
  50. package/src/__tests__/rewrite-props-object.test.ts +41 -4
  51. package/src/analyzer.ts +71 -0
  52. package/src/errors.ts +17 -1
  53. package/src/expression-parser.ts +26 -0
  54. package/src/index.ts +1 -1
  55. package/src/ir-to-client-js/collect-elements.ts +49 -45
  56. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +19 -0
  57. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +22 -3
  58. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  59. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +12 -0
  60. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +9 -0
  61. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +7 -1
  62. package/src/ir-to-client-js/element-refs.ts +8 -0
  63. package/src/ir-to-client-js/emit-reactive.ts +91 -23
  64. package/src/ir-to-client-js/emit-registration.ts +26 -7
  65. package/src/ir-to-client-js/generate-init.ts +1 -1
  66. package/src/ir-to-client-js/html-template.ts +8 -8
  67. package/src/ir-to-client-js/imports.ts +5 -0
  68. package/src/ir-to-client-js/index.ts +10 -4
  69. package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
  70. package/src/ir-to-client-js/prop-handling.ts +94 -0
  71. package/src/ir-to-client-js/reactivity.ts +59 -0
  72. package/src/ir-to-client-js/rewrite-props-object.ts +50 -10
  73. package/src/ir-to-client-js/types.ts +21 -0
  74. package/src/jsx-to-ir.ts +204 -9
  75. package/src/types.ts +14 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Regression pin for #2723.
3
+ *
4
+ * A semantically-inert `const x__alias = x` hop between a destructured
5
+ * prop and its use site (exactly what the `alias-props` mutation sweep
6
+ * inserts, #2481) silently dropped the attribute's `createEffect` and, in
7
+ * the rest-spread case, its `applyRestAttrs` call too — collapsing `init`
8
+ * to `function initL() {}` whenever the effect was its only content.
9
+ *
10
+ * Five variants isolate the two independent defects the fix addresses:
11
+ * - A: no alias at all (control — everything present).
12
+ * - B: every destructured binding aliased, INCLUDING the rest
13
+ * parameter (`const rest__alias = rest`) — the shape the real
14
+ * `alias-props` mutation produces.
15
+ * - C: only the prop feeding the reactive attribute is aliased; the
16
+ * rest parameter is spread un-aliased.
17
+ * - D: aliased, but with NO rest spread at all — proves the defect is
18
+ * not spread-handling-specific.
19
+ * - E: aliased (rest included) AND an event handler is present, so
20
+ * `init` is non-empty regardless of the effect. This is the case a
21
+ * fix aimed only at "don't emit an empty init" would still leave
22
+ * broken: the `createEffect` silently disappears WITHOUT collapsing
23
+ * the function, so an empty-init check alone can't catch it.
24
+ */
25
+ import { describe, test, expect } from 'bun:test'
26
+ import { compileJSX } from '../compiler'
27
+ import { TestAdapter } from '../adapters/test-adapter'
28
+
29
+ const adapter = new TestAdapter()
30
+
31
+ function compileInit(source: string): string {
32
+ const result = compileJSX(source, 'L.tsx', { adapter })
33
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
34
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
35
+ return clientJs
36
+ }
37
+
38
+ describe('#2723 — prop alias hop must not drop attribute reactivity', () => {
39
+ test('A: no alias — createEffect and applyRestAttrs both present (control)', () => {
40
+ const clientJs = compileInit(`
41
+ "use client";
42
+ const BASE = 'flex'
43
+ type LProps = { className?: string; children?: any }
44
+ export function L({ className = '', children, ...rest }: LProps) {
45
+ return <label className={\`\${BASE} \${className}\`} {...rest}>{children}</label>
46
+ }
47
+ `)
48
+ expect(clientJs).toContain('createEffect(')
49
+ expect(clientJs).toContain('applyRestAttrs(')
50
+ expect(clientJs).not.toContain('function initL() {}')
51
+ })
52
+
53
+ test('B: every binding aliased, rest included — createEffect and applyRestAttrs survive', () => {
54
+ const clientJs = compileInit(`
55
+ "use client";
56
+ const BASE = 'flex'
57
+ type LProps = { className?: string; children?: any }
58
+ export function L({ className = '', children, ...rest }: LProps) {
59
+ const className__alias = className
60
+ const children__alias = children
61
+ const rest__alias = rest
62
+ return <label className={\`\${BASE} \${className__alias}\`} {...rest__alias}>{children__alias}</label>
63
+ }
64
+ `)
65
+ expect(clientJs).toContain('createEffect(')
66
+ expect(clientJs).toContain('applyRestAttrs(')
67
+ expect(clientJs).not.toContain('function initL() {}')
68
+ // The rest-parameter alias must resolve to the runtime props object,
69
+ // not to the never-declared source-level rest binding.
70
+ expect(clientJs).toContain('const rest__alias = _p')
71
+ expect(clientJs).not.toMatch(/const rest__alias = rest\b/)
72
+ })
73
+
74
+ test('C: only the reactive prop is aliased, rest is spread un-aliased', () => {
75
+ const clientJs = compileInit(`
76
+ "use client";
77
+ const BASE = 'flex'
78
+ type LProps = { className?: string; children?: any }
79
+ export function L({ className = '', children, ...rest }: LProps) {
80
+ const className__alias = className
81
+ return <label className={\`\${BASE} \${className__alias}\`} {...rest}>{children}</label>
82
+ }
83
+ `)
84
+ expect(clientJs).toContain('createEffect(')
85
+ expect(clientJs).toContain('applyRestAttrs(')
86
+ expect(clientJs).not.toContain('function initL() {}')
87
+ })
88
+
89
+ test('D: aliased with NO rest spread — not a spread-handling bug', () => {
90
+ const clientJs = compileInit(`
91
+ "use client";
92
+ const BASE = 'flex'
93
+ type LProps = { className?: string; children?: any }
94
+ export function L({ className = '', children }: LProps) {
95
+ const className__alias = className
96
+ return <label className={\`\${BASE} \${className__alias}\`}>{children}</label>
97
+ }
98
+ `)
99
+ expect(clientJs).toContain('createEffect(')
100
+ expect(clientJs).not.toContain('applyRestAttrs(')
101
+ expect(clientJs).not.toContain('function initL() {}')
102
+ })
103
+
104
+ test('E: aliased + event handler — createEffect must survive even though init is already non-empty', () => {
105
+ const clientJs = compileInit(`
106
+ "use client";
107
+ const BASE = 'flex'
108
+ type LProps = { className?: string; children?: any; onClick?: () => void }
109
+ export function L({ className = '', children, onClick, ...rest }: LProps) {
110
+ const className__alias = className
111
+ const rest__alias = rest
112
+ return <label className={\`\${BASE} \${className__alias}\`} onClick={onClick} {...rest__alias}>{children}</label>
113
+ }
114
+ `)
115
+ // A fix aimed only at "init must not be empty" would pass this
116
+ // assertion for free (the handler alone keeps init non-empty) while
117
+ // leaving the class binding frozen at its initial value — the
118
+ // `createEffect` assertion is the one that actually pins the fix.
119
+ expect(clientJs).not.toContain('function initL() {}')
120
+ expect(clientJs).toContain('createEffect(')
121
+ expect(clientJs).toContain('applyRestAttrs(')
122
+ expect(clientJs).toContain("addEventListener('click'")
123
+ })
124
+ })
@@ -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
+ })
@@ -60,13 +60,50 @@ describe('rewritePropsObjectRef', () => {
60
60
  expect(out).toBe('const name = _p.name')
61
61
  })
62
62
 
63
- test('no-op when propsObjectName is null (destructured-props mode)', () => {
64
- const code = 'const x = props.name'
65
- const out = rewritePropsObjectRef(code, null)
66
- // null defaults to 'props'; rewritten.
63
+ test('still rewrites a literal `props` read when propsObjectName is null', () => {
64
+ // The `propsObjectName ?? 'props'` fallback survives the #2723 work.
65
+ // A destructured component can still write `props.itemId` inside a
66
+ // handler body — `propsObjectName` is null for that shape, so without
67
+ // the fallback the emitted init keeps a `props` reference that no
68
+ // binding satisfies (the doc-example `StatementExample` regressed
69
+ // exactly this way before the fallback was restored).
70
+ const out = rewritePropsObjectRef('const x = props.name', null)
67
71
  expect(out).toBe('const x = _p.name')
68
72
  })
69
73
 
74
+ test('rewrites via restPropsName when propsObjectName is null (#2723)', () => {
75
+ // The destructured-props shape this exists for: `function F({ a,
76
+ // ...rest })` has no `propsObjectName` at all, but a `const
77
+ // rest__alias = rest` hop (or any other bare read of the rest
78
+ // binding) still needs `rest` rewritten to `_p`.
79
+ const out = rewritePropsObjectRef('const restAlias = rest', null, 'rest')
80
+ expect(out).toBe('const restAlias = _p')
81
+ })
82
+
83
+ test('restPropsName rewrite is not tied to the literal name "props"', () => {
84
+ // Regression guard for the bug the widened fallback replaced: a rest
85
+ // binding named anything OTHER than "props" left the reference
86
+ // dangling (a runtime ReferenceError) because the old fallback only
87
+ // ever guessed the literal word "props".
88
+ const out = rewritePropsObjectRef('const leftoverAlias = leftover', null, 'leftover')
89
+ expect(out).toBe('const leftoverAlias = _p')
90
+ })
91
+
92
+ test('rewrites BOTH propsObjectName and restPropsName when both are set', () => {
93
+ // A `(props)`-arg component that ALSO body-destructures a rest
94
+ // binding out of it (`const { a, ...rest } = props`) has both names
95
+ // live in the same init body.
96
+ const out = rewritePropsObjectRef('const x = props.a + rest.b', 'props', 'rest')
97
+ expect(out).toBe('const x = _p.a + _p.b')
98
+ })
99
+
100
+ test('restPropsName defaulting to null preserves the two-arg call shape', () => {
101
+ // Existing call sites that only ever passed `propsObjectName` (the
102
+ // pre-#2723 signature) must keep working unchanged.
103
+ const out = rewritePropsObjectRef('const name = props.name', 'props')
104
+ expect(out).toBe('const name = _p.name')
105
+ })
106
+
70
107
  test('no-op when propsObjectName equals _p', () => {
71
108
  const code = 'const x = _p.name'
72
109
  const out = rewritePropsObjectRef(code, '_p')
package/src/analyzer.ts CHANGED
@@ -796,6 +796,29 @@ function visitComponentBody(node: ts.Node, ctx: AnalyzerContext): void {
796
796
  (ts.isBlock(node) && node.parent === ctx.componentBodyBlock)
797
797
  )
798
798
  ) {
799
+ // #2720: a bare top-level block whose ONLY job is naming the render
800
+ // value before returning it (`{ const __root = <jsx/>; return __root
801
+ // }`) would otherwise be swallowed whole by the opaque-block
802
+ // preservation above — this walk never recurses into it, so neither
803
+ // `jsxConstants` nor `jsxReturn` ever get set and the component
804
+ // silently produces zero files, zero diagnostics. Detect the shape
805
+ // before preserving it and report loudly instead.
806
+ if (ts.isBlock(node)) {
807
+ const returnedLocal = findBlockBodyReturnedJsxLocalName(node)
808
+ if (returnedLocal) {
809
+ ctx.errors.push(createError(
810
+ ErrorCodes.RETURN_VALUE_NOT_JSX,
811
+ getSourceLocation(node, ctx.sourceFile, ctx.filePath),
812
+ {
813
+ message:
814
+ `Component '${ctx.componentName ?? '(unknown)'}' return value is not recognized ` +
815
+ `as JSX — return the JSX expression directly instead of binding it to a local ` +
816
+ `variable first (\`return ${returnedLocal}\` after \`const ${returnedLocal} = ` +
817
+ `<jsx/>\` is not resolved at return position).`,
818
+ },
819
+ ))
820
+ }
821
+ }
799
822
  collectInitStatement(node, ctx)
800
823
  return
801
824
  }
@@ -886,6 +909,54 @@ export function unwrapJsxTransparent(expr: ts.Expression): ts.Expression {
886
909
  return current
887
910
  }
888
911
 
912
+ /**
913
+ * BF027 (#2720) shape detector: a block whose last statement returns a
914
+ * bare identifier, where some earlier statement in the SAME block declares
915
+ * that identifier as a `const`/`let` initialized to JSX (root JSX, or JSX
916
+ * nested in a ternary/`&&`/`||`/`??`) — `{ const __root = <jsx/>; return
917
+ * __root }`. Mirrors the same two "does this initializer hold JSX" checks
918
+ * `collectConstant` uses to populate `jsxConstants` / `inlineableJsxConsts`
919
+ * for ordinary top-level locals, applied here to a nested block that would
920
+ * otherwise never be walked (it is preserved whole as an opaque init
921
+ * statement, see #930). Returns the identifier's name on a match, else
922
+ * null — deliberately narrow (exact "name, then return that name" shape)
923
+ * so an ordinary block scoping unrelated imperative logic is untouched.
924
+ */
925
+ function findBlockBodyReturnedJsxLocalName(block: ts.Block): string | null {
926
+ const stmts = block.statements
927
+ const last = stmts[stmts.length - 1]
928
+ if (!last || !ts.isReturnStatement(last) || !last.expression) return null
929
+ const returned = unwrapJsxTransparent(last.expression)
930
+ if (!ts.isIdentifier(returned)) return null
931
+ const name = returned.text
932
+
933
+ for (const stmt of stmts) {
934
+ if (!ts.isVariableStatement(stmt)) continue
935
+ for (const decl of stmt.declarationList.declarations) {
936
+ if (!ts.isIdentifier(decl.name) || decl.name.text !== name || !decl.initializer) continue
937
+ let init: ts.Expression = decl.initializer
938
+ while (ts.isParenthesizedExpression(init)) init = init.expression
939
+ if (
940
+ ts.isJsxElement(init) ||
941
+ ts.isJsxSelfClosingElement(init) ||
942
+ ts.isJsxFragment(init) ||
943
+ initializerShapeContainsJsx(init) ||
944
+ // `initializerShapeContainsJsx` deliberately stops at arrow
945
+ // boundaries, so a `.map()`/`.flatMap()` whose CALLBACK returns JSX
946
+ // needs the same dedicated check `collectConstant` uses to admit
947
+ // that shape into `inlineableJsxConsts` (#1554) — without it,
948
+ // `{ const __root = items.map(i => <div/>); return __root }` slips
949
+ // past BF027 back into the silent-drop path (Copilot review on
950
+ // #2726).
951
+ isMapLikeCallWithJsx(init)
952
+ ) {
953
+ return name
954
+ }
955
+ }
956
+ }
957
+ return null
958
+ }
959
+
889
960
  /**
890
961
  * Extract JSX element from an expression, handling parenthesized
891
962
  * expressions and TS type-only wrappers (`as`, `satisfies`, `!`,