@barefootjs/jsx 0.33.4 → 0.33.6

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 (73) 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/analyzer.d.ts.map +1 -1
  4. package/dist/errors.d.ts +1 -0
  5. package/dist/errors.d.ts.map +1 -1
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1298 -1106
  9. package/dist/ir-to-client-js/child-components.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/component-scope.d.ts +20 -0
  12. package/dist/ir-to-client-js/component-scope.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/shared.d.ts +4 -3
  15. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/csr-substitute.d.ts +35 -9
  18. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/emit-reactive.d.ts +10 -0
  20. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/html-template.d.ts +49 -2
  22. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/imports.d.ts +2 -2
  24. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/rewrite-props-object.d.ts +12 -1
  27. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  28. package/dist/jsx-to-ir.d.ts.map +1 -1
  29. package/dist/prop-rewrite.d.ts +5 -2
  30. package/dist/prop-rewrite.d.ts.map +1 -1
  31. package/dist/props-binding.d.ts +49 -11
  32. package/dist/props-binding.d.ts.map +1 -1
  33. package/dist/types.d.ts +6 -5
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +2 -2
  36. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +15 -15
  37. package/src/__tests__/aliased-import-child-registry-2777.test.ts +112 -0
  38. package/src/__tests__/date-lowering.test.ts +18 -4
  39. package/src/__tests__/inner-loop-mathml-namespace.test.ts +1 -1
  40. package/src/__tests__/inner-loop-svg-namespace.test.ts +4 -4
  41. package/src/__tests__/issue-2724-prop-alias-loop-not-static.test.ts +355 -0
  42. package/src/__tests__/issue-2737-props-object-alias-template.test.ts +86 -0
  43. package/src/__tests__/issue-2756-input-skeleton-bails.test.ts +123 -0
  44. package/src/__tests__/issue-2778-getter-alias-csr-template.test.ts +113 -0
  45. package/src/__tests__/issue-2806-rest-bag-template-rewrite.test.ts +136 -0
  46. package/src/__tests__/issue-2835-props-type-member-name-collision.test.ts +259 -0
  47. package/src/__tests__/loop-item-root-scope.test.ts +7 -3
  48. package/src/__tests__/markup-prop-brand.test.ts +165 -26
  49. package/src/__tests__/namespace-import-primitive.test.ts +244 -0
  50. package/src/__tests__/rewrite-destructured-props.test.ts +53 -0
  51. package/src/__tests__/static-loop-csr-materialize.test.ts +2 -2
  52. package/src/adapters/child-scope.ts +23 -0
  53. package/src/analyzer.ts +130 -0
  54. package/src/errors.ts +14 -1
  55. package/src/index.ts +7 -0
  56. package/src/ir-to-client-js/child-components.ts +8 -1
  57. package/src/ir-to-client-js/collect-elements.ts +10 -38
  58. package/src/ir-to-client-js/component-scope.ts +60 -0
  59. package/src/ir-to-client-js/compute-inlinability.ts +46 -11
  60. package/src/ir-to-client-js/control-flow/shared.ts +12 -5
  61. package/src/ir-to-client-js/control-flow/stringify/loop.ts +8 -2
  62. package/src/ir-to-client-js/csr-substitute.ts +82 -16
  63. package/src/ir-to-client-js/emit-reactive.ts +29 -13
  64. package/src/ir-to-client-js/emit-registration.ts +1 -1
  65. package/src/ir-to-client-js/html-template.ts +141 -70
  66. package/src/ir-to-client-js/imports.ts +4 -0
  67. package/src/ir-to-client-js/index.ts +34 -23
  68. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +7 -2
  69. package/src/ir-to-client-js/rewrite-props-object.ts +14 -0
  70. package/src/jsx-to-ir.ts +200 -42
  71. package/src/prop-rewrite.ts +23 -5
  72. package/src/props-binding.ts +74 -18
  73. 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
  })
@@ -159,27 +159,18 @@ describe('markup-prop brand (#2651)', () => {
159
159
  expect(clientJs).toMatch(/get header\(\)\s*\{\s*return __slot\(/)
160
160
  })
161
161
 
162
- // KNOWN BUG (#2702) pins the CURRENT (broken) emission, not the
163
- // correct one. `isSingleElementJsxChildren` (`ir-to-client-js/collect-
164
- // elements.ts`) only brands the single-element jsx-children shape;
165
- // #2651's PR body enumerated "conditional-in-fragment" as deliberately
166
- // out of that PR's scope, and #2702 confirms it is actually broken, not
167
- // merely unbranded-but-harmless: the child's own `escapeTextOrNode`
168
- // reactive effect re-escapes the chosen branch's HTML as literal text
169
- // the moment it first runs (real DOM corruption, verified directly
170
- // see `jsx-element-prop-fragment-conditional`'s fixture docstring,
171
- // `packages/adapter-tests/fixtures/`, for the full mechanism and why no
172
- // other shared conformance suite observes it: SSR is genuinely correct,
173
- // and CSR conformance's `createEffect` mock never runs the effect).
174
- //
175
- // Graduation: once `bfMarkup()` branding is extended to this multi-part
176
- // shape, this assertion starts failing — replace it with a positive
177
- // `bfMarkup(...)`-wrapped assertion (mirroring test (b) above) and
178
- // delete this comment block; that is the fixture's own graduation
179
- // trigger too (its `expectedHtml` is already the correct SSR output, so
180
- // no fixture change is needed, only this pin's removal and #2667's
181
- // BF021 suggestion regaining the fragment-wrap escape).
182
- test('(h) KNOWN BUG (#2702): a conditional-in-fragment jsx-children prop reaches initChild UNbranded', () => {
162
+ // #2702: a conditional-in-fragment jsx-children prop now brands EACH
163
+ // element branch with `bfMarkup()` (`jsxChildrenPropGetterExpr`,
164
+ // `ir-to-client-js/html-template.ts`) the shape `isSingleElementJsx
165
+ // Children`'s narrower single-element gate (#2651) left unbranded,
166
+ // which corrupted the DOM the moment the child's own `escapeTextOrNode`
167
+ // reactive effect first ran (re-escaped the chosen branch's HTML as
168
+ // literal text; see `jsx-element-prop-fragment-conditional`'s fixture
169
+ // docstring for the full mechanism). Branding each LEAF rather than the
170
+ // whole ternary keeps a non-element branch (text/expression/`&&`'s
171
+ // `''`) unbranded and thus still escaped normally — see the sibling
172
+ // tests below.
173
+ test('(h) a conditional-in-fragment jsx-children prop brands each element branch with bfMarkup()', () => {
183
174
  const source = `
184
175
  'use client'
185
176
  import { createSignal } from '@barefootjs/client'
@@ -201,10 +192,158 @@ describe('markup-prop brand (#2651)', () => {
201
192
  }
202
193
  `
203
194
  const clientJs = clientJsFor(source)
204
- // Bug: the getter returns a bare template-literal ternary — NOT
205
- // wrapped in bfMarkup(...). If this ever starts matching bfMarkup(...)
206
- // the bug is fixed; see the graduation note above.
207
- expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*`<a>x<\/a>`\s*:\s*`<b>y<\/b>`\s*\}/)
208
- expect(clientJs).not.toMatch(/get header\(\)\s*\{\s*return bfMarkup\(/)
195
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*bfMarkup\(`<b>y<\/b>`\)\s*\}/)
196
+ })
197
+
198
+ test('(i) a conditional-in-fragment jsx-children prop leaves a non-element branch unbranded', () => {
199
+ const source = `
200
+ 'use client'
201
+ import { createSignal } from '@barefootjs/client'
202
+ export function Card(props: { header?: any }) {
203
+ return <section><header>{props.header}</header></section>
204
+ }
205
+ export function label() { return 'x' }
206
+ export function TextBranch() {
207
+ const [cond, setCond] = createSignal(true)
208
+ return <Card header={<>{cond() ? <a>x</a> : 'plain'}</>} />
209
+ }
210
+ export function ExpressionBranch() {
211
+ const [cond, setCond] = createSignal(true)
212
+ return <Card header={<>{cond() ? <a>x</a> : label()}</>} />
213
+ }
214
+ export function LogicalAnd() {
215
+ const [cond, setCond] = createSignal(true)
216
+ return <Card header={<>{cond() && <a>x</a>}</>} />
217
+ }
218
+ `
219
+ const clientJs = clientJsFor(source)
220
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*'plain'\s*\}/)
221
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*label\(\)\s*\}/)
222
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*''\s*\}/)
223
+ })
224
+
225
+ test('(j) nested conditionals brand every element branch', () => {
226
+ const source = `
227
+ 'use client'
228
+ import { createSignal } from '@barefootjs/client'
229
+ export function Card(props: { header?: any }) {
230
+ return <section><header>{props.header}</header></section>
231
+ }
232
+ export function P() {
233
+ const [a, setA] = createSignal(true)
234
+ const [b, setB] = createSignal(true)
235
+ return <Card header={<>{a() ? (b() ? <a>x</a> : <c>z</c>) : <b>y</b>}</>} />
236
+ }
237
+ `
238
+ const clientJs = clientJsFor(source)
239
+ expect(clientJs).toMatch(
240
+ /get header\(\)\s*\{\s*return a\(\)\s*\?\s*b\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*bfMarkup\(`<c>z<\/c>`\)\s*:\s*bfMarkup\(`<b>y<\/b>`\)\s*\}/,
241
+ )
242
+ })
243
+
244
+ // A multi-child fragment (more than one flattened part) has no `bfMarkup`/
245
+ // `escapeTextOrNode` array contract — stays unbranded, unchanged from
246
+ // before #2702 (a separate, pre-existing gap, not this fix's scope).
247
+ test('(k) a multi-child fragment jsx-children prop stays unbranded', () => {
248
+ const source = `
249
+ 'use client'
250
+ export function Card(props: { header?: any }) {
251
+ return <section><header>{props.header}</header></section>
252
+ }
253
+ export function P() {
254
+ return <Card header={<>text<strong>x</strong></>} />
255
+ }
256
+ `
257
+ const clientJs = clientJsFor(source)
258
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return \["text", `<strong>x<\/strong>`\]\s*\}/)
259
+ })
260
+
261
+ // An explicit `children={<jsx/>}` prop stays unbranded regardless of
262
+ // shape — its consumer is a bare `{children}` passthrough with no
263
+ // unwrap call (test (b)'s comment above explains the same exclusion).
264
+ // pullfrog review, PR #2820: `jsxChildrenPropGetterExpr` (the shared
265
+ // brand-decision door test (h)-(k) pin) wasn't wired into EVERY builder
266
+ // that constructs the identical `get <propName>() { return <expr> }`
267
+ // getter — `buildComponentPropsExpr` (`control-flow/shared.ts`, shared by
268
+ // loop AND conditional-branch component reconciliation) and
269
+ // `buildStaticPropsExpr` (`build-static-array-child-init.ts`) both still
270
+ // called the plain, unbranded `irChildrenToJsExpr` directly. The same
271
+ // conditional-in-fragment shape test (h) pins for a top-level component
272
+ // corrupted the DOM at hydrate time when the component was instead
273
+ // instantiated inside a `.map()` loop.
274
+ test('(m) a conditional-in-fragment jsx-children prop is branded for a component instantiated inside a .map() loop', () => {
275
+ const source = `
276
+ 'use client'
277
+ import { createSignal } from '@barefootjs/client'
278
+ export function Card(props: { header?: any }) {
279
+ return <header>{props.header}</header>
280
+ }
281
+ export function List() {
282
+ const [items, setItems] = createSignal([{ id: 1 }])
283
+ return (
284
+ <div>
285
+ {items().map(item => (
286
+ <Card key={item.id} header={<>{item.id > 0 ? <a>x</a> : <b>y</b>}</>} />
287
+ ))}
288
+ </div>
289
+ )
290
+ }
291
+ `
292
+ const clientJs = clientJsFor(source)
293
+ // Both the reactive-patch (initChild) and initial-mount (createComponent)
294
+ // paths through mapArray's callback must brand each element branch.
295
+ expect(clientJs).toMatch(
296
+ /initChild\('Card', __existing, \{ get header\(\) \{ return item\(\)\.id > 0 \? bfMarkup\(`<a>x<\/a>`\) : bfMarkup\(`<b>y<\/b>`\) \} \}\)/,
297
+ )
298
+ expect(clientJs).toMatch(
299
+ /createComponent\('Card', \{ get header\(\) \{ return item\(\)\.id > 0 \? bfMarkup\(`<a>x<\/a>`\) : bfMarkup\(`<b>y<\/b>`\) \} \}, item\(\)\.id\)/,
300
+ )
301
+ })
302
+
303
+ // pullfrog review, PR #2820: test (m) covers the SIGNAL-driven `.map()`
304
+ // loop (`buildComponentPropsExpr`); `buildStaticPropsExpr`
305
+ // (`build-static-array-child-init.ts`) — the sibling builder for a
306
+ // STATIC, non-signal array `.map()` — got the identical fix but no
307
+ // dedicated test, which is exactly the untested-call-site pattern that
308
+ // let #2702's original corruption go uncaught. Pins the hydrate-time
309
+ // `initChild` path this builder feeds.
310
+ test('(n) a conditional-in-fragment jsx-children prop is branded for a component instantiated inside a static (non-signal) array .map()', () => {
311
+ const source = `
312
+ 'use client'
313
+ import { createSignal } from '@barefootjs/client'
314
+ export function Card(props: { header?: any }) {
315
+ return <header>{props.header}</header>
316
+ }
317
+ export function List() {
318
+ const [cond, setCond] = createSignal(true)
319
+ return (
320
+ <div>
321
+ {[{ id: 1 }].map(item => (
322
+ <Card key={item.id} header={<>{cond() ? <a>x</a> : <b>y</b>}</>} />
323
+ ))}
324
+ </div>
325
+ )
326
+ }
327
+ `
328
+ const clientJs = clientJsFor(source)
329
+ expect(clientJs).toMatch(
330
+ /initChild\('Card', childScope, \{ get header\(\) \{ return cond\(\) \? bfMarkup\(`<a>x<\/a>`\) : bfMarkup\(`<b>y<\/b>`\) \} \}\)/,
331
+ )
332
+ })
333
+
334
+ test('(l) an explicit children prop with a conditional stays unbranded', () => {
335
+ const source = `
336
+ 'use client'
337
+ import { createSignal } from '@barefootjs/client'
338
+ export function Card(props: { children?: any }) {
339
+ return <section>{props.children}</section>
340
+ }
341
+ export function P() {
342
+ const [cond, setCond] = createSignal(true)
343
+ return <Card children={<>{cond() ? <a>x</a> : <b>y</b>}</>} />
344
+ }
345
+ `
346
+ const clientJs = clientJsFor(source)
347
+ expect(clientJs).toMatch(/get children\(\)\s*\{\s*return cond\(\)\s*\?\s*`<a>x<\/a>`\s*:\s*`<b>y<\/b>`\s*\}/)
209
348
  })
210
349
  })