@barefootjs/jsx 0.34.0 → 0.35.1

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 (148) hide show
  1. package/dist/analyzer-context.d.ts.map +1 -1
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1072 -903
  6. package/dist/ir-to-client-js/collect-elements.d.ts +22 -2
  7. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts +14 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +11 -0
  14. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +6 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts +52 -0
  19. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
  20. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +5 -2
  21. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +24 -44
  23. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
  25. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
  27. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +14 -0
  30. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
  32. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
  34. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +6 -14
  37. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +1 -3
  39. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +19 -3
  41. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  43. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +5 -1
  44. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  45. package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
  46. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  47. package/dist/ir-to-client-js/emit-reactive.d.ts +54 -0
  48. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  49. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  50. package/dist/ir-to-client-js/html-template.d.ts +2 -2
  51. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  52. package/dist/ir-to-client-js/imports.d.ts +2 -2
  53. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  54. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  55. package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
  56. package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
  57. package/dist/ir-to-client-js/reactivity.d.ts +62 -18
  58. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  59. package/dist/ir-to-client-js/safe-html.d.ts +158 -0
  60. package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
  61. package/dist/ir-to-client-js/types.d.ts +18 -2
  62. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  63. package/dist/ir-to-client-js/utils.d.ts +34 -1
  64. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  65. package/dist/jsx-to-ir.d.ts.map +1 -1
  66. package/dist/prop-rewrite.d.ts +8 -3
  67. package/dist/prop-rewrite.d.ts.map +1 -1
  68. package/dist/props-binding.d.ts +22 -1
  69. package/dist/props-binding.d.ts.map +1 -1
  70. package/dist/reactivity-checker.d.ts +18 -0
  71. package/dist/reactivity-checker.d.ts.map +1 -1
  72. package/dist/ssr-defaults.d.ts.map +1 -1
  73. package/dist/types.d.ts +5 -10
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +2 -2
  76. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +138 -22
  77. package/src/__tests__/binding-scope-ratchet.test.ts +25 -2
  78. package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
  79. package/src/__tests__/child-prop-fallback-wrap.test.ts +4 -2
  80. package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
  81. package/src/__tests__/compiler-stress-1244.test.ts +8 -8
  82. package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
  83. package/src/__tests__/flatmap-segments.test.ts +1 -1
  84. package/src/__tests__/issue-2868-cond-arm-tag-collision.test.ts +185 -0
  85. package/src/__tests__/issue-2869-attr-dedup-guard.test.ts +141 -0
  86. package/src/__tests__/lazy-conditional.test.ts +52 -12
  87. package/src/__tests__/lazy-preamble.test.ts +11 -11
  88. package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
  89. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
  90. package/src/__tests__/loop-index-reactivity.test.ts +208 -0
  91. package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
  92. package/src/__tests__/map-arbitrary-body.test.ts +1 -1
  93. package/src/__tests__/map-body-brand-checker.test.ts +176 -0
  94. package/src/__tests__/nested-loop-index-param.test.ts +41 -8
  95. package/src/__tests__/nested-loop-non-outer-reactivity.test.ts +177 -0
  96. package/src/__tests__/nested-loop-plain.test.ts +39 -25
  97. package/src/__tests__/nested-loop-reactive-attrs.test.ts +16 -6
  98. package/src/__tests__/preamble-branch-arm-rerun.test.ts +135 -0
  99. package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
  100. package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
  101. package/src/__tests__/safe-html-splice-door.test.ts +61 -0
  102. package/src/__tests__/ssr-defaults.test.ts +59 -0
  103. package/src/__tests__/style-css-var-reactive.test.ts +4 -1
  104. package/src/analyzer-context.ts +12 -10
  105. package/src/analyzer.ts +4 -0
  106. package/src/index.ts +8 -2
  107. package/src/ir-to-client-js/collect-elements.ts +126 -32
  108. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
  109. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
  110. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +12 -6
  111. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +36 -59
  112. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +25 -4
  113. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +36 -9
  114. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
  115. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +116 -0
  116. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +23 -8
  117. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +24 -45
  118. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
  119. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
  120. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
  121. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +14 -0
  122. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
  123. package/src/ir-to-client-js/control-flow/shared.ts +40 -17
  124. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
  125. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +20 -66
  126. package/src/ir-to-client-js/control-flow/stringify/insert.ts +4 -2
  127. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +22 -15
  128. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +53 -11
  129. package/src/ir-to-client-js/control-flow/stringify/loop.ts +4 -2
  130. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +29 -18
  131. package/src/ir-to-client-js/csr-substitute.ts +29 -0
  132. package/src/ir-to-client-js/emit-reactive.ts +92 -6
  133. package/src/ir-to-client-js/emit-registration.ts +2 -4
  134. package/src/ir-to-client-js/html-template.ts +67 -245
  135. package/src/ir-to-client-js/imports.ts +3 -0
  136. package/src/ir-to-client-js/index.ts +4 -4
  137. package/src/ir-to-client-js/markup-slots.ts +25 -0
  138. package/src/ir-to-client-js/reactivity.ts +73 -27
  139. package/src/ir-to-client-js/safe-html.ts +243 -0
  140. package/src/ir-to-client-js/types.ts +19 -3
  141. package/src/ir-to-client-js/utils.ts +58 -10
  142. package/src/jsx-to-ir.ts +136 -24
  143. package/src/prop-rewrite.ts +8 -4
  144. package/src/props-binding.ts +37 -1
  145. package/src/reactivity-checker.ts +60 -0
  146. package/src/ssr-defaults.ts +45 -0
  147. package/src/types.ts +5 -10
  148. package/src/__tests__/text-slot-escaping.test.ts +0 -65
@@ -0,0 +1,177 @@
1
+ /**
2
+ * BarefootJS Compiler — nested `.map()` reactivity when the inner array
3
+ * (or the row's own bindings) does NOT derive from the outer loop's item.
4
+ *
5
+ * #2865: before this fix, `build-inner-loop.ts` decided "reactive vs
6
+ * static" for a nested loop by checking only whether `inner.array`
7
+ * textually referenced the OUTER loop's item — `refsParent`. Anything
8
+ * else the inner loop depended on fell through to a hydration-only
9
+ * `forEach` (`emitStatic` in `stringify/inner-loop.ts`) that never wired
10
+ * a single `createEffect`, regardless of whether the array itself, or a
11
+ * text/attr/conditional inside the row, was genuinely reactive. That
12
+ * fork has been removed — every nested loop now gets the full reactive
13
+ * `mapArray` emission unconditionally.
14
+ *
15
+ * This file pins the previously-frozen shapes directly (as opposed to
16
+ * `nested-loop-plain.test.ts` / `nested-loop-index-param.test.ts`, whose
17
+ * relevant cases were originally written to pin the OLD static shape and
18
+ * have been flipped in place).
19
+ */
20
+
21
+ import { describe, test, expect } from 'bun:test'
22
+ import { compileJSX } from '../compiler'
23
+ import { TestAdapter } from '../adapters/test-adapter'
24
+
25
+ const adapter = new TestAdapter()
26
+
27
+ function clientJsFor(source: string): string {
28
+ const result = compileJSX(source, 'Repro.tsx', { 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
+ describe('nested .map() reactivity independent of the outer item (#2865)', () => {
36
+ test('inner array read from a component signal (not the outer item) wires a nested mapArray', () => {
37
+ const content = clientJsFor(`
38
+ 'use client'
39
+ import { createSignal } from '@barefootjs/client'
40
+ export function Repro() {
41
+ const [rows] = createSignal([{ id: 1 }])
42
+ const [tags] = createSignal(['x', 'y'])
43
+ return (
44
+ <div>
45
+ {rows().map(row => (
46
+ <div key={row.id}>
47
+ <ul>
48
+ {tags().map(t => <li key={t}>{t}</li>)}
49
+ </ul>
50
+ </div>
51
+ ))}
52
+ </div>
53
+ )
54
+ }
55
+ `)
56
+
57
+ // The old bug emitted `tags().forEach(...)` with no mapArray/createEffect
58
+ // at all — appending a tag never reached the DOM.
59
+ expect(content).toMatch(/mapArray\(\(\) => tags\(\) \|\| \[\], /)
60
+ expect(content).not.toMatch(/tags\(\)\.forEach\(/)
61
+ })
62
+
63
+ test('inner array read from a memo (not the outer item) wires a nested mapArray', () => {
64
+ const content = clientJsFor(`
65
+ 'use client'
66
+ import { createSignal, createMemo } from '@barefootjs/client'
67
+ export function Repro() {
68
+ const [rows] = createSignal([{ id: 1 }])
69
+ const [count] = createSignal(2)
70
+ const tags = createMemo(() => Array.from({ length: count() }, (_, i) => \`t\${i}\`))
71
+ return (
72
+ <div>
73
+ {rows().map(row => (
74
+ <div key={row.id}>
75
+ <ul>
76
+ {tags().map(t => <li key={t}>{t}</li>)}
77
+ </ul>
78
+ </div>
79
+ ))}
80
+ </div>
81
+ )
82
+ }
83
+ `)
84
+
85
+ expect(content).toMatch(/mapArray\(\(\) => tags\(\) \|\| \[\], /)
86
+ expect(content).not.toMatch(/tags\(\)\.forEach\(/)
87
+ })
88
+
89
+ test('a row-content signal read (text) inside a LITERAL inner array still wires createEffect', () => {
90
+ // Even when the ARRAY itself is a plain constant (never needs to
91
+ // reconcile), a text/attr/conditional inside the row that reads a
92
+ // component signal must still update. The old static emitter never
93
+ // read `reactiveTexts`/`reactiveAttrs`/`conditionals` at all, so this
94
+ // froze independent of whether the array was reactive.
95
+ const content = clientJsFor(`
96
+ 'use client'
97
+ import { createSignal } from '@barefootjs/client'
98
+ const TAGS = ['x', 'y']
99
+ export function Repro() {
100
+ const [rows] = createSignal([{ id: 1 }])
101
+ const [count, setCount] = createSignal(0)
102
+ return (
103
+ <div onClick={() => setCount(c => c + 1)}>
104
+ {rows().map(row => (
105
+ <div key={row.id}>
106
+ <ul>
107
+ {TAGS.map(t => <li key={t}>{t}: {count()}</li>)}
108
+ </ul>
109
+ </div>
110
+ ))}
111
+ </div>
112
+ )
113
+ }
114
+ `)
115
+
116
+ expect(content).toMatch(/mapArray\(\(\) => TAGS \|\| \[\], /)
117
+ expect(content).toContain('createEffect(')
118
+ expect(content).not.toMatch(/TAGS\.forEach\(/)
119
+ })
120
+
121
+ test('destructured outer param used by the inner array no longer throws ReferenceError at row construction', () => {
122
+ // Before #2865: static mode skipped the destructure-unwrap statement,
123
+ // so the emitted `forEach` referenced a bare `tags` identifier that
124
+ // was never declared in scope.
125
+ const content = clientJsFor(`
126
+ 'use client'
127
+ import { createSignal } from '@barefootjs/client'
128
+ interface Row { id: number; tags: string[] }
129
+ export function Repro() {
130
+ const [rows] = createSignal<Row[]>([{ id: 1, tags: ['a', 'b'] }])
131
+ return (
132
+ <div>
133
+ {rows().map(({ id, tags }) => (
134
+ <div key={id}>
135
+ <ul>
136
+ {tags.map(t => <li key={t}>{t}</li>)}
137
+ </ul>
138
+ </div>
139
+ ))}
140
+ </div>
141
+ )
142
+ }
143
+ `)
144
+
145
+ // `tags` must resolve through the outer row accessor
146
+ // (`__bfItem().tags`) — not a bare free identifier that was never
147
+ // declared in scope (the old static-mode bug: the destructured
148
+ // pattern names `tags` for the SSR template, but the reactive
149
+ // renderItem's own param is the whole-item accessor, and every
150
+ // reference to a destructured name is rewritten to go through it).
151
+ expect(content).toMatch(/mapArray\(\(\) => __bfItem\(\)\.tags \|\| \[\], /)
152
+ })
153
+
154
+ test('outer-index-derived inner array wires a nested mapArray', () => {
155
+ const content = clientJsFor(`
156
+ 'use client'
157
+ import { createSignal } from '@barefootjs/client'
158
+ export function Repro() {
159
+ const [matrix] = createSignal([['a', 'b'], ['c']])
160
+ return (
161
+ <div>
162
+ {matrix().map((row, i) => (
163
+ <div key={i}>
164
+ <ul>
165
+ {matrix()[i].map(cell => <li key={cell}>{cell}</li>)}
166
+ </ul>
167
+ </div>
168
+ ))}
169
+ </div>
170
+ )
171
+ }
172
+ `)
173
+
174
+ expect(content).toContain('mapArray(')
175
+ expect(content).not.toMatch(/\.forEach\(\(cell,/)
176
+ })
177
+ })
@@ -138,18 +138,21 @@ describe('plain nested loops without conditional wrapper', () => {
138
138
  })
139
139
 
140
140
  test('regression: 3-level nested loop wires mapArray at the deepest level (not forEach)', () => {
141
- // Before the fix, `emitInnerLoopSetup` decided "is this loop reactive?"
142
- // by checking the IR's `inner.refsOuterParam` — a flag set at collect
143
- // time against the *outermost* loop's param only. At depth 2+, the array
144
- // expression typically references the *immediate* parent (e.g.
145
- // `g.items` inside `t.groups.map(g => ...)` body), so the check failed
146
- // and the loop fell through to the static `forEach` branch. Result:
147
- // additions / removals at the deepest level silently never reached
148
- // the DOM.
141
+ // Originally (the O-8 narrowing fix), `emitInnerLoopSetup` decided "is
142
+ // this loop reactive?" by checking the IR's `inner.refsOuterParam` — a
143
+ // flag set at collect time against the *outermost* loop's param only.
144
+ // At depth 2+, the array expression typically references the
145
+ // *immediate* parent (e.g. `g.items` inside `t.groups.map(g => ...)`
146
+ // body), so the check failed and the loop fell through to the static
147
+ // `forEach` branch. Result: additions / removals at the deepest level
148
+ // silently never reached the DOM.
149
149
  //
150
- // Fix: re-check dynamically at each level against the parent param
151
- // passed in, and narrow the parent param when recursing into child
152
- // levels.
150
+ // O-8's own fix: re-check dynamically at each level against the parent
151
+ // param passed in, and narrow the parent param when recursing into
152
+ // child levels. #2865 later removed the reactive/static fork (and
153
+ // `refsOuterParam` with it) entirely — every level is unconditionally
154
+ // reactive now — so this assertion holds trivially, but the test stays
155
+ // as a regression pin on "every depth gets its own `mapArray`."
153
156
  const source = `
154
157
  'use client'
155
158
  import { createSignal } from '@barefootjs/client'
@@ -293,10 +296,13 @@ describe('plain nested loops without conditional wrapper', () => {
293
296
  // locals — `initChild`'s prop getter would throw `ReferenceError` when
294
297
  // the child component first mounted.
295
298
  //
296
- // Fix: thread `inner.mapPreamble` through `InnerLoopStaticEmit` and emit
297
- // it (raw — `forEach`'s param is the literal item, not a signal accessor)
298
- // at the top of the forEach body so the component setup can resolve the
299
- // locals.
299
+ // Fix: thread `inner.mapPreamble` through the static-array-child-init
300
+ // emission (`build-static-array-child-init.ts` the OUTER array here
301
+ // is a literal, so this is unrelated to #2865's nested-loop reactive/
302
+ // static fork, which only ever applied under a REACTIVE outer loop)
303
+ // and emit it (raw — `forEach`'s param is the literal item, not a
304
+ // signal accessor) at the top of the forEach body so the component
305
+ // setup can resolve the locals.
300
306
  const source = `
301
307
  'use client'
302
308
  import { createSignal } from '@barefootjs/client'
@@ -442,13 +448,17 @@ describe('plain nested loops without conditional wrapper', () => {
442
448
  expect(section.indexOf('const rowTag')).toBeLessThan(section.indexOf('initChild'))
443
449
  })
444
450
 
445
- test('regression #1064: control-flow static inner forEach (signal outer + literal-array inner) emits inner preamble', () => {
446
- // The `control-flow/inner-loop.ts::emitStatic` path fires when a
447
- // reactive composite renderItem hosts a static inner loopthe
448
- // outer array is a signal, but the inner array is referenced via
449
- // a non-reactive lookup so its rendering stays setup-only.
450
- // Use a `__innerIdx<uid>` suffix in the regex to specifically
451
- // target this path (vs. the unsuffixed `static-array-child-init`).
451
+ test('regression #1064: reactive inner mapArray (signal outer + non-outer-derived inner array) emits inner preamble', () => {
452
+ // The reactive composite renderItem hosts an inner loop whose array is
453
+ // a non-outer-derived lookup (a module-level constant herecould just
454
+ // as easily be a signal, a memo, or a prop). Before #2865, an inner
455
+ // array not referencing the outer loop param routed to
456
+ // `control-flow/inner-loop.ts::emitStatic` a hydration-only `forEach`
457
+ // that silently never re-ran the row's own preamble/reactivity on a
458
+ // later update. That fork is gone: every inner loop now gets the full
459
+ // `mapArray` emission. Use a `__innerIdx<uid>` suffix in the regex to
460
+ // specifically target the nested `mapArray` (vs. the unsuffixed
461
+ // `static-array-child-init` for a fully-static OUTER array).
452
462
  const source = `
453
463
  'use client'
454
464
  import { createSignal } from '@barefootjs/client'
@@ -480,8 +490,12 @@ describe('plain nested loops without conditional wrapper', () => {
480
490
  expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
481
491
  const js = result.files.find(f => f.type === 'clientJs')!.content
482
492
 
483
- // The static forEach uses a `__innerIdx<uid>` suffix in this path
484
- // assert preamble appears in that body and precedes any prop read.
485
- expect(js).toMatch(/SHARED\.forEach\(\(cell, __innerIdx\d+_\d+\) => \{[\s\S]*?const\s+tag\s*=/)
493
+ // The inner mapArray's renderItem uses a `__innerIdx<uid>` suffix —
494
+ // assert the preamble appears in that body, re-derived from the
495
+ // ACCESSOR form of both the outer and inner loop params (`row()`,
496
+ // `cell()`), and precedes the child-component prop read.
497
+ expect(js).toMatch(
498
+ /mapArray\(\(\) => SHARED \|\| \[\], .*?, \(cell, __innerIdx\d+_\d+, __existing\) => \{[\s\S]*?const\s+tag\s*=\s*`\$\{row\(\)\.key\}-\$\{cell\(\)\.n\}`[\s\S]*?upsertChild/
499
+ )
486
500
  })
487
501
  })
@@ -140,8 +140,10 @@ describe('reactive attributes inside a nested .map() body (#135)', () => {
140
140
  const content = result.files.find((f) => f.type === 'clientJs')!.content
141
141
 
142
142
  // `data-off` uses the truthy-check shape (no `__v != null` for this
143
- // attribute) so a concrete `false` removes the attribute.
144
- expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?if \(c\(\)\.isOff\)\s*\S+\.setAttribute\('data-off',\s*''\)/)
143
+ // attribute) so a concrete `false` removes the attribute. The dedup
144
+ // guard (#2869) computes into `__x` first, so the truthy check reads
145
+ // `__x`, not `c().isOff` inline.
146
+ expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?const __x = c\(\)\.isOff[\s\S]*?if \(__x\)\s*\S+\.setAttribute\('data-off',\s*''\)/)
145
147
  // aria-* keeps the explicit "true" value per WAI-ARIA.
146
148
  expect(content).toContain("setAttribute('aria-pressed', 'true')")
147
149
  })
@@ -233,8 +235,11 @@ describe('reactive attributes inside a nested .map() body (#135)', () => {
233
235
  // and the inner-loop reactive `createEffect` (must be `task().id`
234
236
  // to subscribe to the per-item accessor). The bug we're locking
235
237
  // down is purely in the createEffect emission, so scope to that.
238
+ // The object literal now sits in the dedup guard's `const __x = {...}`
239
+ // temp (#2869) — `styleToCss` reads it back as `styleToCss(__x)`, not
240
+ // the object literal inline.
236
241
  const effectMatch = content.match(
237
- /createEffect\(\(\) => \{[\s\S]*?styleToCss\(\{[\s\S]*?\}\)[\s\S]*?__ta_s\d+\.setAttribute\('style'/,
242
+ /createEffect\(\(\) => \{[\s\S]*?const __x = \{[\s\S]*?\}[\s\S]*?styleToCss\(__x\)[\s\S]*?__ta_s\d+\.setAttribute\('style'/,
238
243
  )
239
244
  expect(effectMatch).not.toBeNull()
240
245
  const effectBody = effectMatch![0]
@@ -282,8 +287,11 @@ describe('reactive attributes inside a nested .map() body (#135)', () => {
282
287
  const result = compileJSX(source, 'G.tsx', { adapter })
283
288
  expect(result.errors).toHaveLength(0)
284
289
  const content = result.files.find((f) => f.type === 'clientJs')!.content
290
+ // The object literal now sits in the dedup guard's `const __x = {...}`
291
+ // temp (#2869) — `styleToCss` reads it back as `styleToCss(__x)`, not
292
+ // the object literal inline.
285
293
  const effectMatch = content.match(
286
- /createEffect\(\(\) => \{[\s\S]*?styleToCss\(\{[\s\S]*?\}\)[\s\S]*?__ta_s\d+\.setAttribute\('style'/,
294
+ /createEffect\(\(\) => \{[\s\S]*?const __x = \{[\s\S]*?\}[\s\S]*?styleToCss\(__x\)[\s\S]*?__ta_s\d+\.setAttribute\('style'/,
287
295
  )
288
296
  expect(effectMatch).not.toBeNull()
289
297
  const effectBody = effectMatch![0]
@@ -394,8 +402,10 @@ describe('reactive attributes inside a nested .map() body (#135)', () => {
394
402
  // The helper call must appear inside a createEffect alongside the
395
403
  // textContent write — `labelAt(` also appears in the static template
396
404
  // clone, so asserting it independently would pass even with the
397
- // effect missing (the exact regression here).
398
- expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?__bfw_\w+\('s\d+', String\(labelAt\(pi\)\)\)/)
405
+ // effect missing (the exact regression here). `pi` reads the row's
406
+ // live index accessor (#2859), so `labelAt(pi())` also stays correct
407
+ // if this innermost loop's rows ever survive a same-key reorder.
408
+ expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?__bfw_\w+\('s\d+', String\(labelAt\(pi\(\)\)\)\)/)
399
409
  expect(content).toContain("setAttribute('class'")
400
410
  })
401
411
  })
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Codegen-shape pins for the loop-row branch-arm preamble gap (#2596/#2447
3
+ * follow-up, found while discussing #2869's aftermath). Companion to the
4
+ * behavioral test in
5
+ * `packages/client/__tests__/runtime/preamble-branch-attr-staleness.test.ts`,
6
+ * which is the one that actually proves the fix works end to end — this
7
+ * file pins the exact emitted shape so a future refactor of
8
+ * `stringifyLoopChildArm`/`stringifyBranchReactiveAttrs`/
9
+ * `stringifyLoopChildConditional` can't silently drop the preamble re-run
10
+ * again.
11
+ *
12
+ * Two sites were fixed together: a reactive ATTRIBUTE inside a loop row's
13
+ * branch conditional that reads a `.map()` preamble local (case a), and a
14
+ * NESTED conditional's own CONDITION expression doing the same (case b,
15
+ * #2596 one nesting level deeper than the already-fixed outer-conditional
16
+ * case pinned by `preamble-conditional-reactivity.test.ts`). Case c is the
17
+ * negative control: no preamble local in play, so nothing should be
18
+ * injected.
19
+ */
20
+
21
+ import { describe, test, expect } from 'bun:test'
22
+ import { compileJSX } from '../compiler'
23
+ import { TestAdapter } from '../adapters/test-adapter'
24
+
25
+ const adapter = new TestAdapter()
26
+
27
+ function clientJsFor(source: string, filename: string): string {
28
+ const result = compileJSX(source, filename, { adapter })
29
+ expect(result.errors).toHaveLength(0)
30
+ const clientJs = result.files.find(f => f.type === 'clientJs')?.content
31
+ if (!clientJs) throw new Error('No client JS emitted')
32
+ return clientJs
33
+ }
34
+
35
+ describe('#2596/#2447 follow-up — preamble re-run inside a loop-row branch arm', () => {
36
+ test('(a) an attribute inside a branch arm re-runs the preamble before reading it', () => {
37
+ const source = `'use client'
38
+ import { createSignal } from '@barefootjs/client'
39
+ type Row = { id: number; tier: string }
40
+ export function List() {
41
+ const [visible] = createSignal(true)
42
+ const [rows] = createSignal<Row[]>([{ id: 1, tier: 'gold' }])
43
+ return (
44
+ <ul>
45
+ {rows().map(row => {
46
+ const cls = 'tier-' + row.tier
47
+ return (
48
+ <li key={row.id}>
49
+ {visible() ? <span class={cls} id="target">{row.tier}</span> : <span>x</span>}
50
+ </li>
51
+ )
52
+ })}
53
+ </ul>
54
+ )
55
+ }
56
+ `
57
+ const js = clientJsFor(source, 'List.tsx')
58
+
59
+ // Pin the eager per-row mapArray path (not lazy-row, not relevant here
60
+ // but keeps this fixture stable if eligibility ever widens).
61
+ expect(js).toContain('mapArray(')
62
+
63
+ // The outer conditional's own getter is NOT preamble-guarded (`visible`
64
+ // is a plain signal, no preamble read) — stays bare.
65
+ expect(js).toContain("insert(__el, 's0', () => visible(), {")
66
+
67
+ // The arm's attr effect re-runs the preamble before reading `cls`.
68
+ const disposerStart = js.indexOf('const __disposers = []')
69
+ const disposerEnd = js.indexOf('return () => __disposers.forEach')
70
+ const arm = js.slice(disposerStart, disposerEnd)
71
+ const preambleAt = arm.indexOf("const cls = 'tier-' + row().tier;")
72
+ const readAt = arm.indexOf('const __x = cls')
73
+ expect(preambleAt).toBeGreaterThanOrEqual(0)
74
+ expect(readAt).toBeGreaterThan(preambleAt)
75
+ })
76
+
77
+ test('(b) a nested conditional inside a branch arm re-runs the preamble in its own condition getter', () => {
78
+ const source = `'use client'
79
+ import { createSignal } from '@barefootjs/client'
80
+ type Row = { id: number; tier: string }
81
+ export function List() {
82
+ const [visible] = createSignal(true)
83
+ const [selected] = createSignal(1)
84
+ const [rows] = createSignal<Row[]>([{ id: 1, tier: 'gold' }])
85
+ return (
86
+ <ul>
87
+ {rows().map(row => {
88
+ const cls = 'tier-' + row.tier
89
+ const on = selected() === row.id
90
+ return (
91
+ <li key={row.id}>
92
+ {visible() ? <div><span>{cls}</span>{on ? <em id="yes">yes</em> : <em id="no">no</em>}</div> : <span>x</span>}
93
+ </li>
94
+ )
95
+ })}
96
+ </ul>
97
+ )
98
+ }
99
+ `
100
+ const js = clientJsFor(source, 'List2.tsx')
101
+
102
+ expect(js).toMatch(
103
+ /insert\(__branchScope, 's\d+', \(\) => \{ const cls = 'tier-' \+ row\(\)\.tier; const on = selected\(\) === row\(\)\.id;; return \(on\) \}, \{/,
104
+ )
105
+ })
106
+
107
+ test('(c) negative control — no preamble local in play, nothing injected', () => {
108
+ const source = `'use client'
109
+ import { createSignal } from '@barefootjs/client'
110
+ type Row = { id: number; tier: string }
111
+ export function List() {
112
+ const [visible] = createSignal(true)
113
+ const [rows] = createSignal<Row[]>([{ id: 1, tier: 'gold' }])
114
+ return (
115
+ <ul>
116
+ {rows().map(row => (
117
+ <li key={row.id}>
118
+ {visible() ? <span class={row.tier} id="target">{row.tier}</span> : <span>x</span>}
119
+ </li>
120
+ ))}
121
+ </ul>
122
+ )
123
+ }
124
+ `
125
+ const js = clientJsFor(source, 'List3.tsx')
126
+
127
+ expect(js).not.toContain('const cls =')
128
+ // The attr effect still exists and still writes — just with no
129
+ // preamble re-run ahead of it, since there is nothing to re-run.
130
+ const disposerStart = js.indexOf('const __disposers = []')
131
+ const disposerEnd = js.indexOf('return () => __disposers.forEach')
132
+ const arm = js.slice(disposerStart, disposerEnd)
133
+ expect(arm).toContain("setAttribute('class'")
134
+ })
135
+ })
@@ -266,17 +266,20 @@ describe('reactive attributes inside .map() callbacks', () => {
266
266
  expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
267
267
 
268
268
  const clientJs = result.files.find(f => f.type === 'clientJs')!.content
269
- // The per-item style binding must be wired through a createEffect that
270
- // re-reads the helper and writes the style attribute, so it tracks the
271
- // `items()` signal and updates after hydration. Pin the helper call
272
- // *inside* the createEffect alongside the style write `widthAt(` also
269
+ // The per-item style binding must be wired reactively re-reading the
270
+ // helper and writing the style attribute, so it tracks the `items()`
271
+ // signal and updates after hydration, rather than being baked into the
272
+ // item template once. This row has no ref/child component/inner loop,
273
+ // so it is lazy-eligible (#2859 follow-on): `widthAt(i)` reads the
274
+ // index (forcing `applyItem`) and closes over `items()` opaquely
275
+ // (forcing `applyOuter` too, via the re-subscribe seam), so BOTH apply
276
+ // bodies re-evaluate it, not a `createEffect`. Pin the helper call
277
+ // *inside* `applyItem` alongside the style write — `widthAt(` also
273
278
  // appears in the static template clone, so asserting it independently
274
- // could pass even if the effect was missing (the exact regression here).
275
- // The index resolves to the loop's renderItem index param, in scope
276
- // inside the factory.
277
- expect(clientJs).toMatch(
278
- /createEffect\(\(\)\s*=>\s*\{[\s\S]*?widthAt\([\s\S]*?setAttribute\('style'/,
279
- )
279
+ // could pass even if the reactive wiring were missing (the exact
280
+ // regression here).
281
+ const applyItem = clientJs.slice(clientJs.indexOf('applyItem:'), clientJs.indexOf('applyOuter:'))
282
+ expect(applyItem).toMatch(/widthAt\([\s\S]*?setAttribute\('style'/)
280
283
  })
281
284
 
282
285
  test('keyed loop: `key` prop is not emitted as a reactive DOM attribute', () => {
@@ -124,3 +124,40 @@ export { Comp }
124
124
  expect(js).toContain("items.map((tag) => tag.a).join(',') + _p.tag")
125
125
  })
126
126
  })
127
+
128
+ // #2828 follow-up (pullfrog review on #2853): `collectBranchLocalPropRefsViaSubstitution`
129
+ // (jsx-to-ir.ts) is a second, independent AST walk that decides whether a
130
+ // branch-local variable's TRANSITIVE prop dependency gets unioned into a
131
+ // reactive expression's rewrite — same "is this a value reference" question
132
+ // #2828 fixed in `collectAstPropRefs`, but this walk had its own,
133
+ // un-fixed `isShorthand` exclusion. A branch-local whose own initializer
134
+ // reads a prop, referenced via object-literal shorthand inside a REACTIVE
135
+ // expression, had its prop dependency silently dropped: the emitted
136
+ // module-scope hydrate template lambda kept a bare, unprefixed reference
137
+ // to the prop name instead of `_p.<prop>`.
138
+ describe('branch-local transitive prop-dep via shorthand reference (#2828 follow-up)', () => {
139
+ test('a branch-local read from a prop, referenced via shorthand, still resolves to _p.<prop> in the hydrate template', () => {
140
+ const source = `
141
+ "use client"
142
+ import { createSignal } from '@barefootjs/client'
143
+ export function Comp({ tag }: { tag: string }) {
144
+ const [n] = createSignal(0)
145
+ if (n() > 0) {
146
+ const local = tag
147
+ return <div data-x={JSON.stringify({ local }) + n()}>{n()}</div>
148
+ }
149
+ return <div>none</div>
150
+ }
151
+ `
152
+ const js = compileClientJs(source)
153
+ // Pre-fix: the hydrate template's module-scope lambda kept a bare
154
+ // `tag` reference (no binding at that scope -- ReferenceError at pure
155
+ // CSR mount). Post-fix: it resolves through `_p.tag` like every other
156
+ // prop reference. (The surrounding `{ (_p.tag) }` shape -- the branch-
157
+ // local substitution dropping the shorthand's own key -- is a
158
+ // separate, pre-existing defect tracked independently; this test pins
159
+ // only the prop-dependency question #2828's follow-up fixed.)
160
+ expect(js).not.toContain('{ (tag) }')
161
+ expect(js).toContain('{ (_p.tag) }')
162
+ })
163
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Direct unit tests for `spliceChildValue`'s arm order (#2795 follow-up).
3
+ *
4
+ * The door's arm order changed during the #2795 migration:
5
+ * `node.joinArrayChild` is now checked BEFORE `cx.branchSlotsVar`. Before,
6
+ * `irToHtmlTemplate` wrapped an array in `__bfSlot` first and joined after —
7
+ * for an element-array child (`{out}`, built by an arbitrary `.map()`
8
+ * preamble) rendered INSIDE a conditional branch, that would have handed
9
+ * `__bfSlot` the whole array-join expression, which stringifies an array
10
+ * argument via `String(...)`-style coercion internally rather than joining
11
+ * compiled-leaf HTML — corrupting already-escaped leaf markup. Joining first
12
+ * (the current order) hands `__bfSlot` a single already-assembled HTML
13
+ * string instead.
14
+ *
15
+ * PR #2858 shipped this reorder without a test exercising the co-occurrence
16
+ * (join-array-child AND branch-slot at once) — this file is that test,
17
+ * added directly against the door function rather than a full JSX compile,
18
+ * since the arm order is entirely `spliceChildValue`'s own concern.
19
+ */
20
+
21
+ import { describe, test, expect } from 'bun:test'
22
+ import { spliceChildValue } from '../ir-to-client-js/safe-html'
23
+
24
+ describe('spliceChildValue arm order', () => {
25
+ test('joinArrayChild wins over branchSlotsVar — the array is joined, not handed to __bfSlot', () => {
26
+ const result = spliceChildValue(
27
+ { expr: 'out', slotId: null, joinArrayChild: true },
28
+ 'out',
29
+ { branchSlotsVar: 'slots' },
30
+ )
31
+ expect(result).toBe(`Array.isArray(out) ? out.join('') : (out ?? '')`)
32
+ expect(result).not.toContain('__bfSlot')
33
+ })
34
+
35
+ test('branchSlotsVar alone (no joinArrayChild) still routes through __bfSlot', () => {
36
+ const result = spliceChildValue(
37
+ { expr: 'value', slotId: null, joinArrayChild: false },
38
+ 'value',
39
+ { branchSlotsVar: 'slots' },
40
+ )
41
+ expect(result).toBe('__bfSlot(value, slots)')
42
+ })
43
+
44
+ test('neither joinArrayChild nor branchSlotsVar nor slotId nor children — plain escapeText default', () => {
45
+ const result = spliceChildValue(
46
+ { expr: 'label', slotId: null, joinArrayChild: false },
47
+ 'label',
48
+ {},
49
+ )
50
+ expect(result).toBe('escapeText(label)')
51
+ })
52
+
53
+ test('a markup-claimed slotId still escapes-or-unwraps even with joinArrayChild absent', () => {
54
+ const result = spliceChildValue(
55
+ { expr: 'title', slotId: 's0', joinArrayChild: false },
56
+ '(title)',
57
+ { markupSlotIds: new Set(['s0']) },
58
+ )
59
+ expect(result).toBe('escapeTextOrMarkup((title))')
60
+ })
61
+ })
@@ -499,6 +499,65 @@ describe('extractSsrDefaults', () => {
499
499
  // it does not abort the whole computation.
500
500
  expect(defaults?.x).toEqual({ value: [1, null] })
501
501
  })
502
+
503
+ // #2813 (pullfrog nitpick on the PR fixing it): a bare local-const alias
504
+ // of a signal getter must be seeded under its OWN name too, alongside
505
+ // the getter's — a template-stash adapter's `.map()` loop over
506
+ // `items__alias()` looks up the stash entry by that name, which the
507
+ // getter-name-only seeding above never creates.
508
+ test('a bare local-const alias of a signal getter is seeded under the alias name too', () => {
509
+ const metadata = metadataFor(`
510
+ 'use client'
511
+ import { createSignal } from '@barefootjs/client'
512
+ function C() {
513
+ const [items] = createSignal([1, 2, 3])
514
+ const items__alias = items
515
+ return <ul>{items__alias().map(n => <li key={n}>{n}</li>)}</ul>
516
+ }
517
+ `)
518
+
519
+ const defaults = extractSsrDefaults(metadata)
520
+ expect(defaults?.items).toEqual({ value: [1, 2, 3] })
521
+ expect(defaults?.items__alias).toEqual({ value: [1, 2, 3] })
522
+ })
523
+
524
+ test('a multi-hop local-const alias chain resolves through every link', () => {
525
+ const metadata = metadataFor(`
526
+ 'use client'
527
+ import { createSignal } from '@barefootjs/client'
528
+ function C() {
529
+ const [items] = createSignal([1, 2, 3])
530
+ const hop1 = items
531
+ const hop2 = hop1
532
+ return <ul>{hop2().map(n => <li key={n}>{n}</li>)}</ul>
533
+ }
534
+ `)
535
+
536
+ const defaults = extractSsrDefaults(metadata)
537
+ expect(defaults?.items).toEqual({ value: [1, 2, 3] })
538
+ expect(defaults?.hop1).toEqual({ value: [1, 2, 3] })
539
+ expect(defaults?.hop2).toEqual({ value: [1, 2, 3] })
540
+ })
541
+
542
+ test('a getter alias shares the ORIGIN entry object, including a self-derivation-collision propName shape', () => {
543
+ // The origin signal's OWN name collides with the prop its initializer
544
+ // derives from (#2669), so its entry is a propName-carrying prop
545
+ // entry, not a plain `{ value }` — the alias must share that exact
546
+ // shape (same object reference), not re-derive a plain value entry.
547
+ const metadata = metadataFor(`
548
+ 'use client'
549
+ import { createSignal } from '@barefootjs/client'
550
+ function C(props: { label?: string }) {
551
+ const [label] = createSignal(props.label ?? 'Default')
552
+ const labelAlias = label
553
+ return <p>{labelAlias()}</p>
554
+ }
555
+ `)
556
+
557
+ const defaults = extractSsrDefaults(metadata)
558
+ expect(defaults?.label).toEqual({ propName: 'label', value: null })
559
+ expect(defaults?.labelAlias).toBe(defaults?.label)
560
+ })
502
561
  })
503
562
 
504
563
  // TS twin of the Ruby/Python/PHP/Perl/Rust `derive*FromDefaults` runtime