@barefootjs/jsx 0.35.0 → 0.35.2

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 (72) hide show
  1. package/dist/analyzer-context.d.ts.map +1 -1
  2. package/dist/index.js +731 -691
  3. package/dist/ir-to-client-js/collect-elements.d.ts +22 -2
  4. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  5. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts +14 -2
  7. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +6 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +3 -2
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +24 -44
  15. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +14 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +6 -14
  19. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +1 -3
  21. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +19 -3
  23. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +5 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/emit-reactive.d.ts +54 -0
  28. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/reactivity.d.ts +62 -18
  31. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/types.d.ts +18 -2
  33. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  34. package/dist/reactivity-checker.d.ts +18 -0
  35. package/dist/reactivity-checker.d.ts.map +1 -1
  36. package/package.json +2 -2
  37. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +128 -10
  38. package/src/__tests__/binding-scope-ratchet.test.ts +26 -3
  39. package/src/__tests__/child-prop-fallback-wrap.test.ts +4 -2
  40. package/src/__tests__/compiler-stress-1244.test.ts +8 -8
  41. package/src/__tests__/issue-2868-cond-arm-tag-collision.test.ts +185 -0
  42. package/src/__tests__/issue-2869-attr-dedup-guard.test.ts +141 -0
  43. package/src/__tests__/loop-index-reactivity.test.ts +208 -0
  44. package/src/__tests__/map-body-brand-checker.test.ts +176 -0
  45. package/src/__tests__/nested-loop-index-param.test.ts +27 -5
  46. package/src/__tests__/nested-loop-non-outer-reactivity.test.ts +177 -0
  47. package/src/__tests__/nested-loop-plain.test.ts +39 -25
  48. package/src/__tests__/nested-loop-reactive-attrs.test.ts +12 -4
  49. package/src/__tests__/preamble-branch-arm-rerun.test.ts +135 -0
  50. package/src/__tests__/style-css-var-reactive.test.ts +4 -1
  51. package/src/analyzer-context.ts +12 -10
  52. package/src/ir-to-client-js/collect-elements.ts +110 -32
  53. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -0
  54. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +28 -51
  55. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +7 -2
  56. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +33 -6
  57. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +7 -1
  58. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +18 -6
  59. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +24 -45
  60. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +14 -0
  61. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +20 -66
  62. package/src/ir-to-client-js/control-flow/stringify/insert.ts +4 -2
  63. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +12 -15
  64. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +53 -11
  65. package/src/ir-to-client-js/control-flow/stringify/loop.ts +4 -2
  66. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +29 -18
  67. package/src/ir-to-client-js/emit-reactive.ts +88 -5
  68. package/src/ir-to-client-js/html-template.ts +9 -1
  69. package/src/ir-to-client-js/reactivity.ts +73 -27
  70. package/src/ir-to-client-js/types.ts +19 -3
  71. package/src/jsx-to-ir.ts +21 -4
  72. package/src/reactivity-checker.ts +60 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * #2867 / #2830: `shouldAutoDeferReactiveBrand` (jsx-to-ir.ts) used to call
3
+ * `getJS()` on the whole candidate expression to regex-test it for a native
4
+ * signal/memo call. For a `.map()` loop or a ternary/`&&` with JSX arms, that
5
+ * expression IS JSX-bearing — and whenever the TypeChecker was active and
6
+ * resolved a `Reactive<T>` brand anywhere in the expression, `getJS()`'s
7
+ * trust-boundary assertion (armed by `map-body-no-silent-divergence.test.ts`)
8
+ * threw, uncaught, on `signal-array-builder` / `flatmap-expression-body-signal-array`.
9
+ *
10
+ * That harness's own `assess()` compiles via a bare relative path (`T.tsx`)
11
+ * with no injected `ts.Program`, so whether the checker actually activates —
12
+ * and thus whether this reproduces — silently depended on the process's cwd
13
+ * and whether `@barefootjs/client`'s `dist` happened to be built, which is
14
+ * exactly why the failure looked like an environment-dependent flake (#2867)
15
+ * rather than the deterministic compiler bug it is. These tests pin it with
16
+ * an explicitly injected `ts.Program` (mirroring `auto-defer-brand.test.ts`)
17
+ * so the repro — and the fix — hold regardless of cwd or build state.
18
+ */
19
+
20
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
21
+ import ts from 'typescript'
22
+ import path from 'path'
23
+ import { compileJSX } from '../compiler'
24
+ import { TestAdapter } from '../adapters/test-adapter'
25
+
26
+ let prevAssertEnv: string | undefined
27
+ beforeAll(() => {
28
+ prevAssertEnv = process.env.BF_ASSERT_NO_JSX_IN_GETJS
29
+ process.env.BF_ASSERT_NO_JSX_IN_GETJS = '1'
30
+ })
31
+ afterAll(() => {
32
+ if (prevAssertEnv === undefined) delete process.env.BF_ASSERT_NO_JSX_IN_GETJS
33
+ else process.env.BF_ASSERT_NO_JSX_IN_GETJS = prevAssertEnv
34
+ })
35
+
36
+ interface Compiled {
37
+ errors: string[]
38
+ hasError: boolean
39
+ clientJs: string
40
+ }
41
+
42
+ /**
43
+ * Compile `source` with a real TypeChecker that resolves `@barefootjs/client`
44
+ * (found deterministically via `packages/jsx/node_modules`, since the virtual
45
+ * file lives under this file's own directory — no dependency on `process.cwd()`).
46
+ */
47
+ function compileWithChecker(source: string): Compiled {
48
+ const componentPath = path.join(path.resolve(__dirname), '_map-body-brand-component.tsx')
49
+ const virtualFiles = new Map<string, string>([[componentPath, source]])
50
+
51
+ const compilerOptions: ts.CompilerOptions = {
52
+ target: ts.ScriptTarget.Latest,
53
+ module: ts.ModuleKind.ESNext,
54
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
55
+ jsx: ts.JsxEmit.ReactJSX,
56
+ strict: true,
57
+ skipLibCheck: true,
58
+ noEmit: true,
59
+ }
60
+
61
+ const defaultHost = ts.createCompilerHost(compilerOptions)
62
+ const host: ts.CompilerHost = {
63
+ ...defaultHost,
64
+ getSourceFile(fileName, languageVersion) {
65
+ const resolved = path.resolve(fileName)
66
+ const content = virtualFiles.get(resolved)
67
+ if (content !== undefined) {
68
+ return ts.createSourceFile(fileName, content, languageVersion, true, ts.ScriptKind.TSX)
69
+ }
70
+ return defaultHost.getSourceFile(fileName, languageVersion)
71
+ },
72
+ fileExists(fileName) {
73
+ return virtualFiles.has(path.resolve(fileName)) || defaultHost.fileExists(fileName)
74
+ },
75
+ readFile(fileName) {
76
+ return virtualFiles.get(path.resolve(fileName)) ?? defaultHost.readFile(fileName)
77
+ },
78
+ }
79
+
80
+ const program = ts.createProgram([componentPath], compilerOptions, host)
81
+ const adapter = new TestAdapter()
82
+ const r = compileJSX(source, componentPath, { adapter, program })
83
+ const clientJs = r.files.find((f) => f.type === 'clientJs')?.content ?? ''
84
+ return {
85
+ errors: r.errors.map((e) => `[${e.code}] ${e.message}`),
86
+ hasError: r.errors.some((e) => e.severity === 'error'),
87
+ clientJs,
88
+ }
89
+ }
90
+
91
+ function assertSound(clientJs: string): void {
92
+ const sf = ts.createSourceFile('bundle.js', clientJs, ts.ScriptTarget.ES2022, false, ts.ScriptKind.JS)
93
+ const parseErrors = (sf as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics.length
94
+ expect(parseErrors).toBe(0)
95
+ expect(clientJs).not.toMatch(/__BF_JSX_\d+__/)
96
+ expect(clientJs).not.toMatch(/\bpush\(</)
97
+ }
98
+
99
+ describe('shouldAutoDeferReactiveBrand does not stringify JSX-bearing expressions (#2867/#2830)', () => {
100
+ test('signal-sourced .map() array builder compiles clean under an active checker', () => {
101
+ const { hasError, clientJs } = compileWithChecker(`
102
+ 'use client'
103
+ import { createSignal } from '@barefootjs/client'
104
+ function T() {
105
+ const [rows] = createSignal([{ id: '1', cells: ['a'] }])
106
+ return <table><tbody>{rows().map((r) => {
107
+ const out = []
108
+ for (const c of r.cells) out.push(<td>{c}</td>)
109
+ return <tr key={r.id}>{out}</tr>
110
+ })}</tbody></table>
111
+ }
112
+ export { T }`)
113
+ expect(hasError).toBe(false)
114
+ assertSound(clientJs)
115
+ })
116
+
117
+ test('signal-sourced .flatMap() with an expression-body inner .map() compiles clean under an active checker', () => {
118
+ const { hasError, clientJs } = compileWithChecker(`
119
+ 'use client'
120
+ import { createSignal } from '@barefootjs/client'
121
+ function T() {
122
+ const [items] = createSignal([{ id: '1', tags: ['a'] }])
123
+ return <ul>{items().flatMap((it) => it.tags.map((t) => <li key={t}>{t}</li>))}</ul>
124
+ }
125
+ export { T }`)
126
+ expect(hasError).toBe(false)
127
+ assertSound(clientJs)
128
+ })
129
+
130
+ test('memo-sourced .map() array builder also compiles clean (not just signals)', () => {
131
+ const { hasError, clientJs } = compileWithChecker(`
132
+ 'use client'
133
+ import { createSignal, createMemo } from '@barefootjs/client'
134
+ function T() {
135
+ const [rows] = createSignal([{ id: '1', cells: ['a'] }])
136
+ const upper = createMemo(() => rows())
137
+ return <table><tbody>{upper().map((r) => {
138
+ const out = []
139
+ for (const c of r.cells) out.push(<td>{c}</td>)
140
+ return <tr key={r.id}>{out}</tr>
141
+ })}</tbody></table>
142
+ }
143
+ export { T }`)
144
+ expect(hasError).toBe(false)
145
+ assertSound(clientJs)
146
+ })
147
+
148
+ test('signal-conditioned ternary with JSX on both arms, no props destructuring (BF043) to mask the assertion', () => {
149
+ // No destructured props here (which would emit a BF043 warning and, pre-
150
+ // fix, incidentally have silently disarmed the trust-boundary assertion
151
+ // for this shape). This pins the conditional-arm path directly.
152
+ const { hasError, clientJs } = compileWithChecker(`
153
+ 'use client'
154
+ import { createSignal } from '@barefootjs/client'
155
+ function T() {
156
+ const [show] = createSignal(true)
157
+ return <div>{show() ? <b>yes</b> : <em>no</em>}</div>
158
+ }
159
+ export { T }`)
160
+ expect(hasError).toBe(false)
161
+ assertSound(clientJs)
162
+ })
163
+
164
+ test('a component also importing @barefootjs/client but with no .map() and no reactive JSX branch is unaffected', () => {
165
+ const { hasError, clientJs } = compileWithChecker(`
166
+ 'use client'
167
+ import { createSignal } from '@barefootjs/client'
168
+ function T() {
169
+ const [count, setCount] = createSignal(0)
170
+ return <button onClick={() => setCount((c) => c + 1)}>{count()}</button>
171
+ }
172
+ export { T }`)
173
+ expect(hasError).toBe(false)
174
+ assertSound(clientJs)
175
+ })
176
+ })
@@ -108,6 +108,14 @@ describe('nested .map() index param referenced in key/text/attr (#2218)', () =>
108
108
  // *after* the alias, inside the inner renderItem's own clone template.
109
109
  const templateIdx = content.indexOf('escapeText(i)', aliasIdx)
110
110
  expect(templateIdx).toBeGreaterThan(aliasIdx)
111
+ // #2861: a bare `{i}` — no signal, no function call anywhere in the
112
+ // expression — used to be classified fully static by Phase 2 and never
113
+ // get a `createEffect` at all, so it stayed frozen at whatever value it
114
+ // had when the row was first created. `classifyReactivity`'s new
115
+ // `loop-index` source fixes this: the index is wired through the same
116
+ // accessor `mapArray` already hands every row (#2859/#2860), and the
117
+ // slot now gets a real update effect.
118
+ expect(content).toContain(`createEffect(() => { __bfw_s0('s0', String(i())) })`)
111
119
  })
112
120
 
113
121
  test('index referenced in a reactive attribute expression is bound', () => {
@@ -134,6 +142,17 @@ describe('nested .map() index param referenced in key/text/attr (#2218)', () =>
134
142
 
135
143
  expect(content).toContain('const i = __innerIdx')
136
144
  expect(content).toContain(`i % 2 === 0 ? 'even' : 'odd'`)
145
+ // #2861: `class={i % 2 === 0 ? …}` has no signal read and no function
146
+ // call — before `classifyReactivity`'s `loop-index` source existed this
147
+ // was classified fully static and never got a `createEffect`, so the
148
+ // class stayed frozen at whichever value the row was created with.
149
+ // The expression is read into `__x` first (#2869's dedup-guard shape,
150
+ // `emitDedupedAttrUpdate`) before the actual attribute write reads it
151
+ // back as `__v` — assert both halves rather than the old direct
152
+ // `const __v = <expr>` shape #2869 replaced.
153
+ expect(content).toMatch(
154
+ /createEffect\(\(\) => \{[\s\S]*?const __x = `\$\{i\(\) % 2 === 0 \? 'even' : 'odd'\}`[\s\S]*?const __v = __x;/
155
+ )
137
156
  })
138
157
 
139
158
  test('index referenced only in an event handler inside the inner loop is bound', () => {
@@ -223,10 +242,13 @@ describe('nested .map() index param referenced in key/text/attr (#2218)', () =>
223
242
  expect(content).not.toContain('const id = __innerIdx')
224
243
  })
225
244
 
226
- test('static inner array (no signal dependency) also binds the index alias', () => {
227
- // Sibling of the reactive-path test above, exercising `emitStatic` in
228
- // `stringify/inner-loop.ts` (the `forEach` renderItem path used when the
229
- // inner array does not reference the outer loop param reactively).
245
+ test('inner array with no outer-item dependency still binds the index alias (#2865: no more static forEach)', () => {
246
+ // Sibling of the reactive-path test above. Before #2865, an inner loop
247
+ // whose array didn't reference the outer loop param was routed to
248
+ // `emitStatic` in `stringify/inner-loop.ts` a hydration-only
249
+ // `forEach` that never wired reactivity for anything inside the row.
250
+ // That fork is gone: every inner loop now gets the full `mapArray`
251
+ // emission regardless of what its array depends on.
230
252
  const content = clientJsFor(`
231
253
  'use client'
232
254
  import { createSignal } from '@barefootjs/client'
@@ -251,7 +273,7 @@ describe('nested .map() index param referenced in key/text/attr (#2218)', () =>
251
273
  }
252
274
  `)
253
275
 
254
- expect(content).toMatch(/SHARED\.forEach\(\(cell, __innerIdx[^)]+\) => \{[\s\S]*?const\s+i\s*=\s*__innerIdx/)
276
+ expect(content).toMatch(/mapArray\(\(\) => SHARED \|\| \[\], .*?, \(cell, __innerIdx[^)]+, __existing\) => \{[\s\S]*?const\s+i\s*=\s*__innerIdx/)
255
277
  })
256
278
 
257
279
  test('conditional-branch-arm inner loop (loop -> conditional -> inner loop) binds the index alias', () => {
@@ -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]