@barefootjs/jsx 0.33.0 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.js +145 -40
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +8 -0
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +12 -0
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -0
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +21 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +2 -4
- package/src/__tests__/child-components-in-map.test.ts +11 -3
- package/src/__tests__/client-js-generation.test.ts +37 -1
- package/src/__tests__/inline-jsx-callback.test.ts +55 -0
- package/src/__tests__/ir-jsx-props.test.ts +148 -0
- package/src/__tests__/issue-2705-branch-inner-loop-container.test.ts +91 -0
- package/src/__tests__/markup-prop-brand.test.ts +49 -0
- package/src/__tests__/nested-loop-conditional.test.ts +20 -11
- package/src/__tests__/return-through-local-var.test.ts +269 -0
- package/src/analyzer.ts +71 -0
- package/src/errors.ts +17 -1
- package/src/ir-to-client-js/collect-elements.ts +31 -20
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +19 -0
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +22 -3
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
- package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +12 -0
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +9 -0
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +7 -1
- package/src/ir-to-client-js/element-refs.ts +8 -0
- package/src/ir-to-client-js/emit-reactive.ts +91 -23
- package/src/ir-to-client-js/imports.ts +5 -0
- package/src/ir-to-client-js/index.ts +4 -0
- package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
- package/src/ir-to-client-js/types.ts +21 -0
- package/src/jsx-to-ir.ts +157 -8
|
@@ -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
|
+
})
|
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`, `!`,
|
package/src/errors.ts
CHANGED
|
@@ -22,11 +22,24 @@ export const ErrorCodes = {
|
|
|
22
22
|
// Signal/Memo errors (BF011-BF019)
|
|
23
23
|
SIGNAL_OUTSIDE_COMPONENT: 'BF011',
|
|
24
24
|
|
|
25
|
-
// JSX errors (BF021-BF029)
|
|
25
|
+
// JSX errors (BF021-BF029). BF022 was retired (see
|
|
26
|
+
// `invalid-jsx-attribute.audit.test.ts`) and BF026 is reserved by
|
|
27
|
+
// `spec/callback-fidelity.md` for a future `.map()`-callback-shape
|
|
28
|
+
// diagnostic — BF027 is the next free slot.
|
|
26
29
|
UNSUPPORTED_JSX_PATTERN: 'BF021',
|
|
27
30
|
MISSING_KEY_IN_LIST: 'BF023',
|
|
28
31
|
MISSING_KEY_IN_NESTED_LIST: 'BF024',
|
|
29
32
|
UNSUPPORTED_DESTRUCTURE_REST: 'BF025',
|
|
33
|
+
// The component's return statement resolves to a bare identifier that
|
|
34
|
+
// refers to a local `const`/`let` whose initializer IS JSX (or a
|
|
35
|
+
// JSX-shaped ternary/`&&`/`||`/`??`), e.g. `const __root = <div/>; return
|
|
36
|
+
// __root`. JSX-child position resolves such identifiers through
|
|
37
|
+
// `jsxConstants` / `inlineableJsxConsts` (#547 / #1409), but return
|
|
38
|
+
// position deliberately does not (see `transformExpressionInner`'s
|
|
39
|
+
// docstring) — so the dispatcher's scalar-leaf fallback silently produces
|
|
40
|
+
// no IR and no diagnostic (#2720). Loud stopgap until the analyzer learns
|
|
41
|
+
// to resolve the identifier at return position too.
|
|
42
|
+
RETURN_VALUE_NOT_JSX: 'BF027',
|
|
30
43
|
|
|
31
44
|
// Component errors (BF043-BF049)
|
|
32
45
|
PROPS_DESTRUCTURING: 'BF043',
|
|
@@ -152,6 +165,9 @@ const errorMessages: Record<ErrorCode, string> = {
|
|
|
152
165
|
// stable.
|
|
153
166
|
'Computed property key in .map() callback destructure is not supported. Rewrite the callback to destructure explicit bindings (e.g., `({ a, b }) => ...`) so the compiler can rewrite references to per-item signal accessors.',
|
|
154
167
|
|
|
168
|
+
[ErrorCodes.RETURN_VALUE_NOT_JSX]:
|
|
169
|
+
"Component's return value is not recognized as JSX — return the JSX expression directly instead of binding it to a local variable first.",
|
|
170
|
+
|
|
155
171
|
[ErrorCodes.PROPS_DESTRUCTURING]:
|
|
156
172
|
'Props destructuring in function parameters breaks reactivity. Use props object directly.',
|
|
157
173
|
[ErrorCodes.SIGNAL_GETTER_NOT_CALLED]:
|
|
@@ -325,24 +325,47 @@ export function collectInnerLoops(
|
|
|
325
325
|
// param) silently dropped its text-child update effect while the
|
|
326
326
|
// sibling attribute effect (ungated) still fired. Refs need to fire
|
|
327
327
|
// on every renderItem invocation (#1244).
|
|
328
|
-
// - events
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
328
|
+
// - events: only in `collectBindings` (branch) mode; the
|
|
329
|
+
// legacy non-branch path didn't wire them on `NestedLoop`
|
|
330
|
+
// because event delegation handles them through the parent's
|
|
331
|
+
// bindings instead.
|
|
332
|
+
// - conditionals: collected for EVERY inner loop, branch or not
|
|
333
|
+
// (#2706) — see the `stopAtReactiveConditionals: true` note
|
|
334
|
+
// below for why this must not be gated the same way events are.
|
|
332
335
|
const bindings: LoopChildBindings = emptyLoopChildBindings()
|
|
333
336
|
// Hoisted: one Set per loop, not one per child (Copilot review).
|
|
334
337
|
const innerPreambleNames = preambleNamesOf(n)
|
|
335
338
|
if (ctx) {
|
|
336
339
|
for (const child of n.children) {
|
|
337
|
-
|
|
338
|
-
|
|
340
|
+
// `stopAtReactiveConditionals: true` (#2347's parameter, #2706's
|
|
341
|
+
// fix here) — a per-item conditional inside THIS loop's own row
|
|
342
|
+
// now always gets its own `bindings.conditionals` entry (below)
|
|
343
|
+
// and its own `insert()`, regardless of branch/general mode.
|
|
344
|
+
// Descending past it here too (the pre-#2706 default) would
|
|
345
|
+
// double-bind: once via insert()'s bindEvents, once via this
|
|
346
|
+
// flat `insideConditional`-flagged reclaim-on-every-run effect
|
|
347
|
+
// — the latter is also unsound on its own, since nothing
|
|
348
|
+
// guarantees the branch's marker is mounted the moment this
|
|
349
|
+
// effect first runs (issue-2706-nested-loop-conditional-slot
|
|
350
|
+
// .test.ts's original repro).
|
|
351
|
+
bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings, true, innerPreambleNames, n.index))
|
|
352
|
+
bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings, true, innerPreambleNames, n.index))
|
|
339
353
|
bindings.refs.push(...collectLoopChildRefs(child))
|
|
340
354
|
}
|
|
355
|
+
bindings.conditionals.push(...collectLoopChildConditionals(
|
|
356
|
+
{ type: 'fragment', children: n.children, loc: n.loc } as unknown as IRNode,
|
|
357
|
+
ctx,
|
|
358
|
+
siblingOffsets,
|
|
359
|
+
n.param,
|
|
360
|
+
n.paramBindings,
|
|
361
|
+
innerPreambleNames,
|
|
362
|
+
n.index,
|
|
363
|
+
))
|
|
341
364
|
}
|
|
342
365
|
|
|
343
366
|
// Per-item bindings for branch-mode callers (child components,
|
|
344
|
-
// events
|
|
345
|
-
//
|
|
367
|
+
// events) — matches the pre-Phase 2 `collectBranchInnerLoops`
|
|
368
|
+
// behaviour. Conditionals are collected above, uniformly.
|
|
346
369
|
let childComponents: import('../types.ts').IRLoopChildComponent[] | undefined
|
|
347
370
|
if (collectBindings) {
|
|
348
371
|
// skipConditionals=true: components inside conditional branches
|
|
@@ -369,18 +392,6 @@ export function collectInnerLoops(
|
|
|
369
392
|
for (const child of n.children) {
|
|
370
393
|
bindings.events.push(...collectLoopChildEventsWithNesting(child))
|
|
371
394
|
}
|
|
372
|
-
|
|
373
|
-
if (ctx) {
|
|
374
|
-
bindings.conditionals.push(...collectLoopChildConditionals(
|
|
375
|
-
{ type: 'fragment', children: n.children, loc: n.loc } as unknown as IRNode,
|
|
376
|
-
ctx,
|
|
377
|
-
siblingOffsets,
|
|
378
|
-
n.param,
|
|
379
|
-
n.paramBindings,
|
|
380
|
-
innerPreambleNames,
|
|
381
|
-
n.index,
|
|
382
|
-
))
|
|
383
|
-
}
|
|
384
395
|
}
|
|
385
396
|
|
|
386
397
|
result.push({
|
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
} from '../../utils.ts'
|
|
31
31
|
import { buildChildRefBindings, buildStaticChildRefBindings } from '../shared.ts'
|
|
32
32
|
import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
|
|
33
|
+
import { buildLoopChildConditionalsPlan } from './build-loop-child-arm.ts'
|
|
34
|
+
import type { LoopChildConditionalPlan } from './loop-child-arm.ts'
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* Mirror of the helper in `build-loop-child-arm.ts` — kept local to avoid
|
|
@@ -160,6 +162,7 @@ function buildReactiveEmit(
|
|
|
160
162
|
outerLoopParamBindings?: readonly LoopParamBinding[],
|
|
161
163
|
): InnerLoopReactiveEmit {
|
|
162
164
|
const wrapInner = (expr: string) => wrapLoopParamAsAccessor(expr, inner.param, inner.paramBindings)
|
|
165
|
+
const wrapBoth = (expr: string) => wrapLoopParamAsAccessor(wrapOuter(expr), inner.param, inner.paramBindings)
|
|
163
166
|
const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(inner.param, inner.paramBindings)
|
|
164
167
|
const wrappedKey = inner.key
|
|
165
168
|
? wrapLoopParamAsAccessor(inner.key, inner.param, inner.paramBindings)
|
|
@@ -249,6 +252,21 @@ function buildReactiveEmit(
|
|
|
249
252
|
|
|
250
253
|
const childRefs = buildChildRefBindings(inner.bindings.refs, inner.param, inner.paramBindings)
|
|
251
254
|
|
|
255
|
+
// Per-item conditionals inside THIS loop's own row (#2706) — same
|
|
256
|
+
// insert()-parity treatment the top-level loop's row conditionals
|
|
257
|
+
// already get (`buildLoopReactiveEffectsPlan`), now extended to a
|
|
258
|
+
// NESTED loop's row too. `scopeVar` is the row's own element
|
|
259
|
+
// (`__innerEl<uidSuffix>`, matching `stringifyInnerLoops`'s emission),
|
|
260
|
+
// and `wrapBoth` matches every other per-item expression in this emit
|
|
261
|
+
// (outer accessor, then inner accessor).
|
|
262
|
+
const conditionals: LoopChildConditionalPlan[] = buildLoopChildConditionalsPlan({
|
|
263
|
+
conditionals: inner.bindings.conditionals,
|
|
264
|
+
scopeVar: `__innerEl${uidSuffix}`,
|
|
265
|
+
wrap: wrapBoth,
|
|
266
|
+
loopParam: inner.param,
|
|
267
|
+
loopParamBindings: inner.paramBindings,
|
|
268
|
+
})
|
|
269
|
+
|
|
252
270
|
return {
|
|
253
271
|
mode: 'reactive',
|
|
254
272
|
keyFn: loopKeyFn(inner),
|
|
@@ -261,6 +279,7 @@ function buildReactiveEmit(
|
|
|
261
279
|
events,
|
|
262
280
|
reactiveTexts,
|
|
263
281
|
reactiveAttrs,
|
|
282
|
+
conditionals,
|
|
264
283
|
childRefs,
|
|
265
284
|
}
|
|
266
285
|
}
|
|
@@ -190,6 +190,14 @@ export interface BuildBranchInnerLoopsArgs {
|
|
|
190
190
|
innerLoops: readonly NestedLoop[] | undefined
|
|
191
191
|
/** The variable expression naming the parent scope element (e.g. `__branchScope`). */
|
|
192
192
|
scopeVar: string
|
|
193
|
+
/**
|
|
194
|
+
* The enclosing conditional's own slot id — every call site of this
|
|
195
|
+
* builder originates from a conditional branch's arm, so this is always
|
|
196
|
+
* a real id. Used as the `containerExpr` fallback (`findCondContainer`,
|
|
197
|
+
* #2705) for an inner loop whose IR never got a `containerSlotId` of its
|
|
198
|
+
* own (its wrapper element sits outside the branch's IR subtree).
|
|
199
|
+
*/
|
|
200
|
+
condSlotId: string
|
|
193
201
|
/** Outer loop param identifier (the conditional's enclosing loop). */
|
|
194
202
|
outerLoopParam: string
|
|
195
203
|
/** Outer loop param destructuring metadata. */
|
|
@@ -214,6 +222,7 @@ export function buildBranchInnerLoopsPlan(
|
|
|
214
222
|
const {
|
|
215
223
|
innerLoops,
|
|
216
224
|
scopeVar,
|
|
225
|
+
condSlotId,
|
|
217
226
|
outerLoopParam,
|
|
218
227
|
outerLoopParamBindings,
|
|
219
228
|
wrapOuter,
|
|
@@ -230,10 +239,15 @@ export function buildBranchInnerLoopsPlan(
|
|
|
230
239
|
|
|
231
240
|
const csl = inner.containerSlotId
|
|
232
241
|
// Inner loop's container: host-side `bf="<slot>"` slot marker first,
|
|
233
|
-
// then (bf-h, bf-m) when the container is itself a child scope.
|
|
242
|
+
// then (bf-h, bf-m) when the container is itself a child scope. When
|
|
243
|
+
// neither exists — the loop's own IR never got a `containerSlotId`
|
|
244
|
+
// because its wrapper element sits outside the branch's IR subtree
|
|
245
|
+
// (#2705) — resolve via the conditional's OWN comment marker instead
|
|
246
|
+
// of falling back to the whole branch scope, which may be several
|
|
247
|
+
// elements wider than the loop's actual container.
|
|
234
248
|
const containerExpr = csl
|
|
235
249
|
? `(${scopeVar}.querySelector('[bf="${csl}"]') ?? ${scopeVar}.querySelector(\`[${BF_HOST}="\${__scopeId}"][${BF_AT}="${csl}"]\`) ?? ${scopeVar})`
|
|
236
|
-
: scopeVar
|
|
250
|
+
: `findCondContainer(${scopeVar}, '${condSlotId}')`
|
|
237
251
|
|
|
238
252
|
const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(inner.param, inner.paramBindings)
|
|
239
253
|
const wrappedKey = inner.key
|
|
@@ -372,12 +386,14 @@ export function buildLoopChildConditionalsPlan(
|
|
|
372
386
|
wrap,
|
|
373
387
|
loopParam,
|
|
374
388
|
loopParamBindings,
|
|
389
|
+
condId: cond.slotId,
|
|
375
390
|
}),
|
|
376
391
|
whenFalseArm: buildLoopChildArmPlan({
|
|
377
392
|
branch: cond.whenFalse,
|
|
378
393
|
wrap,
|
|
379
394
|
loopParam,
|
|
380
395
|
loopParamBindings,
|
|
396
|
+
condId: cond.slotId,
|
|
381
397
|
}),
|
|
382
398
|
})
|
|
383
399
|
}
|
|
@@ -439,10 +455,12 @@ interface BuildLoopChildArmArgs {
|
|
|
439
455
|
wrap: (expr: string) => string
|
|
440
456
|
loopParam: string
|
|
441
457
|
loopParamBindings?: readonly LoopParamBinding[]
|
|
458
|
+
/** The enclosing conditional's own slot id — threaded to `buildBranchInnerLoopsPlan`'s `condSlotId` (#2705). */
|
|
459
|
+
condId: string
|
|
442
460
|
}
|
|
443
461
|
|
|
444
462
|
function buildLoopChildArmPlan(args: BuildLoopChildArmArgs): LoopChildArmPlan {
|
|
445
|
-
const { branch, wrap, loopParam, loopParamBindings } = args
|
|
463
|
+
const { branch, wrap, loopParam, loopParamBindings, condId } = args
|
|
446
464
|
return {
|
|
447
465
|
events: buildBranchEventBindingsPlan({
|
|
448
466
|
events: branch.events,
|
|
@@ -455,6 +473,7 @@ function buildLoopChildArmPlan(args: BuildLoopChildArmArgs): LoopChildArmPlan {
|
|
|
455
473
|
innerLoops: buildBranchInnerLoopsPlan({
|
|
456
474
|
innerLoops: branch.innerLoops,
|
|
457
475
|
scopeVar: '__branchScope',
|
|
476
|
+
condSlotId: condId,
|
|
458
477
|
outerLoopParam: loopParam,
|
|
459
478
|
outerLoopParamBindings: loopParamBindings,
|
|
460
479
|
wrapOuter: wrap,
|
|
@@ -110,8 +110,8 @@ export function buildReactiveEffectsPlan(
|
|
|
110
110
|
wrappedCondition: wrap(cond.condition),
|
|
111
111
|
whenTrueTemplateHtml: addCondAttrToTemplate(wrap(cond.whenTrueHtml), cond.slotId),
|
|
112
112
|
whenFalseTemplateHtml: addCondAttrToTemplate(wrap(cond.whenFalseHtml), cond.slotId),
|
|
113
|
-
whenTrueArm: buildOuterArm(cond.whenTrue, wrap, loopParam, loopParamBindings, profileComponentName),
|
|
114
|
-
whenFalseArm: buildOuterArm(cond.whenFalse, wrap, loopParam, loopParamBindings, profileComponentName),
|
|
113
|
+
whenTrueArm: buildOuterArm(cond.whenTrue, wrap, loopParam, loopParamBindings, cond.slotId, profileComponentName),
|
|
114
|
+
whenFalseArm: buildOuterArm(cond.whenFalse, wrap, loopParam, loopParamBindings, cond.slotId, profileComponentName),
|
|
115
115
|
...(cond.readsPreamble && { readsPreamble: true }),
|
|
116
116
|
})
|
|
117
117
|
}
|
|
@@ -130,6 +130,8 @@ function buildOuterArm(
|
|
|
130
130
|
wrap: (expr: string) => string,
|
|
131
131
|
loopParam: string,
|
|
132
132
|
loopParamBindings: readonly LoopParamBinding[] | undefined,
|
|
133
|
+
/** The conditional's own slot id — threaded to `buildBranchInnerLoopsPlan`'s `condSlotId` (#2705). */
|
|
134
|
+
condSlotId: string,
|
|
133
135
|
profileComponentName?: string,
|
|
134
136
|
): LoopChildArmPlan {
|
|
135
137
|
return {
|
|
@@ -145,6 +147,7 @@ function buildOuterArm(
|
|
|
145
147
|
innerLoops: buildBranchInnerLoopsPlan({
|
|
146
148
|
innerLoops: branch.innerLoops,
|
|
147
149
|
scopeVar: '__branchScope',
|
|
150
|
+
condSlotId,
|
|
148
151
|
outerLoopParam: loopParam,
|
|
149
152
|
outerLoopParamBindings: loopParamBindings,
|
|
150
153
|
wrapOuter: wrap,
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
LoopParamBinding,
|
|
18
18
|
} from '../../../types.ts'
|
|
19
19
|
import type { LoopChildRefBinding } from './loop.ts'
|
|
20
|
+
import type { LoopChildConditionalPlan } from './loop-child-arm.ts'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Body-entry statements emitted in order at the top of a `mapArray`
|
|
@@ -148,6 +149,17 @@ export interface InnerLoopReactiveEmit {
|
|
|
148
149
|
reactiveTexts: readonly InnerLoopText[]
|
|
149
150
|
/** Pre-wrapped reactive attribute effects for the inner-item body. */
|
|
150
151
|
reactiveAttrs: readonly InnerLoopReactiveAttr[]
|
|
152
|
+
/**
|
|
153
|
+
* Per-item conditionals inside THIS loop's own row (#2706) — each gets
|
|
154
|
+
* its own `insert()` call, the same insert()-parity the top-level loop's
|
|
155
|
+
* row conditionals already have (`ReactiveEffectsPlan.conditionals`).
|
|
156
|
+
* Before this field existed, a per-item conditional inside a nested
|
|
157
|
+
* loop's row was baked into the static row template ONCE at row
|
|
158
|
+
* creation and never revisited — silently frozen against later signal
|
|
159
|
+
* changes — while its reactive text still (unsoundly) assumed insert()
|
|
160
|
+
* kept the branch's marker around.
|
|
161
|
+
*/
|
|
162
|
+
conditionals: readonly LoopChildConditionalPlan[]
|
|
151
163
|
/** Pre-wrapped imperative ref callbacks for the inner-item body (#1244). */
|
|
152
164
|
childRefs: readonly LoopChildRefBinding[]
|
|
153
165
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* <indent> emitComponentAndEventSetup(...)
|
|
15
15
|
* <indent> recurse on childLevels
|
|
16
16
|
* <indent> reactive text effects
|
|
17
|
+
* <indent> per-item conditionals: insert() over __innerEl<uid> (#2706)
|
|
17
18
|
* <indent> return __innerEl<uid>
|
|
18
19
|
* <indent>}) }
|
|
19
20
|
*
|
|
@@ -36,6 +37,7 @@ import { emitAttrUpdate } from '../../emit-reactive.ts'
|
|
|
36
37
|
import { emitMultiRootTemplateCloneLines, namespaceWrapForTemplate } from './template-parse.ts'
|
|
37
38
|
import { emitLoopChildRefs } from './loop.ts'
|
|
38
39
|
import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
|
|
40
|
+
import { stringifyLoopChildConditionals } from './loop-child-arm.ts'
|
|
39
41
|
import type {
|
|
40
42
|
InnerLoopPlan,
|
|
41
43
|
InnerLoopsPlan,
|
|
@@ -139,6 +141,13 @@ function emitReactive(lines: string[], inner: InnerLoopPlan, indent: string, pc:
|
|
|
139
141
|
}
|
|
140
142
|
lines.push(`${indent} }${profileBindingId(pc, attr.slotId)}) }`)
|
|
141
143
|
}
|
|
144
|
+
// Per-item conditionals inside THIS loop's own row (#2706) — each is a
|
|
145
|
+
// real `insert()` over `__innerEl<uid>`, not a bake-once-at-creation
|
|
146
|
+
// ternary. Mirrors `stringifyBranchInnerLoops`'s identical call for a
|
|
147
|
+
// branch-scoped inner loop's own conditionals.
|
|
148
|
+
if (emit.conditionals.length > 0) {
|
|
149
|
+
stringifyLoopChildConditionals(lines, emit.conditionals, `${indent} `, pc)
|
|
150
|
+
}
|
|
142
151
|
// Imperative ref callbacks fire on every renderItem invocation, which
|
|
143
152
|
// means every mount: SSR hydration, initial CSR creation, and same-key
|
|
144
153
|
// remount after unmount (#1244).
|