@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.
- 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 +14 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +268 -79
- 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/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +7 -7
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/index.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/prop-handling.d.ts +33 -0
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +5 -0
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/rewrite-props-object.d.ts +36 -8
- package/dist/ir-to-client-js/rewrite-props-object.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/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +23 -6
- package/src/__tests__/binding-scope-ratchet.test.ts +5 -1
- package/src/__tests__/child-components-in-map.test.ts +11 -3
- package/src/__tests__/client-js-generation.test.ts +48 -1
- 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__/issue-2723-prop-alias-reactivity.test.ts +124 -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__/rewrite-props-object.test.ts +41 -4
- package/src/analyzer.ts +71 -0
- package/src/errors.ts +17 -1
- package/src/expression-parser.ts +26 -0
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/collect-elements.ts +49 -45
- 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/emit-registration.ts +26 -7
- package/src/ir-to-client-js/generate-init.ts +1 -1
- package/src/ir-to-client-js/html-template.ts +8 -8
- package/src/ir-to-client-js/imports.ts +5 -0
- package/src/ir-to-client-js/index.ts +10 -4
- package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
- package/src/ir-to-client-js/prop-handling.ts +94 -0
- package/src/ir-to-client-js/reactivity.ts +59 -0
- package/src/ir-to-client-js/rewrite-props-object.ts +50 -10
- package/src/ir-to-client-js/types.ts +21 -0
- package/src/jsx-to-ir.ts +204 -9
- 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
|
|
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
|
+
})
|
|
@@ -60,13 +60,50 @@ describe('rewritePropsObjectRef', () => {
|
|
|
60
60
|
expect(out).toBe('const name = _p.name')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
test('
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// null
|
|
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`, `!`,
|