@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.
Files changed (83) hide show
  1. package/dist/adapters/child-scope.d.ts +13 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -1
  3. package/dist/adapters/parsed-expr-emitter.d.ts +17 -0
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/errors.d.ts +1 -0
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/expression-parser.d.ts +17 -4
  9. package/dist/expression-parser.d.ts.map +1 -1
  10. package/dist/index.d.ts +4 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1399 -1173
  13. package/dist/ir-to-client-js/child-components.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/component-scope.d.ts +20 -0
  16. package/dist/ir-to-client-js/component-scope.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +4 -3
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/csr-substitute.d.ts +35 -9
  22. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-reactive.d.ts +10 -0
  24. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/html-template.d.ts +49 -2
  26. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/imports.d.ts +2 -2
  28. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/rewrite-props-object.d.ts +12 -1
  31. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  32. package/dist/jsx-to-ir.d.ts.map +1 -1
  33. package/dist/lowering-registry.d.ts +12 -0
  34. package/dist/lowering-registry.d.ts.map +1 -1
  35. package/dist/prop-rewrite.d.ts +5 -2
  36. package/dist/prop-rewrite.d.ts.map +1 -1
  37. package/dist/props-binding.d.ts +49 -11
  38. package/dist/props-binding.d.ts.map +1 -1
  39. package/dist/types.d.ts +6 -5
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +2 -2
  42. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +15 -15
  43. package/src/__tests__/aliased-import-child-registry-2777.test.ts +112 -0
  44. package/src/__tests__/date-lowering.test.ts +18 -4
  45. package/src/__tests__/inner-loop-mathml-namespace.test.ts +1 -1
  46. package/src/__tests__/inner-loop-svg-namespace.test.ts +4 -4
  47. package/src/__tests__/issue-2724-prop-alias-loop-not-static.test.ts +355 -0
  48. package/src/__tests__/issue-2737-props-object-alias-template.test.ts +86 -0
  49. package/src/__tests__/issue-2756-input-skeleton-bails.test.ts +123 -0
  50. package/src/__tests__/issue-2778-getter-alias-csr-template.test.ts +113 -0
  51. package/src/__tests__/issue-2806-rest-bag-template-rewrite.test.ts +136 -0
  52. package/src/__tests__/issue-2835-props-type-member-name-collision.test.ts +259 -0
  53. package/src/__tests__/loop-item-root-scope.test.ts +7 -3
  54. package/src/__tests__/lowering-value-position.test.ts +232 -0
  55. package/src/__tests__/markup-prop-brand.test.ts +165 -26
  56. package/src/__tests__/namespace-import-primitive.test.ts +244 -0
  57. package/src/__tests__/rewrite-destructured-props.test.ts +53 -0
  58. package/src/__tests__/static-loop-csr-materialize.test.ts +2 -2
  59. package/src/adapters/child-scope.ts +23 -0
  60. package/src/adapters/parsed-expr-emitter.ts +43 -0
  61. package/src/analyzer.ts +130 -0
  62. package/src/errors.ts +14 -1
  63. package/src/expression-parser.ts +63 -29
  64. package/src/index.ts +9 -1
  65. package/src/ir-to-client-js/child-components.ts +8 -1
  66. package/src/ir-to-client-js/collect-elements.ts +10 -38
  67. package/src/ir-to-client-js/component-scope.ts +60 -0
  68. package/src/ir-to-client-js/compute-inlinability.ts +46 -11
  69. package/src/ir-to-client-js/control-flow/shared.ts +12 -5
  70. package/src/ir-to-client-js/control-flow/stringify/loop.ts +8 -2
  71. package/src/ir-to-client-js/csr-substitute.ts +82 -16
  72. package/src/ir-to-client-js/emit-reactive.ts +29 -13
  73. package/src/ir-to-client-js/emit-registration.ts +1 -1
  74. package/src/ir-to-client-js/html-template.ts +141 -70
  75. package/src/ir-to-client-js/imports.ts +4 -0
  76. package/src/ir-to-client-js/index.ts +34 -23
  77. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +7 -2
  78. package/src/ir-to-client-js/rewrite-props-object.ts +14 -0
  79. package/src/jsx-to-ir.ts +200 -42
  80. package/src/lowering-registry.ts +21 -0
  81. package/src/prop-rewrite.ts +23 -5
  82. package/src/props-binding.ts +74 -18
  83. 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 gets no slot suffix', () => {
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
- // Static-array loop item root: no slot suffix it owns its own random id.
211
- expect(clientJs!.content).not.toMatch(/renderChild\('row',[^)]*'s\d+'\)/)
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
+ })