@barefootjs/jsx 0.33.4 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/child-scope.d.ts +13 -0
- package/dist/adapters/child-scope.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +17 -0
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +17 -4
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1399 -1173
- package/dist/ir-to-client-js/child-components.d.ts.map +1 -1
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/component-scope.d.ts +20 -0
- package/dist/ir-to-client-js/component-scope.d.ts.map +1 -1
- package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts +4 -3
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts +35 -9
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts +10 -0
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +49 -2
- 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/rewrite-props-object.d.ts +12 -1
- package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/lowering-registry.d.ts +12 -0
- package/dist/lowering-registry.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +5 -2
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/props-binding.d.ts +49 -11
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/types.d.ts +6 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +15 -15
- package/src/__tests__/aliased-import-child-registry-2777.test.ts +112 -0
- package/src/__tests__/date-lowering.test.ts +18 -4
- package/src/__tests__/inner-loop-mathml-namespace.test.ts +1 -1
- package/src/__tests__/inner-loop-svg-namespace.test.ts +4 -4
- package/src/__tests__/issue-2724-prop-alias-loop-not-static.test.ts +355 -0
- package/src/__tests__/issue-2737-props-object-alias-template.test.ts +86 -0
- package/src/__tests__/issue-2756-input-skeleton-bails.test.ts +123 -0
- package/src/__tests__/issue-2778-getter-alias-csr-template.test.ts +113 -0
- package/src/__tests__/issue-2806-rest-bag-template-rewrite.test.ts +136 -0
- package/src/__tests__/issue-2835-props-type-member-name-collision.test.ts +259 -0
- package/src/__tests__/loop-item-root-scope.test.ts +7 -3
- package/src/__tests__/lowering-value-position.test.ts +232 -0
- package/src/__tests__/markup-prop-brand.test.ts +165 -26
- package/src/__tests__/namespace-import-primitive.test.ts +244 -0
- package/src/__tests__/rewrite-destructured-props.test.ts +53 -0
- package/src/__tests__/static-loop-csr-materialize.test.ts +2 -2
- package/src/adapters/child-scope.ts +23 -0
- package/src/adapters/parsed-expr-emitter.ts +43 -0
- package/src/analyzer.ts +130 -0
- package/src/errors.ts +14 -1
- package/src/expression-parser.ts +63 -29
- package/src/index.ts +9 -1
- package/src/ir-to-client-js/child-components.ts +8 -1
- package/src/ir-to-client-js/collect-elements.ts +10 -38
- package/src/ir-to-client-js/component-scope.ts +60 -0
- package/src/ir-to-client-js/compute-inlinability.ts +46 -11
- package/src/ir-to-client-js/control-flow/shared.ts +12 -5
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +8 -2
- package/src/ir-to-client-js/csr-substitute.ts +82 -16
- package/src/ir-to-client-js/emit-reactive.ts +29 -13
- package/src/ir-to-client-js/emit-registration.ts +1 -1
- package/src/ir-to-client-js/html-template.ts +141 -70
- package/src/ir-to-client-js/imports.ts +4 -0
- package/src/ir-to-client-js/index.ts +34 -23
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +7 -2
- package/src/ir-to-client-js/rewrite-props-object.ts +14 -0
- package/src/jsx-to-ir.ts +200 -42
- package/src/lowering-registry.ts +21 -0
- package/src/prop-rewrite.ts +23 -5
- package/src/props-binding.ts +74 -18
- package/src/types.ts +6 -5
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2724 — a bare `const x = y` alias hop between a prop and a keyed
|
|
3
|
+
* `.map()` of child components made the loop's array look, to
|
|
4
|
+
* `isArrayExprDirectPropRef` (jsx-to-ir.ts), like a local constant with no
|
|
5
|
+
* prop/signal origin. The loop was then classified `isStaticArray: true`
|
|
6
|
+
* and compiled to the static `qsaChildScopes` + `forEach` init path instead
|
|
7
|
+
* of `mapArray`. That static path's `renderChild()` calls (client runtime)
|
|
8
|
+
* carry no `bf-h`/`bf-m` scope-relationship attributes on a CSR-only mount
|
|
9
|
+
* (no existing SSR markup to hydrate against), so the `qsaChildScopes`
|
|
10
|
+
* selector in the static init never matches — the row's child component
|
|
11
|
+
* never gets `initChild`ed, so its signals and event listeners never wire
|
|
12
|
+
* up. Found by the #2481 mutation sweep's `alias-props` mutation against
|
|
13
|
+
* the `toggle-shared` fixture (`packages/adapter-tests/e2e/
|
|
14
|
+
* mutation-quarantine.ts`'s former G4 entry).
|
|
15
|
+
*
|
|
16
|
+
* Fix: `isArrayExprDirectPropRef` now resolves a bare identifier (or, for a
|
|
17
|
+
* `props.<key>`-shaped array, the property access's OBJECT) through a
|
|
18
|
+
* `const x = y` alias-hop chain via `resolveAliasOrigin` (`props-binding.ts`)
|
|
19
|
+
* — the same shared walker `forwardsCallerRestProps` already uses for the
|
|
20
|
+
* rest/props-spread alias case — checking, at each hop, whether the name is
|
|
21
|
+
* itself a destructured prop OR a local constant whose `.parsed` shape is a
|
|
22
|
+
* bare `<propsObjName>.<key>` member access. Deliberately structural
|
|
23
|
+
* (AST-derived), not the regex-based `isPropsReference`: this return value
|
|
24
|
+
* also feeds `IRLoop.isPropDerivedArray`, which the Go adapter reads to
|
|
25
|
+
* decide whether a nested component's field is literally the loop's
|
|
26
|
+
* prop-sourced data, so a regex false positive here would misclassify
|
|
27
|
+
* DSL-adapter codegen too, not just add a redundant `mapArray`.
|
|
28
|
+
*
|
|
29
|
+
* A design review of the initial fix (before it merged) found the
|
|
30
|
+
* unrestricted alias-hop walk introduced two NEW false positives of its
|
|
31
|
+
* own — both covered below (the "review finding" tests) — plus one gap in
|
|
32
|
+
* the same bug class the initial fix didn't reach (the destructure-rename
|
|
33
|
+
* test) and one pre-existing, out-of-scope imprecision fixed separately by
|
|
34
|
+
* #2835 (see the comment at the bottom).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { describe, test, expect } from 'bun:test'
|
|
38
|
+
import { compileJSX } from '../compiler'
|
|
39
|
+
import { analyzeComponent } from '../analyzer'
|
|
40
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
41
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
42
|
+
import type { IRNode } from '../types'
|
|
43
|
+
|
|
44
|
+
const adapter = new TestAdapter()
|
|
45
|
+
|
|
46
|
+
function getClientJs(source: string, filename: string): string {
|
|
47
|
+
const result = compileJSX(source, filename, { adapter })
|
|
48
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
49
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
50
|
+
expect(clientJs).toBeDefined()
|
|
51
|
+
return clientJs!.content
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** First `IRLoop` node found in a DFS of the component IR. */
|
|
55
|
+
function findLoop(node: IRNode | null, predicate: (n: any) => boolean = () => true): any {
|
|
56
|
+
if (!node || typeof node !== 'object') return undefined
|
|
57
|
+
if ((node as any).type === 'loop' && predicate(node)) return node
|
|
58
|
+
for (const key of Object.keys(node)) {
|
|
59
|
+
const v = (node as any)[key]
|
|
60
|
+
if (Array.isArray(v)) {
|
|
61
|
+
for (const c of v) {
|
|
62
|
+
const found = findLoop(c, predicate)
|
|
63
|
+
if (found) return found
|
|
64
|
+
}
|
|
65
|
+
} else if (v && typeof v === 'object') {
|
|
66
|
+
const found = findLoop(v, predicate)
|
|
67
|
+
if (found) return found
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loopFlags(source: string, filename: string, predicate?: (n: any) => boolean) {
|
|
74
|
+
const ctx = analyzeComponent(source, filename, 'List')
|
|
75
|
+
const ir = jsxToIR(ctx)
|
|
76
|
+
expect(ir).not.toBeNull()
|
|
77
|
+
const loop = findLoop(ir, predicate)
|
|
78
|
+
expect(loop).toBeDefined()
|
|
79
|
+
return { isStaticArray: loop.isStaticArray as boolean, isPropDerivedArray: loop.isPropDerivedArray as boolean | undefined }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const CHILD = `
|
|
83
|
+
type ItemProps = { label: string }
|
|
84
|
+
function Item(props: ItemProps) {
|
|
85
|
+
const [on, setOn] = createSignal(false)
|
|
86
|
+
return <button onClick={() => setOn(!on())}>{props.label}: {on() ? 'ON' : 'OFF'}</button>
|
|
87
|
+
}
|
|
88
|
+
`
|
|
89
|
+
|
|
90
|
+
describe('#2724 — prop-alias keyed loop stays on mapArray, not the static path', () => {
|
|
91
|
+
test('one-hop alias of a destructured prop uses mapArray', () => {
|
|
92
|
+
const clientJs = getClientJs(`
|
|
93
|
+
'use client'
|
|
94
|
+
import { createSignal } from '@barefootjs/client'
|
|
95
|
+
${CHILD}
|
|
96
|
+
type Props = { items: ItemProps[] }
|
|
97
|
+
export function List({ items }: Props) {
|
|
98
|
+
const items__alias = items
|
|
99
|
+
return <div>{items__alias.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
100
|
+
}
|
|
101
|
+
`, 'AliasHop.tsx')
|
|
102
|
+
expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
|
|
103
|
+
expect(clientJs).not.toMatch(/qsaChildScopes\(/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('local const derived from a whole-props member access uses mapArray', () => {
|
|
107
|
+
const clientJs = getClientJs(`
|
|
108
|
+
'use client'
|
|
109
|
+
import { createSignal } from '@barefootjs/client'
|
|
110
|
+
${CHILD}
|
|
111
|
+
type Props = { items: ItemProps[] }
|
|
112
|
+
export function List(props: Props) {
|
|
113
|
+
const arr = props.items
|
|
114
|
+
return <div>{arr.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
115
|
+
}
|
|
116
|
+
`, 'MemberAlias.tsx')
|
|
117
|
+
expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
|
|
118
|
+
expect(clientJs).not.toMatch(/qsaChildScopes\(/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('two-hop alias chain uses mapArray', () => {
|
|
122
|
+
const clientJs = getClientJs(`
|
|
123
|
+
'use client'
|
|
124
|
+
import { createSignal } from '@barefootjs/client'
|
|
125
|
+
${CHILD}
|
|
126
|
+
type Props = { items: ItemProps[] }
|
|
127
|
+
export function List({ items }: Props) {
|
|
128
|
+
const a1 = items
|
|
129
|
+
const a2 = a1
|
|
130
|
+
return <div>{a2.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
131
|
+
}
|
|
132
|
+
`, 'TwoHop.tsx')
|
|
133
|
+
expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
|
|
134
|
+
expect(clientJs).not.toMatch(/qsaChildScopes\(/)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('an alias of the WHOLE props object, accessed via property access, uses mapArray', () => {
|
|
138
|
+
// `const p = props; p.items.map(...)` — the same shape
|
|
139
|
+
// `packages/adapter-tests/mutation/mutations.ts`'s `alias-props`
|
|
140
|
+
// mutation produces for a `(props)`-arg component. Unlike a bare-
|
|
141
|
+
// identifier alias, this shape isn't caught by the Go adapter's BF101
|
|
142
|
+
// gate either (that gate only matches bare-identifier array
|
|
143
|
+
// expressions), so before this fix it stayed silently misclassified
|
|
144
|
+
// as static instead of erroring loudly.
|
|
145
|
+
const flags = loopFlags(`
|
|
146
|
+
'use client'
|
|
147
|
+
${CHILD}
|
|
148
|
+
type Props = { items: ItemProps[] }
|
|
149
|
+
export function List(props: Props) {
|
|
150
|
+
const p = props
|
|
151
|
+
return <div>{p.items.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
152
|
+
}
|
|
153
|
+
`, 'ObjectAlias.tsx')
|
|
154
|
+
expect(flags.isStaticArray).toBe(false)
|
|
155
|
+
expect(flags.isPropDerivedArray).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('a two-hop alias of the whole props object also uses mapArray', () => {
|
|
159
|
+
const flags = loopFlags(`
|
|
160
|
+
'use client'
|
|
161
|
+
${CHILD}
|
|
162
|
+
type Props = { items: ItemProps[] }
|
|
163
|
+
export function List(props: Props) {
|
|
164
|
+
const p1 = props
|
|
165
|
+
const p2 = p1
|
|
166
|
+
return <div>{p2.items.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
167
|
+
}
|
|
168
|
+
`, 'ObjectAliasTwoHop.tsx')
|
|
169
|
+
expect(flags.isStaticArray).toBe(false)
|
|
170
|
+
expect(flags.isPropDerivedArray).toBe(true)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('a body-destructured RENAME of a prop uses mapArray', () => {
|
|
174
|
+
// `const { items: rows } = props` — same bug class as the headline
|
|
175
|
+
// fix, but via a different route: the analyzer's body-destructure
|
|
176
|
+
// expansion (`collectConstant`, analyzer.ts) never attached a
|
|
177
|
+
// structured `.parsed` to the expanded constant, so the member-access
|
|
178
|
+
// recognition this fix relies on had nothing to read. An UN-renamed
|
|
179
|
+
// destructure (`const { items } = props`) worked before this fix too,
|
|
180
|
+
// but only because the local name happens to equal a `props` type
|
|
181
|
+
// member name, which the rename breaks.
|
|
182
|
+
const flags = loopFlags(`
|
|
183
|
+
'use client'
|
|
184
|
+
${CHILD}
|
|
185
|
+
type Props = { items: ItemProps[] }
|
|
186
|
+
export function List(props: Props) {
|
|
187
|
+
const { items: rows } = props
|
|
188
|
+
return <div>{rows.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
189
|
+
}
|
|
190
|
+
`, 'RenamedDestructure.tsx')
|
|
191
|
+
expect(flags.isStaticArray).toBe(false)
|
|
192
|
+
expect(flags.isPropDerivedArray).toBe(true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('regression guard: a genuinely static module-level array still takes the static path', () => {
|
|
196
|
+
const clientJs = getClientJs(`
|
|
197
|
+
'use client'
|
|
198
|
+
import { createSignal } from '@barefootjs/client'
|
|
199
|
+
${CHILD}
|
|
200
|
+
const ITEMS: ItemProps[] = [{ label: 'a' }, { label: 'b' }]
|
|
201
|
+
export function List() {
|
|
202
|
+
return <div>{ITEMS.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
203
|
+
}
|
|
204
|
+
`, 'ModuleConst.tsx')
|
|
205
|
+
expect(clientJs).toMatch(/qsaChildScopes\(/)
|
|
206
|
+
expect(clientJs).not.toMatch(/\bmapArray(Lazy)?\s*\(/)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('false-positive guard: an unrelated local object that merely shares a prop-like key name stays static', () => {
|
|
210
|
+
// `state.items` must not be mistaken for `props.items` just because
|
|
211
|
+
// `items` is also a destructured prop name elsewhere in the component —
|
|
212
|
+
// the alias walk only ever resolves `state`'s OWN declaration, never a
|
|
213
|
+
// same-named identifier's meaning at a different binding site.
|
|
214
|
+
const clientJs = getClientJs(`
|
|
215
|
+
'use client'
|
|
216
|
+
import { createSignal } from '@barefootjs/client'
|
|
217
|
+
${CHILD}
|
|
218
|
+
type Props = { items: ItemProps[] }
|
|
219
|
+
export function List({ items }: Props) {
|
|
220
|
+
const state = { items: [{ label: 'z' }] }
|
|
221
|
+
return <div>{state.items.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
222
|
+
}
|
|
223
|
+
`, 'NameCollision.tsx')
|
|
224
|
+
expect(clientJs).toMatch(/qsaChildScopes\(/)
|
|
225
|
+
expect(clientJs).not.toMatch(/\bmapArray(Lazy)?\s*\(/)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('review finding (second pass): a `let` alias of a prop still uses mapArray, not the static path', () => {
|
|
229
|
+
// A first review pass restricted the alias-hop walk to `const` only,
|
|
230
|
+
// reasoning that a reassignable `let` doesn't promise "this IS the
|
|
231
|
+
// prop's own data". That reasoning does not hold: `isStaticArray`
|
|
232
|
+
// treats "not recognized as prop-derived" as STATIC, not dynamic, so
|
|
233
|
+
// excluding `let` doesn't move a `let`-aliased prop array to the safe
|
|
234
|
+
// (over-reconciling) side — it silently reproduces #2724's own bug
|
|
235
|
+
// shape for exactly this case (the array stays on the static
|
|
236
|
+
// `qsaChildScopes` path, so a CSR-only mount never wires up the row's
|
|
237
|
+
// child). Reverted in a second review pass; see `propAliasHopCandidates`'s
|
|
238
|
+
// docstring (jsx-to-ir.ts) for the full reasoning, including why this
|
|
239
|
+
// is safe for the Go-adapter-facing `isPropDerivedArray` reading too.
|
|
240
|
+
const flags = loopFlags(`
|
|
241
|
+
'use client'
|
|
242
|
+
${CHILD}
|
|
243
|
+
type Props = { items: ItemProps[] }
|
|
244
|
+
export function List({ items }: Props) {
|
|
245
|
+
let cur = items
|
|
246
|
+
return <div>{cur.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
247
|
+
}
|
|
248
|
+
`, 'LetAlias.tsx')
|
|
249
|
+
expect(flags.isStaticArray).toBe(false)
|
|
250
|
+
expect(flags.isPropDerivedArray).toBe(true)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('review finding: an inner loop is not misclassified through an outer alias const its own row parameter shadows', () => {
|
|
254
|
+
// `rows.map((list) => list.map(...))` where a component-scope
|
|
255
|
+
// `const list = items` exists SEPARATELY: the inner loop's `list` must
|
|
256
|
+
// resolve to the OUTER LOOP'S ROW VALUE (not itself prop-derived —
|
|
257
|
+
// it's an arbitrary array-of-arrays element), never hop into the
|
|
258
|
+
// unrelated, shadowed `const list = items` just because the names
|
|
259
|
+
// collide. Confirmed via a direct IR probe during review that this
|
|
260
|
+
// shape reaches the Go adapter's real `isPropDerived`-branching
|
|
261
|
+
// codegen (`go-template-adapter.ts`) rather than being caught by its
|
|
262
|
+
// BF101 gate — that gate explicitly allows a scope-bound array name
|
|
263
|
+
// through (`!this.scope.isBound(arrayName)`), so a wrong flag here
|
|
264
|
+
// would have reached real Go output, not just this IR.
|
|
265
|
+
const ctx = analyzeComponent(`
|
|
266
|
+
'use client'
|
|
267
|
+
${CHILD}
|
|
268
|
+
type Props = { rows: ItemProps[][]; items: ItemProps[] }
|
|
269
|
+
export function List({ rows, items }: Props) {
|
|
270
|
+
const list = items
|
|
271
|
+
return (
|
|
272
|
+
<div>
|
|
273
|
+
{rows.map((list, ri) => (
|
|
274
|
+
<ul key={ri}>{list.map((item) => <Item key={item.label} label={item.label} />)}</ul>
|
|
275
|
+
))}
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
`, 'ShadowedAlias.tsx', 'List')
|
|
280
|
+
const ir = jsxToIR(ctx)
|
|
281
|
+
const innerLoop = findLoop(ir, (n: any) => n.array === 'list')
|
|
282
|
+
expect(innerLoop).toBeDefined()
|
|
283
|
+
expect(innerLoop.isStaticArray).toBe(true)
|
|
284
|
+
expect(innerLoop.isPropDerivedArray).toBeUndefined()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('pullfrog review finding: shadow guard also applies when the shadowed outer const is member-access-valued', () => {
|
|
288
|
+
// The previous test's `const list = items` is IDENTIFIER-valued, so its
|
|
289
|
+
// `.parsed` is `{kind: 'identifier'}` — that shape never satisfies
|
|
290
|
+
// `isDirectPropBindingName`'s member-access branch, so it never
|
|
291
|
+
// exercised that branch's own shadow-awareness. A MEMBER-ACCESS-valued
|
|
292
|
+
// shadowed const (`const list = props.entries`) does: `isArrayExprDirectPropRef`
|
|
293
|
+
// calls `isDirectPropBindingName("list", ctx)` as the very FIRST
|
|
294
|
+
// `resolveAliasOrigin` terminal check, before any hop — and that
|
|
295
|
+
// function used to read the shadow-unaware `constantsByName(ctx)`
|
|
296
|
+
// index directly, finding the outer `list` and recognizing its
|
|
297
|
+
// `props.entries` value as prop-derived, entirely bypassing
|
|
298
|
+
// `propAliasHopCandidates`'s shadow filter (which only ever guards hop
|
|
299
|
+
// CONTINUATION, never this first call). Fixed by adding the same
|
|
300
|
+
// `ctx.scope.isBound` guard directly to `isDirectPropBindingName`.
|
|
301
|
+
const ctx = analyzeComponent(`
|
|
302
|
+
'use client'
|
|
303
|
+
${CHILD}
|
|
304
|
+
type Props = { rows: ItemProps[][]; entries: ItemProps[] }
|
|
305
|
+
export function List(props: Props) {
|
|
306
|
+
const list = props.entries
|
|
307
|
+
return (
|
|
308
|
+
<div>
|
|
309
|
+
{props.rows.map((list, ri) => (
|
|
310
|
+
<ul key={ri}>{list.map((item) => <Item key={item.label} label={item.label} />)}</ul>
|
|
311
|
+
))}
|
|
312
|
+
</div>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
`, 'ShadowedMemberAlias.tsx', 'List')
|
|
316
|
+
const ir = jsxToIR(ctx)
|
|
317
|
+
const innerLoop = findLoop(ir, (n: any) => n.array === 'list')
|
|
318
|
+
expect(innerLoop).toBeDefined()
|
|
319
|
+
expect(innerLoop.isStaticArray).toBe(true)
|
|
320
|
+
expect(innerLoop.isPropDerivedArray).toBeUndefined()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('regression guard: a chained `.filter()` alias stays static (no function call on the FINAL hop)', () => {
|
|
324
|
+
const flags = loopFlags(`
|
|
325
|
+
'use client'
|
|
326
|
+
${CHILD}
|
|
327
|
+
type Props = { items: ItemProps[] }
|
|
328
|
+
export function List({ items }: Props) {
|
|
329
|
+
const a = items.filter((i) => i.label.length > 0)
|
|
330
|
+
const b = a
|
|
331
|
+
return <div>{b.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
332
|
+
}
|
|
333
|
+
`, 'FilterChainAlias.tsx')
|
|
334
|
+
expect(flags.isStaticArray).toBe(true)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test('regression guard: `??` fallback on a props member stays static (#2806)', () => {
|
|
338
|
+
const flags = loopFlags(`
|
|
339
|
+
'use client'
|
|
340
|
+
${CHILD}
|
|
341
|
+
type Props = { items?: ItemProps[] }
|
|
342
|
+
export function List(props: Props) {
|
|
343
|
+
const arr = props.items ?? []
|
|
344
|
+
return <div>{arr.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
345
|
+
}
|
|
346
|
+
`, 'NullishFallback.tsx')
|
|
347
|
+
expect(flags.isStaticArray).toBe(true)
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The pre-existing `(props: Props)`-shape name-collision imprecision noted
|
|
353
|
+
* here previously is fixed — see #2835 and
|
|
354
|
+
* `issue-2835-props-type-member-name-collision.test.ts`.
|
|
355
|
+
*/
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2737 — a local `const` alias of the WHOLE props object
|
|
3
|
+
* (`const props__alias = props`) leaks the pre-rewrite name into a
|
|
4
|
+
* component's CSR template.
|
|
5
|
+
*
|
|
6
|
+
* `props__alias` is itself a plain reactive/const-inlinable reference to
|
|
7
|
+
* `props`, so constant inlining correctly substitutes it — but as
|
|
8
|
+
* `(props)`, a PARENTHESISED receiver. `html-template.ts`'s four
|
|
9
|
+
* template-builder `transformExpr`/`transformJs` closures each ran their
|
|
10
|
+
* own `\bpropsObjectName\.` regex to rewrite `props.x` → `_p.x`; that
|
|
11
|
+
* regex requires `props` to be immediately followed by `.`, so `(props).x`
|
|
12
|
+
* survived untouched. The init body's own rewrite (`rewritePropsObjectRef`,
|
|
13
|
+
* an AST walk) never had this gap, since it isn't a regex — this fix
|
|
14
|
+
* makes the four template sites call the SAME function instead of their
|
|
15
|
+
* own copies.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, test, expect } from 'bun:test'
|
|
18
|
+
import { compileJSX } from '../compiler'
|
|
19
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
20
|
+
|
|
21
|
+
const adapter = new TestAdapter()
|
|
22
|
+
|
|
23
|
+
function clientJs(source: string): string {
|
|
24
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
25
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
26
|
+
return result.files.find(f => f.type === 'clientJs')!.content
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('#2737 — props-object alias resolves in the CSR template', () => {
|
|
30
|
+
test('a SolidJS-style component whose whole `props` is aliased emits `(_p)`, not `(props)`', () => {
|
|
31
|
+
const content = clientJs(`
|
|
32
|
+
"use client";
|
|
33
|
+
export function ReactiveChild(props: { label: string; onIncrement: () => void }) {
|
|
34
|
+
const props__alias = props
|
|
35
|
+
return (
|
|
36
|
+
<div>
|
|
37
|
+
<span>{props__alias.label}</span>
|
|
38
|
+
<button onClick={() => props__alias.onIncrement()}>+</button>
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
`)
|
|
43
|
+
expect(content).toContain('(_p).label')
|
|
44
|
+
expect(content).not.toMatch(/\(props\)\./)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('the same alias inside a loop row still resolves (shadow guard does not over-fire on the outer name)', () => {
|
|
48
|
+
const content = clientJs(`
|
|
49
|
+
"use client";
|
|
50
|
+
import { createSignal } from '@barefootjs/client'
|
|
51
|
+
export function Row(props: { items: Array<{ id: number; label: string }> }) {
|
|
52
|
+
const props__alias = props
|
|
53
|
+
return (
|
|
54
|
+
<ul>
|
|
55
|
+
{props__alias.items.map(item => (
|
|
56
|
+
<li key={item.id}>{item.label}</li>
|
|
57
|
+
))}
|
|
58
|
+
</ul>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
`)
|
|
62
|
+
expect(content).toContain('(_p).items')
|
|
63
|
+
expect(content).not.toMatch(/\(props\)\./)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('a `.map()` row PARAMETER literally named `props` is not rewritten (shadow guard)', () => {
|
|
67
|
+
// The outer component's props object happens to share a name with a
|
|
68
|
+
// loop callback's own parameter — the parameter binds a per-row VALUE,
|
|
69
|
+
// not the outer props object, and must not be rewritten to `_p`.
|
|
70
|
+
const content = clientJs(`
|
|
71
|
+
"use client";
|
|
72
|
+
export function List(items: never) {
|
|
73
|
+
const rows = [{ id: 1, name: 'a' }, { id: 2, name: 'b' }]
|
|
74
|
+
return (
|
|
75
|
+
<ul>
|
|
76
|
+
{rows.map(props => (
|
|
77
|
+
<li key={props.id}>{props.name}</li>
|
|
78
|
+
))}
|
|
79
|
+
</ul>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
`)
|
|
83
|
+
expect(content).not.toContain('_p.name')
|
|
84
|
+
expect(content).toContain('props.name')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2756 (input sub-mechanism) — the inverse of
|
|
3
|
+
* `issue-2756-loop-row-honors-client-only.test.ts`.
|
|
4
|
+
*
|
|
5
|
+
* `<textarea>`/`<select>`'s controlled `value` is `clientOnly` at the IR
|
|
6
|
+
* level (`lowerFormControlValueSsr`): SSR never bakes an attribute for it,
|
|
7
|
+
* so a client-built row must not either — that gap was closed by #2764.
|
|
8
|
+
*
|
|
9
|
+
* `<input>`'s `value`/`checked` are NOT `clientOnly` — SSR bakes a literal
|
|
10
|
+
* `value="…"` / `checked` attribute, same as any other adapter. The client
|
|
11
|
+
* effect that keeps it live only ever assigns the DOM PROPERTY (#2716,
|
|
12
|
+
* `emitAttrUpdate`'s value/boolean-attr branches), never `setAttribute`.
|
|
13
|
+
* `buildLoopSkeletonTemplate`'s hoisted shared-`<template>` fast path used
|
|
14
|
+
* to treat this identically to an ordinary reactive attribute — omit it
|
|
15
|
+
* from the skeleton and trust the effect's eager first run to restore it.
|
|
16
|
+
* That trust is misplaced here: the effect never touches the attribute, so
|
|
17
|
+
* a row cloned from the skeleton never gets it, while a hydration-reused
|
|
18
|
+
* (SSR-origin) row keeps it forever — the two legs permanently disagree the
|
|
19
|
+
* moment a row-count change makes both coexist in one list.
|
|
20
|
+
*
|
|
21
|
+
* The fix refuses the skeleton fast path entirely for a row carrying a
|
|
22
|
+
* non-`clientOnly` property-only bind, falling back to the per-row
|
|
23
|
+
* interpolated template (`irToHtmlTemplate`), which already bakes the
|
|
24
|
+
* attribute correctly — so this test asserts the attribute IS present,
|
|
25
|
+
* the opposite assertion from the textarea/select test.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, test, expect } from 'bun:test'
|
|
28
|
+
import { compileJSX } from '../compiler'
|
|
29
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
30
|
+
|
|
31
|
+
const adapter = new TestAdapter()
|
|
32
|
+
|
|
33
|
+
function clientJs(source: string): string {
|
|
34
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
35
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
36
|
+
return result.files.find(f => f.type === 'clientJs')!.content
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `__tpl.innerHTML = \`…\`` / `insert(... html: \`…\`)` builder strings. */
|
|
40
|
+
function builderTemplates(content: string): string[] {
|
|
41
|
+
return [...content.matchAll(/innerHTML = `([^`]*)`/g)].map(m => m[1])
|
|
42
|
+
.concat([...content.matchAll(/html: `([^`]*)`/g)].map(m => m[1]))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('#2756 — a controlled <input> row bails out of the skeleton fast path', () => {
|
|
46
|
+
test('a keyed-loop row builder bakes the controlled input `value` attribute, matching SSR', () => {
|
|
47
|
+
const content = clientJs(`
|
|
48
|
+
"use client";
|
|
49
|
+
import { createSignal } from '@barefootjs/client'
|
|
50
|
+
export function LoopInput() {
|
|
51
|
+
const [val, setVal] = createSignal(0)
|
|
52
|
+
const [items] = createSignal([1, 2, 3])
|
|
53
|
+
return (
|
|
54
|
+
<ul>
|
|
55
|
+
{items().filter(i => i > 0).map(i => (
|
|
56
|
+
<li key={i}>
|
|
57
|
+
<input value={val()} onInput={() => setVal(1)} />
|
|
58
|
+
</li>
|
|
59
|
+
))}
|
|
60
|
+
</ul>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
`)
|
|
64
|
+
const rowTemplates = builderTemplates(content).filter(t => t.includes('<input'))
|
|
65
|
+
expect(rowTemplates.length).toBeGreaterThan(0)
|
|
66
|
+
for (const tpl of rowTemplates) {
|
|
67
|
+
expect(tpl).toContain(`value="' + escapeAttr(val())`)
|
|
68
|
+
}
|
|
69
|
+
// The effect that keeps the live property in sync is still emitted —
|
|
70
|
+
// the attribute is baked in ADDITION to it, not instead of it.
|
|
71
|
+
expect(content).toContain(`'value' in`)
|
|
72
|
+
// The hoisted shared-template fast path (perf, #2143) must not have
|
|
73
|
+
// been used for this loop — its declaration is the tell.
|
|
74
|
+
expect(content).not.toContain('__tpl_')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('a keyed-loop row builder bakes the controlled checkbox `checked` attribute, matching SSR', () => {
|
|
78
|
+
const content = clientJs(`
|
|
79
|
+
"use client";
|
|
80
|
+
import { createSignal } from '@barefootjs/client'
|
|
81
|
+
export function LoopCheckbox() {
|
|
82
|
+
const [on, setOn] = createSignal(true)
|
|
83
|
+
const [items] = createSignal([1, 2, 3])
|
|
84
|
+
return (
|
|
85
|
+
<ul>
|
|
86
|
+
{items().filter(i => i > 0).map(i => (
|
|
87
|
+
<li key={i}>
|
|
88
|
+
<input type="checkbox" checked={on()} onChange={() => setOn(false)} />
|
|
89
|
+
</li>
|
|
90
|
+
))}
|
|
91
|
+
</ul>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
`)
|
|
95
|
+
const rowTemplates = builderTemplates(content).filter(t => t.includes('<input'))
|
|
96
|
+
expect(rowTemplates.length).toBeGreaterThan(0)
|
|
97
|
+
for (const tpl of rowTemplates) {
|
|
98
|
+
expect(tpl).toContain(`on() ? 'checked' : ''`)
|
|
99
|
+
}
|
|
100
|
+
expect(content).not.toContain('__tpl_')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('an ordinary reactive attribute on the same row is unaffected (still eligible for the skeleton fast path elsewhere)', () => {
|
|
104
|
+
// Control: a loop row with NO property-only bind still uses the
|
|
105
|
+
// hoisted skeleton fast path — the fix is scoped to rows that actually
|
|
106
|
+
// carry one, not a blanket disable.
|
|
107
|
+
const content = clientJs(`
|
|
108
|
+
"use client";
|
|
109
|
+
import { createSignal } from '@barefootjs/client'
|
|
110
|
+
export function LoopPlain() {
|
|
111
|
+
const [items] = createSignal([1, 2, 3])
|
|
112
|
+
return (
|
|
113
|
+
<ul>
|
|
114
|
+
{items().filter(i => i > 0).map(i => (
|
|
115
|
+
<li key={i} data-n={i}>{i}</li>
|
|
116
|
+
))}
|
|
117
|
+
</ul>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
`)
|
|
121
|
+
expect(content).toContain('__tpl_')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2778 — a local `const` alias of a signal getter (`const items__alias
|
|
3
|
+
* = items`) was substituted as the literal `undefined` in the CSR
|
|
4
|
+
* template instead of following the alias to the signal's initializer.
|
|
5
|
+
* `(undefined)()` is a guaranteed `TypeError` on pure CSR mount.
|
|
6
|
+
*
|
|
7
|
+
* The alias binding is not a value copy — `items__alias` IS `items` for
|
|
8
|
+
* substitution purposes — so `resolveGetterAliases` (`csr-substitute.ts`)
|
|
9
|
+
* registers it as the SAME call-kind substitution entry as its origin,
|
|
10
|
+
* the same mechanism that already substitutes `items()` correctly.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect } from 'bun:test'
|
|
13
|
+
import { compileJSX } from '../compiler'
|
|
14
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
15
|
+
|
|
16
|
+
const adapter = new TestAdapter()
|
|
17
|
+
|
|
18
|
+
function compile(source: string) {
|
|
19
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
20
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')?.content ?? ''
|
|
21
|
+
return { errors: result.errors.filter(e => e.severity === 'error'), clientJs }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('#2778 — signal/memo getter alias resolves in the CSR template', () => {
|
|
25
|
+
test('a one-hop signal-getter alias as a .map() source inlines the array, not `undefined`', () => {
|
|
26
|
+
const { errors, clientJs } = compile(`
|
|
27
|
+
"use client";
|
|
28
|
+
import { createSignal } from '@barefootjs/client'
|
|
29
|
+
export function Aliased() {
|
|
30
|
+
const [items] = createSignal([{ id: 1, label: 'a' }, { id: 2, label: 'b' }])
|
|
31
|
+
const items__alias = items
|
|
32
|
+
return (
|
|
33
|
+
<ul>
|
|
34
|
+
{items__alias().map(it => <li key={it.id}>{it.label}</li>)}
|
|
35
|
+
</ul>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
`)
|
|
39
|
+
expect(errors).toHaveLength(0)
|
|
40
|
+
expect(clientJs).not.toContain('(undefined)')
|
|
41
|
+
expect(clientJs).toContain(`[{ id: 1, label: 'a' }, { id: 2, label: 'b' }]`)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a one-hop memo alias inlines the memo body', () => {
|
|
45
|
+
const { errors, clientJs } = compile(`
|
|
46
|
+
"use client";
|
|
47
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
48
|
+
export function Aliased() {
|
|
49
|
+
const [count] = createSignal(3)
|
|
50
|
+
const doubled = createMemo(() => count() * 2)
|
|
51
|
+
const doubled__alias = doubled
|
|
52
|
+
return <span>{doubled__alias()}</span>
|
|
53
|
+
}
|
|
54
|
+
`)
|
|
55
|
+
expect(errors).toHaveLength(0)
|
|
56
|
+
expect(clientJs).not.toContain('(undefined)')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('a two-hop alias chain resolves without a spurious diagnostic', () => {
|
|
60
|
+
const { errors, clientJs } = compile(`
|
|
61
|
+
"use client";
|
|
62
|
+
import { createSignal } from '@barefootjs/client'
|
|
63
|
+
export function Aliased() {
|
|
64
|
+
const [items] = createSignal([{ id: 1, label: 'a' }])
|
|
65
|
+
const a = items
|
|
66
|
+
const b = a
|
|
67
|
+
return (
|
|
68
|
+
<ul>
|
|
69
|
+
{b().map(it => <li key={it.id}>{it.label}</li>)}
|
|
70
|
+
</ul>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
`)
|
|
74
|
+
expect(errors).toHaveLength(0)
|
|
75
|
+
expect(clientJs).not.toContain('undefined')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a `.map()` row PARAMETER literally named after the alias is not rewritten (shadow guard)', () => {
|
|
79
|
+
// A REAL component-scope alias must exist for this to exercise
|
|
80
|
+
// anything — `resolveGetterAliases` only registers a substitution
|
|
81
|
+
// entry for `items__alias` because of the `const [items] =
|
|
82
|
+
// createSignal(...); const items__alias = items` pair below. Without
|
|
83
|
+
// it (the original version of this test), the row parameter of the
|
|
84
|
+
// SAME name in `rows.map(items__alias => ...)` shadows nothing, and
|
|
85
|
+
// the assertion would pass identically whether or not the fix exists
|
|
86
|
+
// (pullfrog review, PR #2814).
|
|
87
|
+
const { errors, clientJs } = compile(`
|
|
88
|
+
"use client";
|
|
89
|
+
import { createSignal } from '@barefootjs/client'
|
|
90
|
+
export function List() {
|
|
91
|
+
const [items] = createSignal([{ id: 1, label: 'x' }])
|
|
92
|
+
const items__alias = items
|
|
93
|
+
const rows = [{ id: 1, name: 'a' }]
|
|
94
|
+
return (
|
|
95
|
+
<ul>
|
|
96
|
+
<li>{items__alias().length}</li>
|
|
97
|
+
{rows.map(items__alias => <li key={items__alias.id}>{items__alias.name}</li>)}
|
|
98
|
+
</ul>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
`)
|
|
102
|
+
expect(errors).toHaveLength(0)
|
|
103
|
+
// Outside the loop, the component-scope alias substitutes correctly...
|
|
104
|
+
expect(clientJs).toContain(`[{ id: 1, label: 'x' }]).length`)
|
|
105
|
+
// ...but inside the loop, the SAME name is the row parameter and must
|
|
106
|
+
// stay untouched — not rewritten to the origin signal's array, and not
|
|
107
|
+
// flagged UNSAFE by the outer alias's unsafe-name bookkeeping either
|
|
108
|
+
// (the two bugs this construction actually exercises).
|
|
109
|
+
expect(clientJs).toContain('items__alias.name')
|
|
110
|
+
expect(clientJs).toContain('escapeAttr(items__alias.id)')
|
|
111
|
+
expect(clientJs).not.toContain('escapeAttr(undefined)')
|
|
112
|
+
})
|
|
113
|
+
})
|