@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,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2806 — a component reading its own rest-bag spread directly in JSX
|
|
3
|
+
* (`{rest.header}`, not spread onto an element's attrs) emitted a CSR
|
|
4
|
+
* template that referenced the bare, unbound `rest` identifier.
|
|
5
|
+
*
|
|
6
|
+
* The rest binding IS `_p` at runtime (`applyRestAttrs` excludes the
|
|
7
|
+
* consumed keys by name rather than constructing a narrower object, and
|
|
8
|
+
* the init body already rewrites `rest.x` → `_p.x` via
|
|
9
|
+
* `rewritePropsObjectRef`, #2723). The four CSR/static template builders
|
|
10
|
+
* in `html-template.ts` each call that same function for `propsObjectName`
|
|
11
|
+
* but were passing `null` for the rest name, so a direct rest-bag read
|
|
12
|
+
* never got the same treatment there.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, test, expect } from 'bun:test'
|
|
15
|
+
import { compileJSX } from '../compiler'
|
|
16
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
17
|
+
|
|
18
|
+
const adapter = new TestAdapter()
|
|
19
|
+
|
|
20
|
+
function clientJs(source: string): string {
|
|
21
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
22
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
23
|
+
return result.files.find(f => f.type === 'clientJs')!.content
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('#2806 — rest-bag identifier resolves in the CSR template', () => {
|
|
27
|
+
test('a direct rest-bag read in a text expression emits `_p.x`, not bare `rest`', () => {
|
|
28
|
+
const content = clientJs(`
|
|
29
|
+
"use client";
|
|
30
|
+
import { createSignal } from '@barefootjs/client'
|
|
31
|
+
export function Card({ children, ...rest }: { children?: any; [key: string]: any }) {
|
|
32
|
+
const [cond, setCond] = createSignal(true)
|
|
33
|
+
return (
|
|
34
|
+
<section>
|
|
35
|
+
<header>{cond() ? rest.header : null}</header>
|
|
36
|
+
<div>{children}</div>
|
|
37
|
+
</section>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
`)
|
|
41
|
+
expect(content).toContain('_p.header')
|
|
42
|
+
expect(content).not.toMatch(/[^.\w]rest\.header/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('a rest-bag read inside a `.map()` row still resolves', () => {
|
|
46
|
+
// NOTE: `rest.items ?? []` is classified `isStaticArray` here (no signal,
|
|
47
|
+
// no `isArrayExprDirectPropRef` match — see `jsx-to-ir.ts`'s `isStaticArray`
|
|
48
|
+
// derivation), so this row is emitted through `buildStaticLoopPlan`'s
|
|
49
|
+
// SSR-time-only `forEach`, NOT through the reactive `mapArray`/`mapArrayLazy`
|
|
50
|
+
// loop emitters. It does not exercise those two paths — the tests below do.
|
|
51
|
+
const content = clientJs(`
|
|
52
|
+
"use client";
|
|
53
|
+
export function Row({ children, ...rest }: { children?: any; items?: Array<{ id: number }>; [key: string]: any }) {
|
|
54
|
+
return (
|
|
55
|
+
<ul>
|
|
56
|
+
{(rest.items ?? []).map((item: { id: number }) => (
|
|
57
|
+
<li key={item.id}>{rest.suffix}</li>
|
|
58
|
+
))}
|
|
59
|
+
</ul>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
`)
|
|
63
|
+
expect(content).toContain('_p.suffix')
|
|
64
|
+
expect(content).not.toMatch(/[^.\w]rest\.suffix/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('a rest-bag read inside a lazy-eligible `.map()` row resolves (`mapArrayLazy`)', () => {
|
|
68
|
+
// Signal-backed source + a keyed, single-root, child-component-free row
|
|
69
|
+
// is lazy-eligible (`lazyRowEligibility`), so this hits the `createRow`/
|
|
70
|
+
// `applyOuter` bodies emitted by `stringify/lazy-row.ts` — a SEPARATE
|
|
71
|
+
// emitter from the whole-init-body rewrite the other tests here exercise
|
|
72
|
+
// only incidentally through the eager `mapArray` row body.
|
|
73
|
+
const content = clientJs(`
|
|
74
|
+
"use client";
|
|
75
|
+
import { createSignal } from '@barefootjs/client'
|
|
76
|
+
export function Row({ children, ...rest }: { children?: any; [key: string]: any }) {
|
|
77
|
+
const [items, setItems] = createSignal<{ id: number }[]>([])
|
|
78
|
+
return (
|
|
79
|
+
<ul>
|
|
80
|
+
{items().map((item: { id: number }) => (
|
|
81
|
+
<li key={item.id}>{item.id}{rest.suffix}</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
`)
|
|
87
|
+
expect(content).toContain('mapArrayLazy')
|
|
88
|
+
expect(content).toContain('_p.suffix')
|
|
89
|
+
expect(content).not.toMatch(/[^.\w]rest\.suffix/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('a rest-bag read inside an eager `.map()` row with a child component resolves (`mapArray`)', () => {
|
|
93
|
+
// A child component in the row forces eager `mapArray` eligibility
|
|
94
|
+
// (`lazyRowEligibility`'s `hasChildComponent` gate) — the loop-row-body
|
|
95
|
+
// emission path #2812 actually fixed (the `hydrate(... template: ...)`
|
|
96
|
+
// line and the CSR/static template builders in `html-template.ts`).
|
|
97
|
+
const content = clientJs(`
|
|
98
|
+
"use client";
|
|
99
|
+
import { createSignal } from '@barefootjs/client'
|
|
100
|
+
function Item(props: { id: number }) {
|
|
101
|
+
return <span>{props.id}</span>
|
|
102
|
+
}
|
|
103
|
+
export function Row({ children, ...rest }: { children?: any; [key: string]: any }) {
|
|
104
|
+
const [items, setItems] = createSignal<{ id: number }[]>([])
|
|
105
|
+
return (
|
|
106
|
+
<ul>
|
|
107
|
+
{items().map((item: { id: number }) => (
|
|
108
|
+
<li key={item.id}><Item id={item.id} />{rest.suffix}</li>
|
|
109
|
+
))}
|
|
110
|
+
</ul>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
`)
|
|
114
|
+
expect(content).toContain('mapArray(')
|
|
115
|
+
expect(content).toContain('_p.suffix')
|
|
116
|
+
expect(content).not.toMatch(/[^.\w]rest\.suffix/)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('a `.map()` row PARAMETER literally named `rest` is not rewritten (shadow guard)', () => {
|
|
120
|
+
const content = clientJs(`
|
|
121
|
+
"use client";
|
|
122
|
+
export function List({ children, ...outer }: { children?: any; [key: string]: any }) {
|
|
123
|
+
const rows = [{ id: 1, name: 'a' }]
|
|
124
|
+
return (
|
|
125
|
+
<ul>
|
|
126
|
+
{rows.map(rest => (
|
|
127
|
+
<li key={rest.id}>{rest.name}</li>
|
|
128
|
+
))}
|
|
129
|
+
</ul>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
`)
|
|
133
|
+
expect(content).not.toContain('_p.name')
|
|
134
|
+
expect(content).toContain('rest.name')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2835 — `isDirectPropBindingName` (jsx-to-ir.ts) checked `ctx.patterns.props`,
|
|
3
|
+
* a regex-matching set that, for a whole `(props: Props)` parameter, includes
|
|
4
|
+
* every one of `Props`'s type-member names regardless of whether that name is
|
|
5
|
+
* bound to anything in the current component (`extractPropsFromTypeMembers`,
|
|
6
|
+
* analyzer.ts — needed there so `props.<key>` still regex-matches). Reused as
|
|
7
|
+
* a "is this name a prop binding" check, that over-inclusion let an unrelated
|
|
8
|
+
* module/local identifier resolve as prop-derived purely by sharing a type
|
|
9
|
+
* member's name, with zero aliasing involved (reproduces identically on
|
|
10
|
+
* pre-#2724 `main`).
|
|
11
|
+
*
|
|
12
|
+
* Fix: `isDirectPropBindingName` now reads `ctx.boundPropNames`
|
|
13
|
+
* (`boundPropLocalNames`, `props-binding.ts`) — empty for a whole
|
|
14
|
+
* non-destructured props parameter, since `propsParams` there is type-member
|
|
15
|
+
* names, not local bindings.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, test, expect } from 'bun:test'
|
|
19
|
+
import { compileJSX } from '../compiler'
|
|
20
|
+
import { analyzeComponent } from '../analyzer'
|
|
21
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
22
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
23
|
+
import type { IRNode } from '../types'
|
|
24
|
+
|
|
25
|
+
const adapter = new TestAdapter()
|
|
26
|
+
|
|
27
|
+
function getClientJs(source: string, filename: string): string {
|
|
28
|
+
const result = compileJSX(source, filename, { adapter })
|
|
29
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
30
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
31
|
+
expect(clientJs).toBeDefined()
|
|
32
|
+
return clientJs!.content
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** First `IRLoop` node found in a DFS of the component IR. */
|
|
36
|
+
function findLoop(node: IRNode | null, predicate: (n: any) => boolean = () => true): any {
|
|
37
|
+
if (!node || typeof node !== 'object') return undefined
|
|
38
|
+
if ((node as any).type === 'loop' && predicate(node)) return node
|
|
39
|
+
for (const key of Object.keys(node)) {
|
|
40
|
+
const v = (node as any)[key]
|
|
41
|
+
if (Array.isArray(v)) {
|
|
42
|
+
for (const c of v) {
|
|
43
|
+
const found = findLoop(c, predicate)
|
|
44
|
+
if (found) return found
|
|
45
|
+
}
|
|
46
|
+
} else if (v && typeof v === 'object') {
|
|
47
|
+
const found = findLoop(v, predicate)
|
|
48
|
+
if (found) return found
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** First element node with a non-null `slotId`, found in a DFS of the IR. */
|
|
55
|
+
function findSlottedElement(node: IRNode | null): any {
|
|
56
|
+
if (!node || typeof node !== 'object') return undefined
|
|
57
|
+
if ((node as any).type === 'element' && (node as any).slotId) 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 = findSlottedElement(c)
|
|
63
|
+
if (found) return found
|
|
64
|
+
}
|
|
65
|
+
} else if (v && typeof v === 'object') {
|
|
66
|
+
const found = findSlottedElement(v)
|
|
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('#2835 — name collision with a whole-props type member is not prop-derived', () => {
|
|
91
|
+
test('headline: a module const colliding with `Props.base` stays static (type alias)', () => {
|
|
92
|
+
const flags = loopFlags(`
|
|
93
|
+
'use client'
|
|
94
|
+
${CHILD}
|
|
95
|
+
const base = [{ label: 'z' }]
|
|
96
|
+
type Props = { base: ItemProps[] }
|
|
97
|
+
export function List(props: Props) {
|
|
98
|
+
return <div>{base.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
99
|
+
}
|
|
100
|
+
`, 'ModuleConstCollision.tsx')
|
|
101
|
+
expect(flags.isStaticArray).toBe(true)
|
|
102
|
+
expect(flags.isPropDerivedArray).toBeUndefined()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('same collision with `interface Props`', () => {
|
|
106
|
+
const flags = loopFlags(`
|
|
107
|
+
'use client'
|
|
108
|
+
${CHILD}
|
|
109
|
+
const base = [{ label: 'z' }]
|
|
110
|
+
interface Props { base: ItemProps[] }
|
|
111
|
+
export function List(props: Props) {
|
|
112
|
+
return <div>{base.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
113
|
+
}
|
|
114
|
+
`, 'InterfaceCollision.tsx')
|
|
115
|
+
expect(flags.isStaticArray).toBe(true)
|
|
116
|
+
expect(flags.isPropDerivedArray).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('same collision with an inline type-literal props annotation', () => {
|
|
120
|
+
const flags = loopFlags(`
|
|
121
|
+
'use client'
|
|
122
|
+
${CHILD}
|
|
123
|
+
const base = [{ label: 'z' }]
|
|
124
|
+
export function List(props: { base: ItemProps[] }) {
|
|
125
|
+
return <div>{base.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
126
|
+
}
|
|
127
|
+
`, 'InlineTypeCollision.tsx')
|
|
128
|
+
expect(flags.isStaticArray).toBe(true)
|
|
129
|
+
expect(flags.isPropDerivedArray).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a component-local const literal colliding with a type member also stays static (not reachable via Go BF101 either — bakedChildLoop screens it out first)', () => {
|
|
133
|
+
const flags = loopFlags(`
|
|
134
|
+
'use client'
|
|
135
|
+
${CHILD}
|
|
136
|
+
type Props = { base: ItemProps[] }
|
|
137
|
+
export function List(props: Props) {
|
|
138
|
+
const base = [{ label: 'z' }]
|
|
139
|
+
return <div>{base.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
140
|
+
}
|
|
141
|
+
`, 'LocalConstCollision.tsx')
|
|
142
|
+
expect(flags.isStaticArray).toBe(true)
|
|
143
|
+
expect(flags.isPropDerivedArray).toBeUndefined()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('the collision survives an alias hop', () => {
|
|
147
|
+
const flags = loopFlags(`
|
|
148
|
+
'use client'
|
|
149
|
+
${CHILD}
|
|
150
|
+
const base = [{ label: 'z' }]
|
|
151
|
+
type Props = { base: ItemProps[] }
|
|
152
|
+
export function List(props: Props) {
|
|
153
|
+
const b = base
|
|
154
|
+
return <div>{b.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
155
|
+
}
|
|
156
|
+
`, 'AliasedCollision.tsx')
|
|
157
|
+
expect(flags.isStaticArray).toBe(true)
|
|
158
|
+
expect(flags.isPropDerivedArray).toBeUndefined()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('regression guard: destructured props still resolve to mapArray', () => {
|
|
162
|
+
const clientJs = getClientJs(`
|
|
163
|
+
'use client'
|
|
164
|
+
import { createSignal } from '@barefootjs/client'
|
|
165
|
+
${CHILD}
|
|
166
|
+
type Props = { base: ItemProps[] }
|
|
167
|
+
export function List({ base }: Props) {
|
|
168
|
+
return <div>{base.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
169
|
+
}
|
|
170
|
+
`, 'DestructuredRegression.tsx')
|
|
171
|
+
expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
|
|
172
|
+
expect(clientJs).not.toMatch(/qsaChildScopes\(/)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('regression guard: a renamed destructure ({ base: rows }) still resolves to mapArray', () => {
|
|
176
|
+
const clientJs = getClientJs(`
|
|
177
|
+
'use client'
|
|
178
|
+
import { createSignal } from '@barefootjs/client'
|
|
179
|
+
${CHILD}
|
|
180
|
+
type Props = { base: ItemProps[] }
|
|
181
|
+
export function List({ base: rows }: Props) {
|
|
182
|
+
return <div>{rows.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
183
|
+
}
|
|
184
|
+
`, 'RenamedDestructureRegression.tsx')
|
|
185
|
+
expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
|
|
186
|
+
expect(clientJs).not.toMatch(/qsaChildScopes\(/)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('regression guard: a renamed destructure keeps a same-named module const static (no name collision through the rename)', () => {
|
|
190
|
+
const flags = loopFlags(`
|
|
191
|
+
'use client'
|
|
192
|
+
${CHILD}
|
|
193
|
+
const base = [{ label: 'z' }]
|
|
194
|
+
type Props = { base: ItemProps[] }
|
|
195
|
+
export function List({ base: rows }: Props) {
|
|
196
|
+
return <div>{base.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
197
|
+
}
|
|
198
|
+
`, 'RenamedDestructureModuleConst.tsx')
|
|
199
|
+
expect(flags.isStaticArray).toBe(true)
|
|
200
|
+
expect(flags.isPropDerivedArray).toBeUndefined()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('regression guard: whole-props body destructure (`const { base } = props`) still resolves to mapArray via `.parsed`, not the name-only branch', () => {
|
|
204
|
+
const clientJs = getClientJs(`
|
|
205
|
+
'use client'
|
|
206
|
+
import { createSignal } from '@barefootjs/client'
|
|
207
|
+
${CHILD}
|
|
208
|
+
type Props = { base: ItemProps[] }
|
|
209
|
+
export function List(props: Props) {
|
|
210
|
+
const { base } = props
|
|
211
|
+
return <div>{base.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
212
|
+
}
|
|
213
|
+
`, 'WholePropsBodyDestructure.tsx')
|
|
214
|
+
expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
|
|
215
|
+
expect(clientJs).not.toMatch(/qsaChildScopes\(/)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('regression guard: direct `props.base` access still resolves to mapArray', () => {
|
|
219
|
+
const clientJs = getClientJs(`
|
|
220
|
+
'use client'
|
|
221
|
+
import { createSignal } from '@barefootjs/client'
|
|
222
|
+
${CHILD}
|
|
223
|
+
type Props = { base: ItemProps[] }
|
|
224
|
+
export function List(props: Props) {
|
|
225
|
+
return <div>{props.base.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
226
|
+
}
|
|
227
|
+
`, 'DirectMemberAccessRegression.tsx')
|
|
228
|
+
expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
|
|
229
|
+
expect(clientJs).not.toMatch(/qsaChildScopes\(/)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('regression guard: a rest-spread sibling name is unaffected (no collision to guard against)', () => {
|
|
233
|
+
const clientJs = getClientJs(`
|
|
234
|
+
'use client'
|
|
235
|
+
import { createSignal } from '@barefootjs/client'
|
|
236
|
+
${CHILD}
|
|
237
|
+
const base = [{ label: 'z' }]
|
|
238
|
+
type Props = { other: string; base: ItemProps[] }
|
|
239
|
+
export function List({ other, ...rest }: Props) {
|
|
240
|
+
return <div>{other}{base.map((item) => <Item key={item.label} label={item.label} />)}</div>
|
|
241
|
+
}
|
|
242
|
+
`, 'RestSpreadSibling.tsx')
|
|
243
|
+
expect(clientJs).not.toMatch(/\bmapArray(Lazy)?\s*\(/)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('non-impact pin: `ctx.patterns.props`\' regex detection of `props.<key>` in a whole-props component is unaffected — the element still gets a slot id', () => {
|
|
247
|
+
const ctx = analyzeComponent(`
|
|
248
|
+
'use client'
|
|
249
|
+
type Props = { base: { label: string }[] }
|
|
250
|
+
export function List(props: Props) {
|
|
251
|
+
return <div class={props.base.length ? 'a' : 'b'}>static</div>
|
|
252
|
+
}
|
|
253
|
+
`, 'NonImpactPin.tsx', 'List')
|
|
254
|
+
const ir = jsxToIR(ctx)
|
|
255
|
+
const el = findSlottedElement(ir)
|
|
256
|
+
expect(el).toBeDefined()
|
|
257
|
+
expect(el.slotId).not.toBeNull()
|
|
258
|
+
})
|
|
259
|
+
})
|
|
@@ -185,7 +185,7 @@ describe('IRComponent.loopItemRoot (#2444)', () => {
|
|
|
185
185
|
expect(clientJs!.content).toMatch(/renderChild\('Badge[^']*',\s*\{text:\s*row\.label\},\s*undefined,\s*'s\d+'\)/)
|
|
186
186
|
})
|
|
187
187
|
|
|
188
|
-
test('CSR template: a component that IS the loop row root
|
|
188
|
+
test('CSR template: a component that IS the loop row root still passes its slot id, with the loopItemRoot flag (#2833)', () => {
|
|
189
189
|
const source = `
|
|
190
190
|
'use client'
|
|
191
191
|
import { createSignal } from '@barefootjs/client'
|
|
@@ -207,7 +207,11 @@ describe('IRComponent.loopItemRoot (#2444)', () => {
|
|
|
207
207
|
expect(result.errors).toHaveLength(0)
|
|
208
208
|
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
209
209
|
expect(clientJs).toBeDefined()
|
|
210
|
-
//
|
|
211
|
-
|
|
210
|
+
// A row root still doesn't DERIVE its scope from the slot (no `_sN`
|
|
211
|
+
// scope-id suffix elsewhere in this file's other assertions), but it
|
|
212
|
+
// must keep slot identity — a pure-CSR mount's static init selector
|
|
213
|
+
// depends on the `bf-h`/`bf-m` this renders, per `renderChild`'s
|
|
214
|
+
// `loopItemRoot` param docstring (component.ts).
|
|
215
|
+
expect(clientJs!.content).toMatch(/renderChild\('Row[^']*',\s*\{label: row\.label\},\s*row\.id,\s*'s\d+',\s*true\)/)
|
|
212
216
|
})
|
|
213
217
|
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registered-lowering recognition at ANY value position (#2843), not only
|
|
3
|
+
* a call's own top-level entry point. Two independent seams share this
|
|
4
|
+
* shared-layer test:
|
|
5
|
+
*
|
|
6
|
+
* - the SUPPORT GATE (`isSupported`/`isSupportedValue`, `expression-parser.ts`),
|
|
7
|
+
* which must admit a matched call's own params — even an `object-literal`,
|
|
8
|
+
* normally refused at `rendered` position — wherever it's nested
|
|
9
|
+
* (a ternary branch, a template-literal interpolation, a binary operand),
|
|
10
|
+
* by re-checking the neutral node's `loweringNodeChildren` instead of the
|
|
11
|
+
* call's raw `args`;
|
|
12
|
+
* - the shared `emitParsedExpr` DISPATCHER (`parsed-expr-emitter.ts`), whose
|
|
13
|
+
* `call` case now tries an emitter's optional `lowering` seam before its
|
|
14
|
+
* ordinary `call()`/`callbackMethod` dispatch, so an adapter that adopts
|
|
15
|
+
* it renders the matched node correctly at any nesting depth too.
|
|
16
|
+
*
|
|
17
|
+
* Uses a standalone SAMPLE plugin (independent of `queryHref`) so these tests
|
|
18
|
+
* exercise the mechanism itself, mirroring `lowering-registry.test.ts`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
22
|
+
import {
|
|
23
|
+
registerLoweringPlugin,
|
|
24
|
+
getLoweringPlugins,
|
|
25
|
+
prepareLoweringMatchers,
|
|
26
|
+
__resetLoweringPluginsForTest,
|
|
27
|
+
isSupported,
|
|
28
|
+
isSupportedValue,
|
|
29
|
+
emitParsedExpr,
|
|
30
|
+
type LoweringPlugin,
|
|
31
|
+
type LoweringEmitter,
|
|
32
|
+
type ParsedExprEmitter,
|
|
33
|
+
type IRMetadata,
|
|
34
|
+
type ParsedExpr,
|
|
35
|
+
} from '../index'
|
|
36
|
+
|
|
37
|
+
// A minimal sample plugin: active only when the component imports anything
|
|
38
|
+
// from `@sample/pkg`, lowering `demo(base, { … })` to a neutral `guard-list`
|
|
39
|
+
// on the `query` helper — the exact shape `queryHref` produces, so these
|
|
40
|
+
// tests exercise the same object-literal-admission path #2743/#2841 needed.
|
|
41
|
+
const samplePlugin: LoweringPlugin = {
|
|
42
|
+
name: 'sample-value-position-demo',
|
|
43
|
+
prepare(metadata) {
|
|
44
|
+
const active = metadata.imports.some(i => i.source === '@sample/pkg' && !i.isTypeOnly)
|
|
45
|
+
if (!active) return null
|
|
46
|
+
return (callee, args) => {
|
|
47
|
+
if (callee.kind !== 'identifier' || callee.name !== 'demo') return null
|
|
48
|
+
const [base, obj] = args
|
|
49
|
+
if (!base || obj?.kind !== 'object-literal') return null
|
|
50
|
+
return {
|
|
51
|
+
kind: 'guard-list',
|
|
52
|
+
helper: 'query',
|
|
53
|
+
base,
|
|
54
|
+
triples: obj.properties
|
|
55
|
+
.filter((p): p is Extract<typeof p, { kind: 'prop' }> => p.kind === 'prop')
|
|
56
|
+
.map(p => ({ guard: null, key: p.key, value: p.value })),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function metadataImporting(source: string): IRMetadata {
|
|
63
|
+
return { imports: [{ source, isTypeOnly: false, specifiers: [] }] } as unknown as IRMetadata
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
const remaining = getLoweringPlugins().filter(p => p.name !== 'sample-value-position-demo')
|
|
68
|
+
__resetLoweringPluginsForTest(remaining)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const base = { kind: 'literal', value: '/x', literalType: 'string' } as ParsedExpr
|
|
72
|
+
const tagValue = { kind: 'literal', value: 'a', literalType: 'string' } as ParsedExpr
|
|
73
|
+
const paramsObj = {
|
|
74
|
+
kind: 'object-literal',
|
|
75
|
+
raw: '{ tag: "a" }',
|
|
76
|
+
properties: [{ kind: 'prop', key: 'tag', shorthand: false, value: tagValue }],
|
|
77
|
+
} as ParsedExpr
|
|
78
|
+
const demoCall = { kind: 'call', callee: { kind: 'identifier', name: 'demo' }, args: [base, paramsObj] } as ParsedExpr
|
|
79
|
+
|
|
80
|
+
function matchers(): ReturnType<typeof prepareLoweringMatchers> {
|
|
81
|
+
registerLoweringPlugin(samplePlugin)
|
|
82
|
+
return prepareLoweringMatchers(metadataImporting('@sample/pkg'))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('isSupported / isSupportedValue with loweringMatchers (#2843)', () => {
|
|
86
|
+
test('a matched call nested in a conditional branch is admitted at rendered position', () => {
|
|
87
|
+
const ms = matchers()
|
|
88
|
+
const cond: ParsedExpr = {
|
|
89
|
+
kind: 'conditional',
|
|
90
|
+
test: { kind: 'identifier', name: 'ok' },
|
|
91
|
+
consequent: demoCall,
|
|
92
|
+
alternate: { kind: 'literal', value: '/fallback', literalType: 'string' },
|
|
93
|
+
}
|
|
94
|
+
expect(isSupported(cond, { loweringMatchers: ms }).supported).toBe(true)
|
|
95
|
+
// Without matchers, the same tree is refused (the object-literal arg).
|
|
96
|
+
expect(isSupported(cond).supported).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('a matched call nested in a logical operand is admitted', () => {
|
|
100
|
+
const ms = matchers()
|
|
101
|
+
const logical: ParsedExpr = {
|
|
102
|
+
kind: 'logical',
|
|
103
|
+
op: '||',
|
|
104
|
+
left: demoCall,
|
|
105
|
+
right: { kind: 'literal', value: '/fallback', literalType: 'string' },
|
|
106
|
+
}
|
|
107
|
+
expect(isSupported(logical, { loweringMatchers: ms }).supported).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('a matched call nested in a template-literal interpolation is admitted', () => {
|
|
111
|
+
const ms = matchers()
|
|
112
|
+
const tmpl: ParsedExpr = {
|
|
113
|
+
kind: 'template-literal',
|
|
114
|
+
parts: [
|
|
115
|
+
{ type: 'string', value: 'pre ' },
|
|
116
|
+
{ type: 'expression', expr: demoCall },
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
expect(isSupported(tmpl, { loweringMatchers: ms }).supported).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('an unmatched call inside the same shapes still refuses (no regression)', () => {
|
|
123
|
+
const ms = matchers()
|
|
124
|
+
const unmatchedCall: ParsedExpr = {
|
|
125
|
+
kind: 'call',
|
|
126
|
+
callee: { kind: 'identifier', name: 'other' },
|
|
127
|
+
args: [base, paramsObj],
|
|
128
|
+
}
|
|
129
|
+
const cond: ParsedExpr = {
|
|
130
|
+
kind: 'conditional',
|
|
131
|
+
test: { kind: 'identifier', name: 'ok' },
|
|
132
|
+
consequent: unmatchedCall,
|
|
133
|
+
alternate: { kind: 'literal', value: '/fallback', literalType: 'string' },
|
|
134
|
+
}
|
|
135
|
+
expect(isSupported(cond, { loweringMatchers: ms }).supported).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('a matched call whose own child is unsupported still refuses, loudly (sound-or-loud)', () => {
|
|
139
|
+
const ms = matchers()
|
|
140
|
+
// The value of the one triple is itself an unsupported node (a bare
|
|
141
|
+
// arrow function) — the plugin's node still carries it as a child, so
|
|
142
|
+
// it must still be checked, not silently passed through.
|
|
143
|
+
const arrow: ParsedExpr = { kind: 'arrow', params: ['x'], body: { kind: 'identifier', name: 'x' } }
|
|
144
|
+
const badParams = {
|
|
145
|
+
kind: 'object-literal',
|
|
146
|
+
raw: '{ tag: (x) => x }',
|
|
147
|
+
properties: [{ kind: 'prop', key: 'tag', shorthand: false, value: arrow }],
|
|
148
|
+
} as ParsedExpr
|
|
149
|
+
const badCall: ParsedExpr = {
|
|
150
|
+
kind: 'call',
|
|
151
|
+
callee: { kind: 'identifier', name: 'demo' },
|
|
152
|
+
args: [base, badParams],
|
|
153
|
+
}
|
|
154
|
+
expect(isSupported(badCall, { loweringMatchers: ms }).supported).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('isSupportedValue behaves the same way as isSupported for a matched nested call', () => {
|
|
158
|
+
const ms = matchers()
|
|
159
|
+
const cond: ParsedExpr = {
|
|
160
|
+
kind: 'conditional',
|
|
161
|
+
test: { kind: 'identifier', name: 'ok' },
|
|
162
|
+
consequent: demoCall,
|
|
163
|
+
alternate: { kind: 'literal', value: '/fallback', literalType: 'string' },
|
|
164
|
+
}
|
|
165
|
+
expect(isSupportedValue(cond, { loweringMatchers: ms }).supported).toBe(true)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('emitParsedExpr dispatch with an emitter.lowering seam (#2843)', () => {
|
|
170
|
+
// A stub emitter exercising only what the `call` case needs; every other
|
|
171
|
+
// method throws so an accidental fallthrough fails loudly in the test.
|
|
172
|
+
function stubEmitter(lowering?: LoweringEmitter): ParsedExprEmitter {
|
|
173
|
+
const unimplemented = (name: string) => () => {
|
|
174
|
+
throw new Error(`unexpected emitter.${name} call in this test`)
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
identifier: (name: string) => `ID(${name})`,
|
|
178
|
+
literal: (v) => `LIT(${JSON.stringify(v)})`,
|
|
179
|
+
call: (callee, args, emit) => `CALL(${emit(callee)}, ${args.map(emit).join(', ')})`,
|
|
180
|
+
member: unimplemented('member'),
|
|
181
|
+
indexAccess: unimplemented('indexAccess'),
|
|
182
|
+
binary: unimplemented('binary'),
|
|
183
|
+
unary: unimplemented('unary'),
|
|
184
|
+
logical: unimplemented('logical'),
|
|
185
|
+
conditional: unimplemented('conditional'),
|
|
186
|
+
templateLiteral: unimplemented('templateLiteral'),
|
|
187
|
+
callbackMethod: unimplemented('callbackMethod'),
|
|
188
|
+
arrow: unimplemented('arrow'),
|
|
189
|
+
regex: unimplemented('regex'),
|
|
190
|
+
arrayLiteral: unimplemented('arrayLiteral'),
|
|
191
|
+
objectLiteral: (_props, raw) => `OBJ(${raw})`,
|
|
192
|
+
arrayMethod: unimplemented('arrayMethod'),
|
|
193
|
+
flatMethod: unimplemented('flatMethod'),
|
|
194
|
+
unsupported: (raw) => `UNSUPPORTED(${raw})`,
|
|
195
|
+
lowering,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
test('a matched call renders via emitter.lowering.render, not the ordinary call() dispatch', () => {
|
|
200
|
+
const ms = matchers()
|
|
201
|
+
const lowering: LoweringEmitter = {
|
|
202
|
+
matchers: ms,
|
|
203
|
+
render: (node, emit) => {
|
|
204
|
+
if (node.kind !== 'guard-list') return null
|
|
205
|
+
const parts = node.triples.map(t => `${t.key}=${emit(t.value)}`).join('&')
|
|
206
|
+
return `QUERY(${emit(node.base)}, ${parts})`
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
expect(emitParsedExpr(demoCall, stubEmitter(lowering))).toBe('QUERY(LIT("/x"), tag=LIT("a"))')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('without emitter.lowering, the same call falls through to the ordinary call() dispatch', () => {
|
|
213
|
+
expect(emitParsedExpr(demoCall, stubEmitter())).toBe('CALL(ID(demo), LIT("/x"), OBJ({ tag: "a" }))')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('lowering present but the node kind/helper has no mapping falls through to call()', () => {
|
|
217
|
+
const ms = matchers()
|
|
218
|
+
const lowering: LoweringEmitter = { matchers: ms, render: () => null }
|
|
219
|
+
expect(emitParsedExpr(demoCall, stubEmitter(lowering))).toBe('CALL(ID(demo), LIT("/x"), OBJ({ tag: "a" }))')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('an unmatched call is unaffected by an emitter.lowering seam', () => {
|
|
223
|
+
const ms = matchers()
|
|
224
|
+
const lowering: LoweringEmitter = { matchers: ms, render: () => 'SHOULD NOT BE REACHED' }
|
|
225
|
+
const unmatchedCall: ParsedExpr = {
|
|
226
|
+
kind: 'call',
|
|
227
|
+
callee: { kind: 'identifier', name: 'other' },
|
|
228
|
+
args: [base],
|
|
229
|
+
}
|
|
230
|
+
expect(emitParsedExpr(unmatchedCall, stubEmitter(lowering))).toBe('CALL(ID(other), LIT("/x"))')
|
|
231
|
+
})
|
|
232
|
+
})
|