@barefootjs/jsx 0.33.1 → 0.33.3

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 (65) hide show
  1. package/dist/analyzer.d.ts +17 -0
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/compiler.d.ts +21 -5
  4. package/dist/compiler.d.ts.map +1 -1
  5. package/dist/expression-parser.d.ts +14 -0
  6. package/dist/expression-parser.d.ts.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +817 -457
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/html-template.d.ts +7 -7
  14. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/imports.d.ts +60 -2
  16. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/prop-handling.d.ts +30 -0
  19. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/reactivity.d.ts +5 -0
  21. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/rewrite-props-object.d.ts +36 -8
  23. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/utils.d.ts +26 -2
  25. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  26. package/dist/jsx-to-ir.d.ts.map +1 -1
  27. package/dist/props-binding.d.ts +35 -0
  28. package/dist/props-binding.d.ts.map +1 -1
  29. package/dist/types.d.ts +51 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +165 -98
  33. package/src/__tests__/binding-scope-ratchet.test.ts +5 -1
  34. package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
  35. package/src/__tests__/client-js-generation.test.ts +11 -0
  36. package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
  37. package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
  38. package/src/__tests__/issue-2723-prop-alias-reactivity.test.ts +124 -0
  39. package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
  40. package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
  41. package/src/__tests__/merge-template-imports.test.ts +41 -1
  42. package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
  43. package/src/__tests__/rewrite-props-object.test.ts +41 -4
  44. package/src/__tests__/root-key-relay.test.ts +170 -0
  45. package/src/__tests__/signal-getter-not-called.test.ts +149 -0
  46. package/src/__tests__/state-only-file-default-import.test.ts +47 -0
  47. package/src/analyzer.ts +36 -0
  48. package/src/compiler.ts +94 -104
  49. package/src/expression-parser.ts +26 -0
  50. package/src/index.ts +2 -2
  51. package/src/ir-to-client-js/collect-elements.ts +45 -30
  52. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
  53. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
  54. package/src/ir-to-client-js/emit-registration.ts +26 -7
  55. package/src/ir-to-client-js/generate-init.ts +1 -1
  56. package/src/ir-to-client-js/html-template.ts +130 -20
  57. package/src/ir-to-client-js/imports.ts +178 -5
  58. package/src/ir-to-client-js/index.ts +11 -4
  59. package/src/ir-to-client-js/prop-handling.ts +83 -0
  60. package/src/ir-to-client-js/reactivity.ts +59 -0
  61. package/src/ir-to-client-js/rewrite-props-object.ts +50 -10
  62. package/src/ir-to-client-js/utils.ts +30 -2
  63. package/src/jsx-to-ir.ts +523 -49
  64. package/src/props-binding.ts +51 -0
  65. package/src/types.ts +48 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * #2756 — a client-built row/branch must carry the SAME attributes a
3
+ * hydration-reused (SSR-origin) row carries.
4
+ *
5
+ * `lowerFormControlValueSsr` already lowers a controlled `<textarea>` /
6
+ * `<select>` `value` in the SHARED IR: the attr becomes `clientOnly` and
7
+ * the value is re-expressed as element content / per-option `selected`,
8
+ * so every SSR adapter omits the attribute. `irToHtmlTemplate` — the
9
+ * builder for keyed-loop rows and conditional branches — ignored that
10
+ * flag and baked `value="…"` back in, so a rebuilt row and a reused row
11
+ * disagreed the moment a row-count change made both coexist in one list.
12
+ *
13
+ * Each assertion here is paired with the effect that OWNS the value, so
14
+ * "the attribute is gone" can never be satisfied by dropping the binding.
15
+ */
16
+ import { describe, test, expect } from 'bun:test'
17
+ import { compileJSX } from '../compiler'
18
+ import { TestAdapter } from '../adapters/test-adapter'
19
+
20
+ const adapter = new TestAdapter()
21
+
22
+ function clientJs(source: string): string {
23
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
24
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
25
+ return result.files.find(f => f.type === 'clientJs')!.content
26
+ }
27
+
28
+ /** The `__tpl.innerHTML = \`…\`` / `insert(... html: \`…\`)` builder strings. */
29
+ function builderTemplates(content: string): string[] {
30
+ return [...content.matchAll(/innerHTML = `([^`]*)`/g)].map(m => m[1])
31
+ .concat([...content.matchAll(/html: `([^`]*)`/g)].map(m => m[1]))
32
+ }
33
+
34
+ describe('#2756 — client-built rows honour clientOnly', () => {
35
+ test('a keyed-loop row builder omits the controlled textarea `value` attribute and keeps the child text', () => {
36
+ const content = clientJs(`
37
+ "use client";
38
+ import { createSignal } from '@barefootjs/client'
39
+ export function LoopTextarea() {
40
+ const [val, setVal] = createSignal(0)
41
+ const [items] = createSignal([1, 2, 3])
42
+ return (
43
+ <ul>
44
+ {items().filter(i => i > 0).map(i => (
45
+ <li key={i}>
46
+ <textarea value={val()} onInput={() => setVal(1)} />
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ )
51
+ }
52
+ `)
53
+ const rowTemplates = builderTemplates(content).filter(t => t.includes('<textarea'))
54
+ expect(rowTemplates.length).toBeGreaterThan(0)
55
+ for (const tpl of rowTemplates) {
56
+ expect(tpl).not.toContain('value=')
57
+ // The SSR projection of the same value — element content — must stay.
58
+ expect(tpl).toMatch(/<textarea[^>]*>\$\{/)
59
+ }
60
+ // The effect that owns the value is still emitted, so the attribute is
61
+ // absent because the effect applies it, not because nothing does.
62
+ expect(content).toContain(`'value' in`)
63
+ })
64
+
65
+ test('a conditional-branch builder omits it too, and still binds the value effect', () => {
66
+ const content = clientJs(`
67
+ "use client";
68
+ import { createSignal } from '@barefootjs/client'
69
+ export function CondTextarea() {
70
+ const [on, setOn] = createSignal(true)
71
+ const [v, setV] = createSignal('x')
72
+ return <div>{on() ? <textarea value={v()} onInput={() => setV('y')} /> : <p>off</p>}</div>
73
+ }
74
+ `)
75
+ const branchTemplates = builderTemplates(content).filter(t => t.includes('<textarea'))
76
+ expect(branchTemplates.length).toBeGreaterThan(0)
77
+ for (const tpl of branchTemplates) expect(tpl).not.toContain('value=')
78
+ expect(content).toContain('createDisposableEffect')
79
+ })
80
+
81
+ test('a loop row keeps per-option `selected` — the select value projection is not collateral', () => {
82
+ const content = clientJs(`
83
+ "use client";
84
+ import { createSignal } from '@barefootjs/client'
85
+ export function LoopSelect() {
86
+ const [val, setVal] = createSignal('a')
87
+ const [rows] = createSignal([1, 2])
88
+ return (
89
+ <ul>
90
+ {rows().filter(r => r > 0).map(r => (
91
+ <li key={r}>
92
+ <select value={val()}>
93
+ <option value="a">A</option>
94
+ <option value="b">B</option>
95
+ </select>
96
+ </li>
97
+ ))}
98
+ </ul>
99
+ )
100
+ }
101
+ `)
102
+ const rowTemplates = builderTemplates(content).filter(t => t.includes('<select'))
103
+ expect(rowTemplates.length).toBeGreaterThan(0)
104
+ for (const tpl of rowTemplates) {
105
+ // The `<select …>` opening tag only — `<option value="a">` is the
106
+ // literal option value and must survive.
107
+ const selectTag = tpl.slice(tpl.indexOf('<select'), tpl.indexOf('<option'))
108
+ expect(selectTag).not.toMatch(/value=/)
109
+ expect(tpl).toContain("'selected'")
110
+ }
111
+ })
112
+
113
+ test('the composite-row placeholder builder omits it too', () => {
114
+ // `irToPlaceholderTemplate` is the twin builder used when a row also
115
+ // hosts a child component (components become `data-bf-ph` placeholders).
116
+ // Same contract, separate function — it had the same gap.
117
+ const content = clientJs(`
118
+ "use client";
119
+ import { createSignal } from '@barefootjs/client'
120
+ function Badge({ label }: { label: string }) { return <em>{label}</em> }
121
+ export function CompositeRows() {
122
+ const [val, setVal] = createSignal('a')
123
+ const [rows] = createSignal([{ id: 1, label: 'x' }])
124
+ return (
125
+ <ul>
126
+ {rows().map(row => (
127
+ <li key={row.id}>
128
+ <Badge label={row.label} />
129
+ <textarea value={val()} onInput={() => setVal('b')} />
130
+ </li>
131
+ ))}
132
+ </ul>
133
+ )
134
+ }
135
+ `)
136
+ const rowTemplates = builderTemplates(content).filter(t => t.includes('data-bf-ph'))
137
+ expect(rowTemplates.length).toBeGreaterThan(0)
138
+ for (const tpl of rowTemplates) {
139
+ expect(tpl).toContain('<textarea')
140
+ expect(tpl).not.toMatch(/\bvalue=/)
141
+ }
142
+ })
143
+
144
+ test('an ordinary reactive attribute on the same row IS still emitted by the builder', () => {
145
+ // Reverse direction, on one row so both halves are read off the same
146
+ // builder string: `clientOnly` is the ONLY thing now deferred. The
147
+ // row's own `title` / `data-n` must keep their inline emission.
148
+ const content = clientJs(`
149
+ "use client";
150
+ import { createSignal } from '@barefootjs/client'
151
+ export function MixedRow() {
152
+ const [val, setVal] = createSignal('a')
153
+ const [rows] = createSignal([1, 2])
154
+ return (
155
+ <ul>
156
+ {rows().filter(r => r > 0).map(r => (
157
+ <li key={r} title={String(r)} data-n={r}>
158
+ <textarea value={val()} onInput={() => setVal('b')} />
159
+ </li>
160
+ ))}
161
+ </ul>
162
+ )
163
+ }
164
+ `)
165
+ const rowTemplates = builderTemplates(content).filter(t => t.includes('<textarea'))
166
+ expect(rowTemplates.length).toBeGreaterThan(0)
167
+ for (const tpl of rowTemplates) {
168
+ expect(tpl).toMatch(/title=/)
169
+ expect(tpl).toMatch(/data-n=/)
170
+ expect(tpl).not.toMatch(/\bvalue=/)
171
+ }
172
+ })
173
+ })
@@ -56,7 +56,7 @@ describe('mergeTemplateImports', () => {
56
56
  expect(out).toBe("import { Foo, Baz } from 'x'\nimport type { Bar } from 'x'")
57
57
  })
58
58
 
59
- test('passes through and dedupes side-effect / default imports by line', () => {
59
+ test('passes through and dedupes side-effect imports, and a lone default import, by line', () => {
60
60
  const out = mergeTemplateImports([
61
61
  "import './a.css'",
62
62
  "import Foo from 'foo'",
@@ -65,4 +65,44 @@ describe('mergeTemplateImports', () => {
65
65
  ])
66
66
  expect(out).toBe("import './a.css'\nimport Foo from 'foo'\nimport { x } from 'm'")
67
67
  })
68
+
69
+ // #2767 follow-up: two sibling components in a multi-component file both
70
+ // compile from the SAME module-scope `import cfg from 'lib'` declaration,
71
+ // but each component's own compiled output only lists the specifiers IT
72
+ // uses — so one component's output can carry `import cfg from 'lib'` and
73
+ // another's `import cfg, { helper } from 'lib'`. Exact-line dedup keeps
74
+ // BOTH (they're different strings), redeclaring `cfg` — a hard
75
+ // `SyntaxError`. Folding by source must collapse them into one line.
76
+ test('folds a default import shared across sibling components instead of redeclaring the binding', () => {
77
+ const out = mergeTemplateImports([
78
+ "import cfg from 'lib'",
79
+ "import cfg, { helper } from 'lib'",
80
+ ])
81
+ expect(out).toBe("import cfg, { helper } from 'lib'")
82
+ expect((out.match(/\bcfg\b/g) ?? []).length).toBe(1)
83
+ })
84
+
85
+ test('folds a default import with named specifiers arriving in the opposite order', () => {
86
+ const out = mergeTemplateImports([
87
+ "import cfg, { helperA } from 'lib'",
88
+ "import cfg, { helperB } from 'lib'",
89
+ ])
90
+ expect(out).toBe("import cfg, { helperA, helperB } from 'lib'")
91
+ })
92
+
93
+ test('keeps a default import separate from a differently-sourced named import', () => {
94
+ const out = mergeTemplateImports([
95
+ "import cfg from 'lib'",
96
+ "import { helper } from 'other-lib'",
97
+ ])
98
+ expect(out).toBe("import cfg from 'lib'\nimport { helper } from 'other-lib'")
99
+ })
100
+
101
+ test('dedupes an identical namespace import shared across sibling components', () => {
102
+ const out = mergeTemplateImports([
103
+ "import * as NS from 'lib'",
104
+ "import * as NS from 'lib'",
105
+ ])
106
+ expect(out).toBe("import * as NS from 'lib'")
107
+ })
68
108
  })
@@ -0,0 +1,55 @@
1
+ /**
2
+ * #2767 follow-up: two sibling components in the SAME multi-component file
3
+ * compile independently from the same module-scope default import — each
4
+ * component's compiled client JS only lists the specifiers IT actually
5
+ * uses, so one component can emit `import cfg from './config'` while
6
+ * another emits `import cfg, { helper } from './config'` for the exact
7
+ * same source declaration. `compileMultipleComponents`'s client-JS merge
8
+ * used to dedupe import lines by EXACT STRING, which kept both — a hard
9
+ * `SyntaxError: Identifier 'cfg' has already been declared` in the merged
10
+ * `.client.js`, with zero compile diagnostics. This is an end-to-end test
11
+ * through the real `compileJSX` multi-component path (not the unit-level
12
+ * `mergeTemplateImports`/`collectExternalImports` tests, which are correct
13
+ * per-component but can't see this cross-component merge on their own).
14
+ */
15
+ import { describe, test, expect } from 'bun:test'
16
+ import { compileJSX } from '../compiler'
17
+ import { TestAdapter } from '../adapters/test-adapter'
18
+
19
+ const adapter = new TestAdapter()
20
+
21
+ describe('multi-component file sharing a default import (#2767 follow-up)', () => {
22
+ test('merges into one import line instead of redeclaring the default binding', () => {
23
+ const source = `
24
+ 'use client'
25
+ import cfg, { helper } from './config'
26
+ import { createSignal } from '@barefootjs/client'
27
+
28
+ export function CompA() {
29
+ const [n, setN] = createSignal(cfg.start)
30
+ return <button onClick={() => setN(n() + 1)}>{n()}</button>
31
+ }
32
+
33
+ export function CompB() {
34
+ const [m, setM] = createSignal(cfg.start + helper())
35
+ return <button onClick={() => setM(m() + 1)}>{m()}</button>
36
+ }
37
+ `
38
+ const result = compileJSX(source, 'shared-default.tsx', { adapter })
39
+ const errors = result.errors.filter(e => e.severity === 'error')
40
+ expect(errors).toEqual([])
41
+
42
+ const clientJs = result.files.find(f => f.type === 'clientJs')
43
+ expect(clientJs).toBeDefined()
44
+
45
+ const importLines = clientJs!.content
46
+ .split('\n')
47
+ .filter(l => l.includes("from './config'"))
48
+ // Exactly one declaration for './config' — not one per component.
49
+ expect(importLines).toEqual(["import cfg, { helper } from './config'"])
50
+
51
+ // The binding is declared exactly once, not once per component.
52
+ const declarationCount = (clientJs!.content.match(/\bimport\s+cfg\b/g) ?? []).length
53
+ expect(declarationCount).toBe(1)
54
+ })
55
+ })
@@ -60,13 +60,50 @@ describe('rewritePropsObjectRef', () => {
60
60
  expect(out).toBe('const name = _p.name')
61
61
  })
62
62
 
63
- test('no-op when propsObjectName is null (destructured-props mode)', () => {
64
- const code = 'const x = props.name'
65
- const out = rewritePropsObjectRef(code, null)
66
- // null defaults to 'props'; rewritten.
63
+ test('still rewrites a literal `props` read when propsObjectName is null', () => {
64
+ // The `propsObjectName ?? 'props'` fallback survives the #2723 work.
65
+ // A destructured component can still write `props.itemId` inside a
66
+ // handler body — `propsObjectName` is null for that shape, so without
67
+ // the fallback the emitted init keeps a `props` reference that no
68
+ // binding satisfies (the doc-example `StatementExample` regressed
69
+ // exactly this way before the fallback was restored).
70
+ const out = rewritePropsObjectRef('const x = props.name', null)
67
71
  expect(out).toBe('const x = _p.name')
68
72
  })
69
73
 
74
+ test('rewrites via restPropsName when propsObjectName is null (#2723)', () => {
75
+ // The destructured-props shape this exists for: `function F({ a,
76
+ // ...rest })` has no `propsObjectName` at all, but a `const
77
+ // rest__alias = rest` hop (or any other bare read of the rest
78
+ // binding) still needs `rest` rewritten to `_p`.
79
+ const out = rewritePropsObjectRef('const restAlias = rest', null, 'rest')
80
+ expect(out).toBe('const restAlias = _p')
81
+ })
82
+
83
+ test('restPropsName rewrite is not tied to the literal name "props"', () => {
84
+ // Regression guard for the bug the widened fallback replaced: a rest
85
+ // binding named anything OTHER than "props" left the reference
86
+ // dangling (a runtime ReferenceError) because the old fallback only
87
+ // ever guessed the literal word "props".
88
+ const out = rewritePropsObjectRef('const leftoverAlias = leftover', null, 'leftover')
89
+ expect(out).toBe('const leftoverAlias = _p')
90
+ })
91
+
92
+ test('rewrites BOTH propsObjectName and restPropsName when both are set', () => {
93
+ // A `(props)`-arg component that ALSO body-destructures a rest
94
+ // binding out of it (`const { a, ...rest } = props`) has both names
95
+ // live in the same init body.
96
+ const out = rewritePropsObjectRef('const x = props.a + rest.b', 'props', 'rest')
97
+ expect(out).toBe('const x = _p.a + _p.b')
98
+ })
99
+
100
+ test('restPropsName defaulting to null preserves the two-arg call shape', () => {
101
+ // Existing call sites that only ever passed `propsObjectName` (the
102
+ // pre-#2723 signature) must keep working unchanged.
103
+ const out = rewritePropsObjectRef('const name = props.name', 'props')
104
+ expect(out).toBe('const name = _p.name')
105
+ })
106
+
70
107
  test('no-op when propsObjectName equals _p', () => {
71
108
  const code = 'const x = _p.name'
72
109
  const out = rewritePropsObjectRef(code, '_p')
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Render-root row-key relay (`IRElement.keyAttr` with no `value`) — #2753's
3
+ * "mechanism 2".
4
+ *
5
+ * A component's rendered root is whatever element carries `bf-s`
6
+ * (`needsScope`). When that component is used as a caller's keyed loop row,
7
+ * the caller's key has to land on THAT element: `mapArray` reconciles rows
8
+ * by reading the key attribute off the row's primary element, and the CSR
9
+ * half of the contract (`renderChild` / `materializeComponent` in
10
+ * `@barefootjs/client`) splices `data-key` onto the rendered markup's first
11
+ * element regardless of what wrapper nodes sit above it. `resolveRootKeyAttr`
12
+ * is the SSR half, and it must agree.
13
+ *
14
+ * The regression these tests exist for: resolving the relay by walking DOWN
15
+ * from the IR root and stopping at the first node that is not an
16
+ * element/fragment/if-statement. `<Ctx.Provider>` is neither, but
17
+ * `transformProviderElement` passes `ctx.isRoot` through to its children — so
18
+ * a provider-rooted component (select, popover, accordion, carousel,
19
+ * combobox, command, dropdown-menu, radio-group) has a `needsScope` element
20
+ * the walk never reaches, and every adapter silently stopped relaying its
21
+ * caller's key.
22
+ */
23
+ import { describe, test, expect } from 'bun:test'
24
+ import { analyzeComponent } from '../analyzer'
25
+ import { jsxToIR } from '../jsx-to-ir'
26
+ import type { IRElement, IRNode } from '../types'
27
+
28
+ function compile(source: string, name = 'Test'): IRNode {
29
+ const ir = jsxToIR(analyzeComponent(source, `${name}.tsx`))
30
+ expect(ir).not.toBeNull()
31
+ return ir!
32
+ }
33
+
34
+ /** Every `element` node in the tree, in document order. */
35
+ function allElements(node: IRNode): IRElement[] {
36
+ const out: IRElement[] = []
37
+ const visit = (n: IRNode | null | undefined): void => {
38
+ if (!n) return
39
+ if (n.type === 'element') out.push(n)
40
+ switch (n.type) {
41
+ case 'element':
42
+ case 'fragment':
43
+ case 'component':
44
+ case 'provider':
45
+ case 'loop':
46
+ for (const c of n.children) visit(c)
47
+ return
48
+ case 'async':
49
+ visit(n.fallback)
50
+ for (const c of n.children) visit(c)
51
+ return
52
+ case 'conditional':
53
+ visit(n.whenTrue)
54
+ visit(n.whenFalse)
55
+ return
56
+ case 'if-statement':
57
+ visit(n.consequent)
58
+ visit(n.alternate)
59
+ return
60
+ }
61
+ }
62
+ visit(node)
63
+ return out
64
+ }
65
+
66
+ const PROVIDER_ROOT = `
67
+ 'use client'
68
+ import { createContext, createSignal } from '@barefootjs/client'
69
+
70
+ const SelectContext = createContext({ open: false })
71
+
72
+ export function Select(props: { children?: unknown }) {
73
+ const [open, setOpen] = createSignal(false)
74
+ return (
75
+ <SelectContext.Provider value={{ open: open(), setOpen }}>
76
+ <div data-slot="select">{props.children}</div>
77
+ </SelectContext.Provider>
78
+ )
79
+ }
80
+ `
81
+
82
+ describe('render-root row-key relay (#2753)', () => {
83
+ test('a provider-rooted component relays the key on the element under the provider', () => {
84
+ const ir = compile(PROVIDER_ROOT, 'Select')
85
+
86
+ expect(ir.type).toBe('provider')
87
+ const div = allElements(ir).find(e => e.tag === 'div')
88
+ expect(div).toBeDefined()
89
+ // The element under the provider IS the rendered root: `ctx.isRoot`
90
+ // survives `transformProviderElement`.
91
+ expect(div!.needsScope).toBe(true)
92
+ // Relay marker: a name and no value — the value arrives at runtime from
93
+ // whoever renders this component as a keyed row.
94
+ expect(div!.keyAttr).toEqual({ name: 'data-key' })
95
+ })
96
+
97
+ test.each([
98
+ ['plain element root', `
99
+ export function C() { return <div class="root"><span>x</span></div> }
100
+ `],
101
+ ['early-return (if-statement) root — every branch top element', `
102
+ export function C(props: { on?: boolean }) {
103
+ if (props.on) return <section>on</section>
104
+ return <article>off</article>
105
+ }
106
+ `],
107
+ ['provider root', PROVIDER_ROOT],
108
+ ['provider wrapping an early-return root', `
109
+ 'use client'
110
+ import { createContext } from '@barefootjs/client'
111
+ const Ctx = createContext(0)
112
+ export function C(props: { on?: boolean }) {
113
+ if (props.on) return <Ctx.Provider value={1}><section>on</section></Ctx.Provider>
114
+ return <Ctx.Provider value={0}><article>off</article></Ctx.Provider>
115
+ }
116
+ `],
117
+ ['nested providers around the root element', `
118
+ 'use client'
119
+ import { createContext } from '@barefootjs/client'
120
+ const A = createContext(0)
121
+ const B = createContext(0)
122
+ export function C() {
123
+ return <A.Provider value={1}><B.Provider value={2}><div>x</div></B.Provider></A.Provider>
124
+ }
125
+ `],
126
+ ])('invariant: %s — every needsScope element carries a relay keyAttr', (_label, source) => {
127
+ const roots = allElements(compile(source)).filter(e => e.needsScope)
128
+ expect(roots.length).toBeGreaterThan(0)
129
+ for (const el of roots) {
130
+ expect({ tag: el.tag, keyAttr: el.keyAttr }).toEqual({
131
+ tag: el.tag,
132
+ keyAttr: { name: 'data-key' },
133
+ })
134
+ }
135
+ })
136
+
137
+ test('an inline .map() row root keeps its own resolved key expression', () => {
138
+ const ir = compile(`
139
+ export function List(props: { items: { id: number; name: string }[] }) {
140
+ return <ul>{props.items.map(i => <li key={i.id}>{i.name}</li>)}</ul>
141
+ }
142
+ `, 'List')
143
+
144
+ const li = allElements(ir).find(e => e.tag === 'li')
145
+ expect(li).toBeDefined()
146
+ // Mechanism 1 (a concretely-known local expression) wins over the relay
147
+ // marker; the relay pass must not overwrite it with a bare name.
148
+ expect(li!.needsScope).toBe(false)
149
+ expect(li!.keyAttr).toEqual({ name: 'data-key', value: 'i.id' })
150
+
151
+ const ul = allElements(ir).find(e => e.tag === 'ul')!
152
+ expect(ul.needsScope).toBe(true)
153
+ expect(ul.keyAttr).toEqual({ name: 'data-key' })
154
+ })
155
+
156
+ test('a scope-comment fragment root marks exactly one carrier, not every child', () => {
157
+ const ir = compile(`
158
+ export function C() {
159
+ return <><h1>title</h1><p>body</p></>
160
+ }
161
+ `)
162
+
163
+ expect(ir.type).toBe('fragment')
164
+ const els = allElements(ir)
165
+ // #2732: hydration markers moved to the wrapping comment, so no child is
166
+ // a `needsScope` element and the relay pass adds nothing of its own.
167
+ expect(els.every(e => !e.needsScope)).toBe(true)
168
+ expect(els.filter(e => e.keyAttr !== undefined).map(e => e.tag)).toEqual(['h1'])
169
+ })
170
+ })
@@ -266,4 +266,153 @@ describe('Signal Getter Not Called (BF044)', () => {
266
266
  expect(bf044[0].severity).toBe('error')
267
267
  })
268
268
  })
269
+
270
+ /**
271
+ * Nested descent (#2755 / #2751 upstream fix).
272
+ *
273
+ * The gate used to open with `if (!ts.isIdentifier(expr)) return`, so it saw
274
+ * only an expression's TOP-LEVEL node. Every shape below reaches a rendered
275
+ * position through some wrapper, and every one of them used to compile
276
+ * silently and then miscompile downstream — the accessor stringified into a
277
+ * DOM property (#2755) or referenced from a module-scope template thunk that
278
+ * cannot see it (#2751).
279
+ *
280
+ * The negative cases are the load-bearing half: descending EVERYWHERE would
281
+ * break the Context-Provider idiom, where handing a descendant an uncalled
282
+ * accessor is the whole point. The rule is "rendered position", not "nested".
283
+ */
284
+ describe('nested descent into rendered positions', () => {
285
+ // `Child` is declared AFTER `Counter` deliberately: `analyzeComponent`
286
+ // analyzes the FIRST function in the module, so hoisting the child above
287
+ // would silently analyze `Child` instead and make every case below report
288
+ // zero diagnostics — the negative cases would then pass for the wrong
289
+ // reason. The positive block is the control that proves the walk is
290
+ // actually live in this exact module shape.
291
+ const wrap = (body: string) => `
292
+ 'use client'
293
+ import { createSignal } from '@barefootjs/client'
294
+
295
+ export function Counter() {
296
+ const [count, setCount] = createSignal(0)
297
+ const [items, setItems] = createSignal([1, 2])
298
+ const obj: Record<string, unknown> = {}
299
+ return ${body}
300
+ }
301
+
302
+ function Child(props: { value?: unknown }) { return <span /> }
303
+ `
304
+ const bf044Of = (body: string) =>
305
+ compileToIR(wrap(body)).errors.filter(e => e.code === ErrorCodes.SIGNAL_GETTER_NOT_CALLED)
306
+
307
+ describe('fires — the getter reaches a rendered position', () => {
308
+ test.each([
309
+ ['ternary condition', '<div className={count ? "on" : "off"} />'],
310
+ ['template literal span', '<div className={`x-${count}`} />'],
311
+ ['call argument', '<div className={String(count)} />'],
312
+ ['array literal member', '<div className={[count].join("")} />'],
313
+ ['style object property value', '<div style={{ color: count }} />'],
314
+ ['JSX text child', '<div>{count ? "a" : "b"}</div>'],
315
+ ])('%s', (_label, body) => {
316
+ const bf044 = bf044Of(body)
317
+ expect(bf044).toHaveLength(1)
318
+ expect(bf044[0].message).toContain("'count'")
319
+ })
320
+ })
321
+
322
+ describe('stays silent — the getter is handed onward, not rendered', () => {
323
+ test.each([
324
+ // The `<SelectContext.Provider value={{ open, ... }}>` shape: every
325
+ // member is an accessor BY CONTRACT. Calling it here would freeze the
326
+ // value at provider-render time and break every consumer.
327
+ ['component prop, object literal member', '<Child value={{ x: count }} />'],
328
+ ['component prop, ternary', '<Child value={count ? 1 : 2} />'],
329
+ ['component prop, call argument', '<Child value={String(count)} />'],
330
+ ])('%s', (_label, body) => {
331
+ expect(bf044Of(body)).toHaveLength(0)
332
+ })
333
+
334
+ test('a loop-row param shadowing a same-named signal', () => {
335
+ // `count` here is the row item, not the signal — resolved through the
336
+ // ambient `BindingScope`, which sees bindings introduced OUTSIDE the
337
+ // checked expression.
338
+ expect(bf044Of('<ul>{items().map(count => <li className={count ? "a" : "b"} />)}</ul>')).toHaveLength(0)
339
+ })
340
+
341
+ test('correctly called getter in every nested shape', () => {
342
+ expect(bf044Of('<div className={count() ? "on" : "off"} />')).toHaveLength(0)
343
+ expect(bf044Of('<div style={{ color: count() }} />')).toHaveLength(0)
344
+ })
345
+ })
346
+
347
+ describe('binding and parameter defaults', () => {
348
+ // A default VALUE is an ordinary expression in the enclosing scope, but
349
+ // the walk used to visit only binding NAMES. Measured before the fix:
350
+ // both shapes compiled silently and emitted a module-scope `template`
351
+ // thunk referencing a component-scope binding — `ReferenceError` on CSR
352
+ // mount, i.e. #2751's mechanism surviving inside the very check meant to
353
+ // close it.
354
+ test('destructuring default in a rendered position', () => {
355
+ expect(bf044Of('<div className={(() => { const { x = count } = ({} as { x?: unknown }); return String(x) })()} />')).toHaveLength(1)
356
+ })
357
+
358
+ test('parameter default in a rendered position', () => {
359
+ expect(bf044Of('<div className={((f = count) => String(f))()} />')).toHaveLength(1)
360
+ })
361
+
362
+ test('a later default reading an EARLIER parameter stays silent', () => {
363
+ // JS binds parameters left to right: `(count, x = count) => …` reads
364
+ // the already-bound parameter, not the signal it shadows (verified
365
+ // against V8). Visiting every default before binding any parameter
366
+ // would flag this — a false positive on working code.
367
+ expect(bf044Of('<div className={((count2: unknown, x = count2) => String(x))(1)} />')).toHaveLength(0)
368
+ expect(bf044Of('<div className={((count: unknown, x = count) => String(x))(1)} />')).toHaveLength(0)
369
+ })
370
+
371
+ test('a later default reading an EARLIER pattern element stays silent', () => {
372
+ // The same left-to-right rule applies WITHIN a pattern.
373
+ //
374
+ // The declaration sits inside a NESTED block on purpose. At a function
375
+ // body's top level, `collectBlockDeclarations` pre-scans the whole
376
+ // `VariableStatement` and binds every name in the pattern up front,
377
+ // regardless of order — so a top-level version of this case passes
378
+ // even with the sequential threading removed, and pins nothing.
379
+ // `collectBlockDeclarations` does not descend into an `if` block, so
380
+ // here the only thing that can bind `c` before `x`'s default is read
381
+ // is `visitBindingDefaults` itself.
382
+ expect(bf044Of('<div className={(() => { if (obj) { const { count, x = count } = obj; return String(x) } return "" })()} />')).toHaveLength(0)
383
+ })
384
+
385
+ test('a literal default stays silent', () => {
386
+ // The overwhelmingly common shape (`{ size = 'md' }`): the default is
387
+ // not a reactive name, so widening the walk must not touch it.
388
+ expect(bf044Of(`<div className={(({ size = 'md' }: { size?: string }) => size)({})} />`)).toHaveLength(0)
389
+ })
390
+ })
391
+
392
+ test('does not misfire on a TYPE position', () => {
393
+ // A type is not a value. `({} as { count?: unknown })` in a rendered
394
+ // position used to read the type literal's property name as a bare
395
+ // reference to the same-named signal and refuse valid code.
396
+ expect(bf044Of('<div className={String(({} as { count?: unknown }).count)} />')).toHaveLength(0)
397
+ })
398
+
399
+ test('does not misfire on a nested element ATTRIBUTE NAME', () => {
400
+ // The walk stops at a nested JSX boundary. Without that guard, descending
401
+ // into a `.map()` body that returns JSX read the nested element's own
402
+ // attribute NAME (`checked=`) as a bare reference to the same-named
403
+ // signal — `transformNode` re-walks that element independently anyway.
404
+ const source = `
405
+ 'use client'
406
+ import { createSignal } from '@barefootjs/client'
407
+
408
+ export function Boxes() {
409
+ const [checked, setChecked] = createSignal(false)
410
+ const [items, setItems] = createSignal([1, 2])
411
+ return <ul>{items().map(n => <li><input checked={checked()} /></li>)}</ul>
412
+ }
413
+ `
414
+ const bf044 = compileToIR(source).errors.filter(e => e.code === ErrorCodes.SIGNAL_GETTER_NOT_CALLED)
415
+ expect(bf044).toHaveLength(0)
416
+ })
417
+ })
269
418
  })