@barefootjs/jsx 0.33.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/adapters/child-scope.d.ts +13 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -1
  3. package/dist/adapters/parsed-expr-emitter.d.ts +17 -0
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/errors.d.ts +1 -0
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/expression-parser.d.ts +17 -4
  9. package/dist/expression-parser.d.ts.map +1 -1
  10. package/dist/index.d.ts +4 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1399 -1173
  13. package/dist/ir-to-client-js/child-components.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/component-scope.d.ts +20 -0
  16. package/dist/ir-to-client-js/component-scope.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +4 -3
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/csr-substitute.d.ts +35 -9
  22. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-reactive.d.ts +10 -0
  24. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/html-template.d.ts +49 -2
  26. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/imports.d.ts +2 -2
  28. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/rewrite-props-object.d.ts +12 -1
  31. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  32. package/dist/jsx-to-ir.d.ts.map +1 -1
  33. package/dist/lowering-registry.d.ts +12 -0
  34. package/dist/lowering-registry.d.ts.map +1 -1
  35. package/dist/prop-rewrite.d.ts +5 -2
  36. package/dist/prop-rewrite.d.ts.map +1 -1
  37. package/dist/props-binding.d.ts +49 -11
  38. package/dist/props-binding.d.ts.map +1 -1
  39. package/dist/types.d.ts +6 -5
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +2 -2
  42. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +15 -15
  43. package/src/__tests__/aliased-import-child-registry-2777.test.ts +112 -0
  44. package/src/__tests__/date-lowering.test.ts +18 -4
  45. package/src/__tests__/inner-loop-mathml-namespace.test.ts +1 -1
  46. package/src/__tests__/inner-loop-svg-namespace.test.ts +4 -4
  47. package/src/__tests__/issue-2724-prop-alias-loop-not-static.test.ts +355 -0
  48. package/src/__tests__/issue-2737-props-object-alias-template.test.ts +86 -0
  49. package/src/__tests__/issue-2756-input-skeleton-bails.test.ts +123 -0
  50. package/src/__tests__/issue-2778-getter-alias-csr-template.test.ts +113 -0
  51. package/src/__tests__/issue-2806-rest-bag-template-rewrite.test.ts +136 -0
  52. package/src/__tests__/issue-2835-props-type-member-name-collision.test.ts +259 -0
  53. package/src/__tests__/loop-item-root-scope.test.ts +7 -3
  54. package/src/__tests__/lowering-value-position.test.ts +232 -0
  55. package/src/__tests__/markup-prop-brand.test.ts +165 -26
  56. package/src/__tests__/namespace-import-primitive.test.ts +244 -0
  57. package/src/__tests__/rewrite-destructured-props.test.ts +53 -0
  58. package/src/__tests__/static-loop-csr-materialize.test.ts +2 -2
  59. package/src/adapters/child-scope.ts +23 -0
  60. package/src/adapters/parsed-expr-emitter.ts +43 -0
  61. package/src/analyzer.ts +130 -0
  62. package/src/errors.ts +14 -1
  63. package/src/expression-parser.ts +63 -29
  64. package/src/index.ts +9 -1
  65. package/src/ir-to-client-js/child-components.ts +8 -1
  66. package/src/ir-to-client-js/collect-elements.ts +10 -38
  67. package/src/ir-to-client-js/component-scope.ts +60 -0
  68. package/src/ir-to-client-js/compute-inlinability.ts +46 -11
  69. package/src/ir-to-client-js/control-flow/shared.ts +12 -5
  70. package/src/ir-to-client-js/control-flow/stringify/loop.ts +8 -2
  71. package/src/ir-to-client-js/csr-substitute.ts +82 -16
  72. package/src/ir-to-client-js/emit-reactive.ts +29 -13
  73. package/src/ir-to-client-js/emit-registration.ts +1 -1
  74. package/src/ir-to-client-js/html-template.ts +141 -70
  75. package/src/ir-to-client-js/imports.ts +4 -0
  76. package/src/ir-to-client-js/index.ts +34 -23
  77. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +7 -2
  78. package/src/ir-to-client-js/rewrite-props-object.ts +14 -0
  79. package/src/jsx-to-ir.ts +200 -42
  80. package/src/lowering-registry.ts +21 -0
  81. package/src/prop-rewrite.ts +23 -5
  82. package/src/props-binding.ts +74 -18
  83. package/src/types.ts +6 -5
@@ -159,27 +159,18 @@ describe('markup-prop brand (#2651)', () => {
159
159
  expect(clientJs).toMatch(/get header\(\)\s*\{\s*return __slot\(/)
160
160
  })
161
161
 
162
- // KNOWN BUG (#2702) pins the CURRENT (broken) emission, not the
163
- // correct one. `isSingleElementJsxChildren` (`ir-to-client-js/collect-
164
- // elements.ts`) only brands the single-element jsx-children shape;
165
- // #2651's PR body enumerated "conditional-in-fragment" as deliberately
166
- // out of that PR's scope, and #2702 confirms it is actually broken, not
167
- // merely unbranded-but-harmless: the child's own `escapeTextOrNode`
168
- // reactive effect re-escapes the chosen branch's HTML as literal text
169
- // the moment it first runs (real DOM corruption, verified directly
170
- // see `jsx-element-prop-fragment-conditional`'s fixture docstring,
171
- // `packages/adapter-tests/fixtures/`, for the full mechanism and why no
172
- // other shared conformance suite observes it: SSR is genuinely correct,
173
- // and CSR conformance's `createEffect` mock never runs the effect).
174
- //
175
- // Graduation: once `bfMarkup()` branding is extended to this multi-part
176
- // shape, this assertion starts failing — replace it with a positive
177
- // `bfMarkup(...)`-wrapped assertion (mirroring test (b) above) and
178
- // delete this comment block; that is the fixture's own graduation
179
- // trigger too (its `expectedHtml` is already the correct SSR output, so
180
- // no fixture change is needed, only this pin's removal and #2667's
181
- // BF021 suggestion regaining the fragment-wrap escape).
182
- test('(h) KNOWN BUG (#2702): a conditional-in-fragment jsx-children prop reaches initChild UNbranded', () => {
162
+ // #2702: a conditional-in-fragment jsx-children prop now brands EACH
163
+ // element branch with `bfMarkup()` (`jsxChildrenPropGetterExpr`,
164
+ // `ir-to-client-js/html-template.ts`) the shape `isSingleElementJsx
165
+ // Children`'s narrower single-element gate (#2651) left unbranded,
166
+ // which corrupted the DOM the moment the child's own `escapeTextOrNode`
167
+ // reactive effect first ran (re-escaped the chosen branch's HTML as
168
+ // literal text; see `jsx-element-prop-fragment-conditional`'s fixture
169
+ // docstring for the full mechanism). Branding each LEAF rather than the
170
+ // whole ternary keeps a non-element branch (text/expression/`&&`'s
171
+ // `''`) unbranded and thus still escaped normally — see the sibling
172
+ // tests below.
173
+ test('(h) a conditional-in-fragment jsx-children prop brands each element branch with bfMarkup()', () => {
183
174
  const source = `
184
175
  'use client'
185
176
  import { createSignal } from '@barefootjs/client'
@@ -201,10 +192,158 @@ describe('markup-prop brand (#2651)', () => {
201
192
  }
202
193
  `
203
194
  const clientJs = clientJsFor(source)
204
- // Bug: the getter returns a bare template-literal ternary — NOT
205
- // wrapped in bfMarkup(...). If this ever starts matching bfMarkup(...)
206
- // the bug is fixed; see the graduation note above.
207
- expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*`<a>x<\/a>`\s*:\s*`<b>y<\/b>`\s*\}/)
208
- expect(clientJs).not.toMatch(/get header\(\)\s*\{\s*return bfMarkup\(/)
195
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*bfMarkup\(`<b>y<\/b>`\)\s*\}/)
196
+ })
197
+
198
+ test('(i) a conditional-in-fragment jsx-children prop leaves a non-element branch unbranded', () => {
199
+ const source = `
200
+ 'use client'
201
+ import { createSignal } from '@barefootjs/client'
202
+ export function Card(props: { header?: any }) {
203
+ return <section><header>{props.header}</header></section>
204
+ }
205
+ export function label() { return 'x' }
206
+ export function TextBranch() {
207
+ const [cond, setCond] = createSignal(true)
208
+ return <Card header={<>{cond() ? <a>x</a> : 'plain'}</>} />
209
+ }
210
+ export function ExpressionBranch() {
211
+ const [cond, setCond] = createSignal(true)
212
+ return <Card header={<>{cond() ? <a>x</a> : label()}</>} />
213
+ }
214
+ export function LogicalAnd() {
215
+ const [cond, setCond] = createSignal(true)
216
+ return <Card header={<>{cond() && <a>x</a>}</>} />
217
+ }
218
+ `
219
+ const clientJs = clientJsFor(source)
220
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*'plain'\s*\}/)
221
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*label\(\)\s*\}/)
222
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*''\s*\}/)
223
+ })
224
+
225
+ test('(j) nested conditionals brand every element branch', () => {
226
+ const source = `
227
+ 'use client'
228
+ import { createSignal } from '@barefootjs/client'
229
+ export function Card(props: { header?: any }) {
230
+ return <section><header>{props.header}</header></section>
231
+ }
232
+ export function P() {
233
+ const [a, setA] = createSignal(true)
234
+ const [b, setB] = createSignal(true)
235
+ return <Card header={<>{a() ? (b() ? <a>x</a> : <c>z</c>) : <b>y</b>}</>} />
236
+ }
237
+ `
238
+ const clientJs = clientJsFor(source)
239
+ expect(clientJs).toMatch(
240
+ /get header\(\)\s*\{\s*return a\(\)\s*\?\s*b\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*bfMarkup\(`<c>z<\/c>`\)\s*:\s*bfMarkup\(`<b>y<\/b>`\)\s*\}/,
241
+ )
242
+ })
243
+
244
+ // A multi-child fragment (more than one flattened part) has no `bfMarkup`/
245
+ // `escapeTextOrNode` array contract — stays unbranded, unchanged from
246
+ // before #2702 (a separate, pre-existing gap, not this fix's scope).
247
+ test('(k) a multi-child fragment jsx-children prop stays unbranded', () => {
248
+ const source = `
249
+ 'use client'
250
+ export function Card(props: { header?: any }) {
251
+ return <section><header>{props.header}</header></section>
252
+ }
253
+ export function P() {
254
+ return <Card header={<>text<strong>x</strong></>} />
255
+ }
256
+ `
257
+ const clientJs = clientJsFor(source)
258
+ expect(clientJs).toMatch(/get header\(\)\s*\{\s*return \["text", `<strong>x<\/strong>`\]\s*\}/)
259
+ })
260
+
261
+ // An explicit `children={<jsx/>}` prop stays unbranded regardless of
262
+ // shape — its consumer is a bare `{children}` passthrough with no
263
+ // unwrap call (test (b)'s comment above explains the same exclusion).
264
+ // pullfrog review, PR #2820: `jsxChildrenPropGetterExpr` (the shared
265
+ // brand-decision door test (h)-(k) pin) wasn't wired into EVERY builder
266
+ // that constructs the identical `get <propName>() { return <expr> }`
267
+ // getter — `buildComponentPropsExpr` (`control-flow/shared.ts`, shared by
268
+ // loop AND conditional-branch component reconciliation) and
269
+ // `buildStaticPropsExpr` (`build-static-array-child-init.ts`) both still
270
+ // called the plain, unbranded `irChildrenToJsExpr` directly. The same
271
+ // conditional-in-fragment shape test (h) pins for a top-level component
272
+ // corrupted the DOM at hydrate time when the component was instead
273
+ // instantiated inside a `.map()` loop.
274
+ test('(m) a conditional-in-fragment jsx-children prop is branded for a component instantiated inside a .map() loop', () => {
275
+ const source = `
276
+ 'use client'
277
+ import { createSignal } from '@barefootjs/client'
278
+ export function Card(props: { header?: any }) {
279
+ return <header>{props.header}</header>
280
+ }
281
+ export function List() {
282
+ const [items, setItems] = createSignal([{ id: 1 }])
283
+ return (
284
+ <div>
285
+ {items().map(item => (
286
+ <Card key={item.id} header={<>{item.id > 0 ? <a>x</a> : <b>y</b>}</>} />
287
+ ))}
288
+ </div>
289
+ )
290
+ }
291
+ `
292
+ const clientJs = clientJsFor(source)
293
+ // Both the reactive-patch (initChild) and initial-mount (createComponent)
294
+ // paths through mapArray's callback must brand each element branch.
295
+ expect(clientJs).toMatch(
296
+ /initChild\('Card', __existing, \{ get header\(\) \{ return item\(\)\.id > 0 \? bfMarkup\(`<a>x<\/a>`\) : bfMarkup\(`<b>y<\/b>`\) \} \}\)/,
297
+ )
298
+ expect(clientJs).toMatch(
299
+ /createComponent\('Card', \{ get header\(\) \{ return item\(\)\.id > 0 \? bfMarkup\(`<a>x<\/a>`\) : bfMarkup\(`<b>y<\/b>`\) \} \}, item\(\)\.id\)/,
300
+ )
301
+ })
302
+
303
+ // pullfrog review, PR #2820: test (m) covers the SIGNAL-driven `.map()`
304
+ // loop (`buildComponentPropsExpr`); `buildStaticPropsExpr`
305
+ // (`build-static-array-child-init.ts`) — the sibling builder for a
306
+ // STATIC, non-signal array `.map()` — got the identical fix but no
307
+ // dedicated test, which is exactly the untested-call-site pattern that
308
+ // let #2702's original corruption go uncaught. Pins the hydrate-time
309
+ // `initChild` path this builder feeds.
310
+ test('(n) a conditional-in-fragment jsx-children prop is branded for a component instantiated inside a static (non-signal) array .map()', () => {
311
+ const source = `
312
+ 'use client'
313
+ import { createSignal } from '@barefootjs/client'
314
+ export function Card(props: { header?: any }) {
315
+ return <header>{props.header}</header>
316
+ }
317
+ export function List() {
318
+ const [cond, setCond] = createSignal(true)
319
+ return (
320
+ <div>
321
+ {[{ id: 1 }].map(item => (
322
+ <Card key={item.id} header={<>{cond() ? <a>x</a> : <b>y</b>}</>} />
323
+ ))}
324
+ </div>
325
+ )
326
+ }
327
+ `
328
+ const clientJs = clientJsFor(source)
329
+ expect(clientJs).toMatch(
330
+ /initChild\('Card', childScope, \{ get header\(\) \{ return cond\(\) \? bfMarkup\(`<a>x<\/a>`\) : bfMarkup\(`<b>y<\/b>`\) \} \}\)/,
331
+ )
332
+ })
333
+
334
+ test('(l) an explicit children prop with a conditional stays unbranded', () => {
335
+ const source = `
336
+ 'use client'
337
+ import { createSignal } from '@barefootjs/client'
338
+ export function Card(props: { children?: any }) {
339
+ return <section>{props.children}</section>
340
+ }
341
+ export function P() {
342
+ const [cond, setCond] = createSignal(true)
343
+ return <Card children={<>{cond() ? <a>x</a> : <b>y</b>}</>} />
344
+ }
345
+ `
346
+ const clientJs = clientJsFor(source)
347
+ expect(clientJs).toMatch(/get children\(\)\s*\{\s*return cond\(\)\s*\?\s*`<a>x<\/a>`\s*:\s*`<b>y<\/b>`\s*\}/)
209
348
  })
210
349
  })
@@ -0,0 +1,244 @@
1
+ /**
2
+ * #2771 — `import * as bf from '@barefootjs/client'` followed by
3
+ * `bf.createSignal(0)` (or any other reactive primitive accessed off the
4
+ * namespace binding) compiled with ZERO diagnostics, but `resolvePrimitiveKind`'s
5
+ * fast path only matches a bare identifier callee — the checker-based slow
6
+ * path that DOES understand a namespace-qualified call only runs when a
7
+ * shared `ts.Program` is supplied (`CompileOptions.program`). Without one,
8
+ * the declaration is silently dropped from the compiled output and every
9
+ * reference to it throws `ReferenceError` at hydrate.
10
+ *
11
+ * BF013 refuses loudly instead, gated on NON-recognition — a compile that
12
+ * DOES supply a program (see `primitive-resolver-alias.test.ts`'s pattern)
13
+ * must never see this diagnostic, since that path already lowers the shape
14
+ * correctly.
15
+ */
16
+ import { describe, test, expect } from 'bun:test'
17
+ import path from 'path'
18
+ import ts from 'typescript'
19
+ import { compileJSX } from '../compiler'
20
+ import { analyzeComponent } from '../index'
21
+ import { TestAdapter } from '../adapters/test-adapter'
22
+
23
+ const adapter = new TestAdapter()
24
+
25
+ function errorsFor(source: string): Array<{ code?: string; severity: string }> {
26
+ const result = compileJSX(source, 'Counter.tsx', { adapter })
27
+ return result.errors.map(e => ({ code: e.code, severity: e.severity }))
28
+ }
29
+
30
+ function bf013(source: string) {
31
+ return errorsFor(source).find(e => e.code === 'BF013')
32
+ }
33
+
34
+ describe('#2771 — BF013: reactive primitive called through an unresolved namespace import', () => {
35
+ test('bf.createSignal(...) (array destructure) fires BF013', () => {
36
+ const result = compileJSX(
37
+ `
38
+ 'use client'
39
+ import * as bf from '@barefootjs/client'
40
+ export function Counter() {
41
+ const [n, setN] = bf.createSignal(0)
42
+ return <button onClick={() => setN(n() + 1)}>{n()}</button>
43
+ }
44
+ `,
45
+ 'Counter.tsx',
46
+ { adapter },
47
+ )
48
+ const err = result.errors.find(e => e.code === 'BF013')
49
+ expect(err).toBeDefined()
50
+ expect(err!.severity).toBe('error')
51
+ expect(err!.message).toContain('bf.createSignal')
52
+ expect(err!.suggestion?.escape).toEqual([{ kind: 'rewrite' }])
53
+ })
54
+
55
+ test('bf.createMemo(...) (array destructure) fires BF013', () => {
56
+ const err = bf013(`
57
+ 'use client'
58
+ import * as bf from '@barefootjs/client'
59
+ import { createSignal } from '@barefootjs/client'
60
+ export function Counter() {
61
+ const [n] = createSignal(1)
62
+ const [doubled] = bf.createMemo(() => n() * 2)
63
+ return <div>{doubled()}</div>
64
+ }
65
+ `)
66
+ expect(err).toBeDefined()
67
+ })
68
+
69
+ test('bf.createEffect(...) (expression statement) fires BF013', () => {
70
+ const err = bf013(`
71
+ 'use client'
72
+ import * as bf from '@barefootjs/client'
73
+ import { createSignal } from '@barefootjs/client'
74
+ export function Counter() {
75
+ const [n] = createSignal(0)
76
+ bf.createEffect(() => { console.log(n()) })
77
+ return <div>{n()}</div>
78
+ }
79
+ `)
80
+ expect(err).toBeDefined()
81
+ })
82
+
83
+ test('bf.onMount(...) (expression statement) fires BF013', () => {
84
+ const err = bf013(`
85
+ 'use client'
86
+ import * as bf from '@barefootjs/client'
87
+ export function Counter() {
88
+ bf.onMount(() => { console.log('mounted') })
89
+ return <div>static</div>
90
+ }
91
+ `)
92
+ expect(err).toBeDefined()
93
+ })
94
+
95
+ test('bf.onCleanup(...) (expression statement) fires BF013', () => {
96
+ const err = bf013(`
97
+ 'use client'
98
+ import * as bf from '@barefootjs/client'
99
+ export function Counter() {
100
+ bf.onCleanup(() => { console.log('cleaned up') })
101
+ return <div>static</div>
102
+ }
103
+ `)
104
+ expect(err).toBeDefined()
105
+ })
106
+
107
+ test('bf.createSearchParams(...) (array destructure) fires BF013', () => {
108
+ const err = bf013(`
109
+ 'use client'
110
+ import * as bf from '@barefootjs/client'
111
+ export function Counter() {
112
+ const [searchParams] = bf.createSearchParams()
113
+ return <div>{searchParams().get('sort')}</div>
114
+ }
115
+ `)
116
+ expect(err).toBeDefined()
117
+ })
118
+
119
+ test('bf.createSignal(0)[0] (element access) fires BF013', () => {
120
+ const err = bf013(`
121
+ 'use client'
122
+ import * as bf from '@barefootjs/client'
123
+ export function Counter() {
124
+ const n = bf.createSignal(0)[0]
125
+ return <div>{n()}</div>
126
+ }
127
+ `)
128
+ expect(err).toBeDefined()
129
+ })
130
+
131
+ test('a bare named import is unaffected (no BF013)', () => {
132
+ const errs = errorsFor(`
133
+ 'use client'
134
+ import { createSignal } from '@barefootjs/client'
135
+ export function Counter() {
136
+ const [n, setN] = createSignal(0)
137
+ return <button onClick={() => setN(n() + 1)}>{n()}</button>
138
+ }
139
+ `)
140
+ expect(errs.find(e => e.code === 'BF013')).toBeUndefined()
141
+ })
142
+
143
+ test('a namespace import of a DIFFERENT module is unaffected (no BF013)', () => {
144
+ const result = compileJSX(
145
+ `
146
+ 'use client'
147
+ import * as store from './store'
148
+ export function Counter() {
149
+ const [n, setN] = store.createSignal(0)
150
+ return <button onClick={() => setN(n() + 1)}>{n()}</button>
151
+ }
152
+ `,
153
+ 'Counter.tsx',
154
+ { adapter },
155
+ )
156
+ expect(result.errors.find(e => e.code === 'BF013')).toBeUndefined()
157
+ })
158
+
159
+ test('a component-local binding shadowing the namespace name is unaffected (no BF013)', () => {
160
+ const result = compileJSX(
161
+ `
162
+ 'use client'
163
+ import * as bf from '@barefootjs/client'
164
+ export function Counter() {
165
+ const bf = { createSignal: (x: number) => [() => x, (v: number) => {}] as const }
166
+ const [n, setN] = bf.createSignal(0)
167
+ return <button onClick={() => setN(n() + 1)}>{n()}</button>
168
+ }
169
+ `,
170
+ 'Counter.tsx',
171
+ { adapter },
172
+ )
173
+ expect(result.errors.find(e => e.code === 'BF013')).toBeUndefined()
174
+ })
175
+
176
+ test('a type-only namespace import is unaffected (no BF013)', () => {
177
+ const result = compileJSX(
178
+ `
179
+ 'use client'
180
+ import type * as bf from '@barefootjs/client'
181
+ export function Counter() {
182
+ return <div>static</div>
183
+ }
184
+ `,
185
+ 'Counter.tsx',
186
+ { adapter },
187
+ )
188
+ expect(result.errors.find(e => e.code === 'BF013')).toBeUndefined()
189
+ })
190
+ })
191
+
192
+ describe('#2771 — checker-path pin: a shared Program resolves the namespace and never fires BF013', () => {
193
+ const CLIENT_DIR = path.resolve(__dirname, '../../../client/src')
194
+
195
+ function programFor(filePath: string, source: string): ts.Program {
196
+ const absolute = path.resolve(filePath)
197
+ const compilerOptions: ts.CompilerOptions = {
198
+ target: ts.ScriptTarget.Latest,
199
+ module: ts.ModuleKind.ESNext,
200
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
201
+ jsx: ts.JsxEmit.ReactJSX,
202
+ strict: true,
203
+ skipLibCheck: true,
204
+ noEmit: true,
205
+ esModuleInterop: true,
206
+ baseUrl: path.dirname(absolute),
207
+ }
208
+ const defaultHost = ts.createCompilerHost(compilerOptions)
209
+ const host: ts.CompilerHost = {
210
+ ...defaultHost,
211
+ getSourceFile(fileName, languageVersion) {
212
+ if (path.resolve(fileName) === absolute) {
213
+ return ts.createSourceFile(fileName, source, languageVersion, true, ts.ScriptKind.TSX)
214
+ }
215
+ return defaultHost.getSourceFile(fileName, languageVersion)
216
+ },
217
+ fileExists(fileName) {
218
+ if (path.resolve(fileName) === absolute) return true
219
+ return defaultHost.fileExists(fileName)
220
+ },
221
+ readFile(fileName) {
222
+ if (path.resolve(fileName) === absolute) return source
223
+ return defaultHost.readFile(fileName)
224
+ },
225
+ }
226
+ return ts.createProgram([absolute], compilerOptions, host)
227
+ }
228
+
229
+ test('with a shared Program, bf.createSignal resolves and BF013 never fires', () => {
230
+ const source = `
231
+ 'use client'
232
+ import * as bf from '@barefootjs/client'
233
+ export function Counter() {
234
+ const [n, setN] = bf.createSignal(0)
235
+ return <button onClick={() => setN(n() + 1)}>{n()}</button>
236
+ }
237
+ `
238
+ const testFile = path.join(CLIENT_DIR, '__ns_primitive_checker_pin__.tsx')
239
+ const program = programFor(testFile, source)
240
+ const ctx = analyzeComponent(source, testFile, undefined, program)
241
+ expect(ctx.signals.length).toBe(1)
242
+ expect(ctx.errors.find(e => e.code === 'BF013')).toBeUndefined()
243
+ })
244
+ })
@@ -71,3 +71,56 @@ export { Btn }
71
71
  expect(js).not.toMatch(/\(_p\.size[^)]*\)-[0-9]/)
72
72
  })
73
73
  })
74
+
75
+ // #2741: `rewriteDestructuredPropsInExpr` (the reactive `createEffect`
76
+ // update path) used to run a bare `\b<prop>\b` regex over the whole
77
+ // expression text, so an object-literal property whose KEY happened to
78
+ // share a prop's name (`{ tag: tag }`) had the KEY rewritten too
79
+ // (`{ _p.tag: _p.tag }`), which is not valid JS — the whole client module
80
+ // failed to parse, silently (no diagnostic). Now delegates to the same
81
+ // AST-based `rewriteBarePropRefs` (`prop-rewrite.ts`) the hydrate template
82
+ // lambda already used, which distinguishes a key from a value reference.
83
+ describe('reactive-effect prop rewrite does not touch object-literal keys (#2741)', () => {
84
+ test('an explicit-key object literal used in a reactive attribute keeps its keys', () => {
85
+ const source = `
86
+ "use client"
87
+ import { queryHref } from '@barefootjs/client'
88
+ function Comp({ base, tag, page }: { base: string; tag: string; page: string }) {
89
+ return <a href={queryHref(base, { tag: tag, page: page ? page : '' })}>filter</a>
90
+ }
91
+ export { Comp }
92
+ `
93
+ const js = compileClientJs(source)
94
+ expect(js).toContain("{ tag: _p.tag, page: _p.page ? _p.page : '' }")
95
+ expect(js).not.toContain('_p.tag:')
96
+ expect(js).not.toContain('_p.page:')
97
+ })
98
+
99
+ test('a keyed prop with a destructure default still gets the (_p.x ?? default) wrap only in value position', () => {
100
+ const source = `
101
+ "use client"
102
+ import { queryHref } from '@barefootjs/client'
103
+ function Comp({ base, tag = 'all', page }: { base: string; tag?: string; page: string }) {
104
+ return <a href={queryHref(base, { tag: tag, page: page ? page : '' })}>filter</a>
105
+ }
106
+ export { Comp }
107
+ `
108
+ const js = compileClientJs(source)
109
+ expect(js).toContain("{ tag: (_p.tag ?? 'all'), page: _p.page ? _p.page : '' }")
110
+ expect(js).not.toContain("(_p.tag ?? 'all'):")
111
+ })
112
+
113
+ test('a prop name shadowed by an inner callback parameter is not rewritten inside that callback', () => {
114
+ const source = `
115
+ "use client"
116
+ import { createSignal } from '@barefootjs/client'
117
+ function Comp({ tag, items }: { tag: string; items: { a: string }[] }) {
118
+ const [n] = createSignal(0)
119
+ return <div data-x={n() + items.map((tag) => tag.a).join(',') + tag}></div>
120
+ }
121
+ export { Comp }
122
+ `
123
+ const js = compileClientJs(source)
124
+ expect(js).toContain("items.map((tag) => tag.a).join(',') + _p.tag")
125
+ })
126
+ })
@@ -242,7 +242,7 @@ describe('#1247 — static-loop CSR self-heal', () => {
242
242
  expect(clientJs).toMatch(/if \(!__iterEl\)/)
243
243
  // The cloned template body is a single `renderChild('Tag', ...)`
244
244
  // expression — props read from the destructured forEach param.
245
- const matMatch = clientJs.match(/__tpl\.innerHTML = `([^`]*)`/)
245
+ const matMatch = clientJs.match(/__tpl\.innerHTML = withParentScope\(__scopeId, \(\) => `([^`]*)`\)/)
246
246
  expect(matMatch).toBeTruthy()
247
247
  const inner = matMatch![1]
248
248
  expect(inner).toMatch(/\$\{renderChild\('Tag', \{[^}]*id: id[^}]*variant: t\.variant[^}]*\}, id/)
@@ -287,7 +287,7 @@ describe('#1247 — static-loop CSR self-heal', () => {
287
287
  // Materialize branch present.
288
288
  expect(clientJs).toMatch(/if \(!__iterEl\)/)
289
289
  // Per-iteration template is `<li ...>...renderChild('Cell', ...)...</li>`.
290
- const matMatch = clientJs.match(/__tpl\.innerHTML = `([^`]*)`/)
290
+ const matMatch = clientJs.match(/__tpl\.innerHTML = withParentScope\(__scopeId, \(\) => `([^`]*)`\)/)
291
291
  expect(matMatch).toBeTruthy()
292
292
  const inner = matMatch![1]
293
293
  expect(inner).toMatch(/^<li\b/)
@@ -20,3 +20,26 @@ export function derivesScopeFromSlot(
20
20
  ): boolean {
21
21
  return comp.slotId != null && comp.loopItemRoot !== true
22
22
  }
23
+
24
+ /**
25
+ * The `renderChild(name, props, key, <here>)` tail arguments — the single
26
+ * decision point both CSR template emitters (`html-template.ts`'s two
27
+ * `case 'component'` branches) share, so they can't drift on it the way
28
+ * #2444 happened for `derivesScopeFromSlot` itself.
29
+ *
30
+ * A loop item root (`!derivesScopeFromSlot`) still gets its `slotId` and the
31
+ * `loopItemRoot` flag (#2833) rather than dropping the slot argument
32
+ * entirely as before: Hono stamps `bf-h`/`bf-m` on a row root too (just
33
+ * without deriving its `bf-s` from them), and a pure-CSR mount's static
34
+ * `qsaChildScopes` init selector needs that same marker to find the row.
35
+ */
36
+ export function renderChildScopeArgs(
37
+ comp: Pick<IRComponent, 'slotId' | 'loopItemRoot'>,
38
+ keyArg: string
39
+ ): string {
40
+ if (comp.slotId == null) return keyArg
41
+ const key = keyArg || ', undefined'
42
+ return derivesScopeFromSlot(comp)
43
+ ? `${key}, '${comp.slotId}'`
44
+ : `${key}, '${comp.slotId}', true`
45
+ }
@@ -35,6 +35,7 @@
35
35
 
36
36
  import type { ParsedExpr, FlatDepth, TemplatePart, ObjectLiteralProperty } from '../expression-parser.ts'
37
37
  import { asCallbackMethodCall } from '../expression-parser.ts'
38
+ import type { LoweringMatcher, LoweringNode } from '../lowering-registry.ts'
38
39
 
39
40
  export type HigherOrderMethod = 'filter' | 'every' | 'some' | 'find' | 'findIndex' | 'findLast' | 'findLastIndex'
40
41
 
@@ -95,6 +96,22 @@ export type SortMethod = 'sort' | 'toSorted'
95
96
 
96
97
  export type LiteralType = 'string' | 'number' | 'boolean' | 'null'
97
98
 
99
+ /**
100
+ * An adapter's registered-lowering seam for the shared `call` dispatch
101
+ * (#2843). `matchers` are the same `ctx.state.loweringMatchers`-style list
102
+ * every adapter already binds at init; `render` maps one matched neutral
103
+ * node to this adapter's own template syntax (its body is typically what
104
+ * used to live inline in the adapter's `call()` method before this seam
105
+ * existed — see `renderLoweringNode` in the Go adapter's `url-builder.ts`
106
+ * for the shape). Returning null for a `kind`/`helper` this adapter has no
107
+ * mapping for lets the caller fall through to the ordinary `call()`
108
+ * dispatch, same as an unmatched call.
109
+ */
110
+ export interface LoweringEmitter {
111
+ readonly matchers: readonly LoweringMatcher[]
112
+ render(node: LoweringNode, emit: (e: ParsedExpr) => string): string | null
113
+ }
114
+
98
115
  /**
99
116
  * Adapter-side ParsedExpr lowering surface. One method per
100
117
  * `ParsedExpr.kind`. The shared `emitParsedExpr` runner dispatches
@@ -109,6 +126,17 @@ export interface ParsedExprEmitter {
109
126
  identifier(name: string): string
110
127
  literal(value: string | number | boolean | null, literalType: LiteralType): string
111
128
  call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string
129
+ // Optional (#2843): when present, `emitParsedExpr`'s `call` case tries
130
+ // every matcher FIRST — before `call()` itself, and before the
131
+ // higher-order `callbackMethod` recognition — so a registered lowering
132
+ // plugin's call is recognised no matter where in an expression tree it
133
+ // sits (a ternary branch, a template-literal interpolation, a binary
134
+ // operand, …), not only when the adapter's own top-level conversion entry
135
+ // point happens to consult the registry directly. Omitted entirely by an
136
+ // adapter that hasn't adopted the seam (e.g. Go, which still consults the
137
+ // registry inline in its own `call()` per #2842) — behaviour is then
138
+ // byte-identical to before this field existed.
139
+ lowering?: LoweringEmitter
112
140
  // `optional` is true for a `?.`-written access (`user?.name`); see the
113
141
  // `ParsedExpr` `member` variant's docstring in `expression-parser.ts`
114
142
  // for the single-hop caveat. Every adapter's `member()` implementation
@@ -340,6 +368,21 @@ export function emitParsedExpr(expr: ParsedExpr, emitter: ParsedExprEmitter): st
340
368
  case 'literal':
341
369
  return emitter.literal(expr.value, expr.literalType)
342
370
  case 'call': {
371
+ // #2843: a registered lowering plugin's call wins in EVERY value
372
+ // position this dispatcher reaches, not only an adapter's own
373
+ // top-level conversion entry point — see `LoweringEmitter`'s doc.
374
+ // First-match-wins; a matched node whose `render` declines (helper id
375
+ // this adapter has no mapping for) falls through to the ordinary
376
+ // dispatch below, same as an unmatched call.
377
+ if (emitter.lowering) {
378
+ for (const matcher of emitter.lowering.matchers) {
379
+ const node = matcher(expr.callee, expr.args)
380
+ if (!node) continue
381
+ const rendered = emitter.lowering.render(node, emit)
382
+ if (rendered !== null) return rendered
383
+ break
384
+ }
385
+ }
343
386
  // A higher-order callback call (`arr.filter(p)` / `arr.sort(cmp)` / …)
344
387
  // routes to the dedicated `callbackMethod` arm; any other call is generic.
345
388
  const cb = asCallbackMethodCall(expr)