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