@barefootjs/jsx 0.31.0 → 0.31.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 (73) hide show
  1. package/dist/adapters/interface.d.ts +11 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +92 -1
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/test-adapter.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/compiler.d.ts.map +1 -1
  8. package/dist/css-layer-prefixer.d.ts +16 -0
  9. package/dist/css-layer-prefixer.d.ts.map +1 -1
  10. package/dist/errors.d.ts +1 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/html-types.d.ts +19 -0
  13. package/dist/html-types.d.ts.map +1 -1
  14. package/dist/index.d.ts +3 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1402 -1124
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/phases/props-event-handlers.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +6 -4
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/jsx-runtime/index.d.ts +2 -8
  24. package/dist/jsx-runtime/index.d.ts.map +1 -1
  25. package/dist/jsx-to-ir.d.ts.map +1 -1
  26. package/dist/module-exports.d.ts +9 -1
  27. package/dist/module-exports.d.ts.map +1 -1
  28. package/dist/prop-rewrite.d.ts +7 -2
  29. package/dist/prop-rewrite.d.ts.map +1 -1
  30. package/dist/props-binding.d.ts +40 -0
  31. package/dist/props-binding.d.ts.map +1 -0
  32. package/dist/relocate.d.ts +9 -0
  33. package/dist/relocate.d.ts.map +1 -1
  34. package/dist/ssr-defaults.d.ts +43 -0
  35. package/dist/ssr-defaults.d.ts.map +1 -1
  36. package/dist/template-parts.d.ts +53 -0
  37. package/dist/template-parts.d.ts.map +1 -0
  38. package/dist/types.d.ts +10 -0
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/adapter-output.test.ts +8 -4
  42. package/src/__tests__/aliased-destructured-prop-csr.test.ts +112 -0
  43. package/src/__tests__/css-layer-prefixer.test.ts +72 -0
  44. package/src/__tests__/form-control-value-ssr.test.ts +48 -3
  45. package/src/__tests__/memo-deps-comments.test.ts +99 -0
  46. package/src/__tests__/multi-return-sibling-diagnostic.test.ts +241 -0
  47. package/src/__tests__/ssr-defaults.test.ts +124 -1
  48. package/src/__tests__/staged-ir/08-relocate-unit.test.ts +1 -0
  49. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +1 -0
  50. package/src/adapters/interface.ts +11 -0
  51. package/src/adapters/jsx-adapter.ts +266 -4
  52. package/src/adapters/test-adapter.ts +13 -10
  53. package/src/analyzer.ts +50 -8
  54. package/src/compiler.ts +119 -18
  55. package/src/css-layer-prefixer.ts +80 -24
  56. package/src/errors.ts +18 -0
  57. package/src/html-types.ts +24 -0
  58. package/src/index.ts +7 -1
  59. package/src/ir-to-client-js/collect-elements.ts +4 -1
  60. package/src/ir-to-client-js/emit-reactive.ts +4 -2
  61. package/src/ir-to-client-js/phases/props-event-handlers.ts +4 -3
  62. package/src/ir-to-client-js/phases/props-extraction.ts +7 -4
  63. package/src/ir-to-client-js/plan/build-declaration-emit.ts +6 -3
  64. package/src/ir-to-client-js/utils.ts +5 -24
  65. package/src/jsx-runtime/index.ts +2 -7
  66. package/src/jsx-to-ir.ts +75 -42
  67. package/src/module-exports.ts +11 -2
  68. package/src/prop-rewrite.ts +25 -5
  69. package/src/props-binding.ts +70 -0
  70. package/src/relocate.ts +19 -2
  71. package/src/ssr-defaults.ts +70 -0
  72. package/src/template-parts.ts +81 -0
  73. package/src/types.ts +10 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Aliased (renaming) destructured props on the CSR/client-JS path (#2524).
3
+ *
4
+ * `_p` is uniformly keyed by the caller-facing property name
5
+ * (`sourceName ?? name` — see `ParamInfo.sourceName`'s docstring in
6
+ * `types.ts`) across every producer/consumer. Before this fix, client-JS
7
+ * emission kept reading `_p.<localBinding>` for a renaming destructure
8
+ * (`{ n: count }`), so `_p.count` read a property the caller never sent
9
+ * (the caller passes `n`) and the local binding hydrated to `undefined`.
10
+ *
11
+ * Mirrors `ssr-defaults.test.ts`'s aliased-prop describes (#2460) for the
12
+ * SSR-defaults half; this covers the generated `initXxx` extraction and
13
+ * the CSR `template:` lambda.
14
+ */
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
+ describe('aliased destructured props reach client JS under the caller-facing key (#2524)', () => {
23
+ test('{ text, n: count } — init extraction and template lambda both read `_p.n`, not `_p.count`', () => {
24
+ const source = `
25
+ 'use client'
26
+ import { createEffect } from '@barefootjs/client'
27
+ export function Badge({ text, n: count }: { text: string; n: number }) {
28
+ createEffect(() => {
29
+ console.log(count)
30
+ })
31
+ return <span>{text}:{count}</span>
32
+ }
33
+ `
34
+ const result = compileJSX(source, 'Badge.tsx', { adapter })
35
+ const errors = result.errors.filter((e) => e.severity === 'error')
36
+ expect(errors).toEqual([])
37
+
38
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
39
+ expect(clientJs).toBeDefined()
40
+ // `initBadge` extracts the local binding `count` from the
41
+ // CALLER-facing key `n` — never the local key `count`.
42
+ expect(clientJs!.content).toContain('const count = _p.n')
43
+ expect(clientJs!.content).not.toContain('_p.count')
44
+ // The CSR `template:` lambda (module-scope SSR fallback) reads the
45
+ // same caller-facing key.
46
+ expect(clientJs!.content).toContain('escapeText(_p.n)')
47
+ })
48
+
49
+ test('{ text, n } — un-aliased: byte-identical to the pre-fix `_p.n` extraction', () => {
50
+ // `sourceName ?? name` is an identity for an un-aliased prop, so this
51
+ // case must be indistinguishable from before the rename-aware fix.
52
+ const source = `
53
+ 'use client'
54
+ import { createEffect } from '@barefootjs/client'
55
+ export function Badge({ text, n }: { text: string; n: number }) {
56
+ createEffect(() => {
57
+ console.log(n)
58
+ })
59
+ return <span>{text}:{n}</span>
60
+ }
61
+ `
62
+ const result = compileJSX(source, 'Badge.tsx', { adapter })
63
+ const errors = result.errors.filter((e) => e.severity === 'error')
64
+ expect(errors).toEqual([])
65
+
66
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
67
+ expect(clientJs).toBeDefined()
68
+ expect(clientJs!.content).toContain('const n = _p.n')
69
+ expect(clientJs!.content).toContain('escapeText(_p.n)')
70
+ })
71
+
72
+ test('aliased controlled-signal prop — the accessor reads the caller-facing key', () => {
73
+ // `build-declaration-emit`'s controlled-signal path resolves
74
+ // `controlled.propName` (a LOCAL name) through the prop's
75
+ // `sourceName` before emitting the `_p.` accessor.
76
+ const source = `
77
+ 'use client'
78
+ import { createSignal } from '@barefootjs/client'
79
+ export function Toggle({ isOpen: open = false }: { isOpen?: boolean }) {
80
+ const [openState, setOpenState] = createSignal(open)
81
+ return <button onClick={() => setOpenState(!openState())}>{openState() ? 'on' : 'off'}</button>
82
+ }
83
+ `
84
+ const result = compileJSX(source, 'Toggle.tsx', { adapter })
85
+ const errors = result.errors.filter((e) => e.severity === 'error')
86
+ expect(errors).toEqual([])
87
+
88
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
89
+ expect(clientJs).toBeDefined()
90
+ expect(clientJs!.content).toContain('_p.isOpen')
91
+ expect(clientJs!.content).not.toContain('_p.open')
92
+ })
93
+
94
+ test('aliased event-handler prop — the handler const reads the caller-facing key', () => {
95
+ // `props-event-handlers` extracts the handler under its LOCAL name
96
+ // from the CALLER-facing `_p` key.
97
+ const source = `
98
+ 'use client'
99
+ export function Clicker({ onPress: handlePress }: { onPress?: () => void }) {
100
+ return <button onClick={handlePress}>go</button>
101
+ }
102
+ `
103
+ const result = compileJSX(source, 'Clicker.tsx', { adapter })
104
+ const errors = result.errors.filter((e) => e.severity === 'error')
105
+ expect(errors).toEqual([])
106
+
107
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
108
+ expect(clientJs).toBeDefined()
109
+ expect(clientJs!.content).toContain('const handlePress = _p.onPress')
110
+ expect(clientJs!.content).not.toContain('_p.handlePress')
111
+ })
112
+ })
@@ -11,6 +11,9 @@ import {
11
11
  extractIdentifiers,
12
12
  } from '../css-layer-prefixer'
13
13
  import type { ComponentIR, IRElement, IRMetadata, IRTemplatePart, ConstantInfo, TemplateAttr, LiteralAttr } from '../types'
14
+ import { compileJSX } from '../compiler'
15
+ import { TestAdapter } from '../adapters/test-adapter'
16
+ import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
14
17
 
15
18
  describe('prefixClass', () => {
16
19
  test('prefixes a simple class', () => {
@@ -452,4 +455,73 @@ describe('applyCssLayerPrefix', () => {
452
455
  const base = ir.metadata.localConstants.find(c => c.name === 'baseClasses')
453
456
  expect(base?.value).toBe("'layer-components:bg-primary'")
454
457
  })
458
+
459
+ test('multi-component file prefixes a shared const identically in every component IR (#2570)', () => {
460
+ // `sharedClasses` is used in CLASS position only by CompA; CompB reads it
461
+ // in a non-class expression. Per-IR application prefixed only CompA's
462
+ // copy, which — now that module-scope constants are hoisted to ONE
463
+ // module declaration — emitted the constant twice with different values
464
+ // ("tabsClasses has already been declared" in the site/ui build). The
465
+ // file-wide union must leave every IR's copy byte-identical.
466
+ const source = `
467
+ const sharedClasses = 'flex flex-col gap-2'
468
+
469
+ export function CompA() {
470
+ return <div className={sharedClasses + ' extra'}>a</div>
471
+ }
472
+
473
+ export function CompB() {
474
+ return <div data-cls={sharedClasses}>b</div>
475
+ }
476
+ `
477
+ const result = compileJSX(source, '/virtual/Tabs.tsx', {
478
+ adapter: new TestAdapter(),
479
+ cssLayerPrefix: 'components',
480
+ })
481
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
482
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
483
+
484
+ const declarations = template.match(/^const sharedClasses = .*$/gm) ?? []
485
+ expect(declarations).toHaveLength(1)
486
+ expect(declarations[0]).toContain('layer-components:flex')
487
+ })
488
+
489
+ test('prefixes a `Record`-shaped `as const` class map constant\'s typedValue (#2575)', () => {
490
+ // `preserveTypes` emitters (Hono) prefer `typedValue ?? value` when
491
+ // emitting a module-scope constant so `.tsx` output stays type-checked
492
+ // — see `generateModuleScopeDeclarations` in
493
+ // `packages/adapter-hono/src/adapter/hono-adapter.ts`'s base class.
494
+ // `applyCssLayerPrefixToFile` used to rewrite only `value`, leaving
495
+ // `typedValue` — the string Hono actually emits — unprefixed for any
496
+ // constant whose initializer carries type syntax (`as const`, a
497
+ // `satisfies`/`Record<...>` annotation, …). Regression for the fix:
498
+ // both strings must be prefixed together, and the `as const` assertion
499
+ // must survive the round trip verbatim.
500
+ const source = `
501
+ const toneClasses: Record<'info' | 'warn', string> = {
502
+ info: 'text-blue-500',
503
+ warn: 'text-amber-500',
504
+ } as const
505
+
506
+ export function Banner() {
507
+ return <div className={toneClasses.info}>hi</div>
508
+ }
509
+ `
510
+ const result = compileJSX(source, '/virtual/Banner.tsx', {
511
+ adapter: new HonoAdapter(),
512
+ cssLayerPrefix: 'components',
513
+ })
514
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
515
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
516
+
517
+ const declarations = [...template.matchAll(/^const toneClasses = [\s\S]*?\} as const$/gm)]
518
+ expect(declarations).toHaveLength(1)
519
+ const declaration = declarations[0][0]
520
+
521
+ // Classes are prefixed...
522
+ expect(declaration).toContain("info: 'layer-components:text-blue-500'")
523
+ expect(declaration).toContain("warn: 'layer-components:text-amber-500'")
524
+ // ...and the `as const` assertion survives the rewrite.
525
+ expect(declaration.trim().endsWith('} as const')).toBe(true)
526
+ })
455
527
  })
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SSR projection of controlled form-control `value` (#2464 / #2465).
2
+ * SSR projection of controlled form-control `value` (#2464 / #2465 / #2466).
3
3
  *
4
4
  * `value` is not an attribute on `<textarea>` or `<select>` — emitting it
5
5
  * verbatim ships invalid HTML that browsers ignore, so no-JS and
@@ -9,6 +9,15 @@
9
9
  * and projects the value into element content (textarea) or per-option
10
10
  * `selected` comparisons (select — the shape `select-option-selected`
11
11
  * already proves across every adapter).
12
+ *
13
+ * `<option>`s rendered by a `.map()` loop get the same `selected`
14
+ * distribution, compared against the option's own value EXPRESSION rather
15
+ * than a literal (#2466). That makes `selected` an ordinary per-row
16
+ * reactive attribute, so it rides the loop's existing `applyItem` /
17
+ * `applyOuter` (or eager `mapArray` per-row effect) machinery — fixing the
18
+ * bug where an INDEX-KEYED reorder left `selected` attached to whichever
19
+ * physical `<option>` happened to have its `value` rewritten in place,
20
+ * instead of following the controlled signal's value.
12
21
  */
13
22
 
14
23
  import { describe, test, expect } from 'bun:test'
@@ -65,7 +74,7 @@ export function Pick() {
65
74
  expect(template).toContain(`((v()) === "b")`)
66
75
  })
67
76
 
68
- test('an authored selected wins; dynamic option loops are left to hydrate', () => {
77
+ test('an authored selected wins; a `.map()` loop option gets a distributed selected binding (#2466)', () => {
69
78
  const { template } = compiled(`
70
79
  "use client"
71
80
  import { createSignal } from '@barefootjs/client'
@@ -89,7 +98,43 @@ export function Pick() {
89
98
  expect(template).toContain('selected={(false) || undefined}')
90
99
  expect(template).not.toContain('(v()) === "a"')
91
100
  expect(template).not.toMatch(/<select[^>]*value=/)
92
- })
101
+ // The loop-rendered option compares against its own value EXPRESSION
102
+ // (the loop param `o`), not a literal — this is the #2466 half.
103
+ expect(template).toContain('((v()) === (o))')
104
+ }, 20000)
105
+
106
+ test('index-keyed .map() loop option: selected rides applyItem AND applyOuter (#2466)', () => {
107
+ const { clientJs } = compiled(`
108
+ "use client"
109
+ import { createSignal } from '@barefootjs/client'
110
+ export function ReorderSelect() {
111
+ const [opts, setOpts] = createSignal([{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }])
112
+ const [val, setVal] = createSignal('b')
113
+ return (
114
+ <div>
115
+ <select value={val()} onChange={(e) => setVal(e.target.value)}>
116
+ {opts().map((o, i) => <option key={i} value={o.id}>{o.label}</option>)}
117
+ </select>
118
+ <button onClick={() => setOpts([...opts()].reverse())}>Reverse</button>
119
+ </div>
120
+ )
121
+ }
122
+ `)
123
+ // `key={i}` is index-keyed: nothing MOVES on reorder, so `value`/text are
124
+ // rewritten in place by applyItem — `selected` must be recomputed there
125
+ // too (item changed under a stationary row), not just at row creation.
126
+ expect(clientJs).toContain('mapArrayLazy(')
127
+ expect(clientJs).toContain('applyItem:')
128
+ expect(clientJs).toContain('applyOuter:')
129
+ // Every apply body (createRow, applyItem, applyOuter) recomputes
130
+ // `selected` from the item AND the outer controlled signal…
131
+ const computed = clientJs.match(/const __x = \(val\(\)\) === \(o\(\)\.id\)/g) ?? []
132
+ expect(computed.length).toBe(3)
133
+ // …and writes it as a DOM PROPERTY (not just an HTML attribute
134
+ // presence, which would not affect a live, already-rendered <option>).
135
+ const writes = clientJs.match(/\.selected = !!\(__x\)/g) ?? []
136
+ expect(writes.length).toBe(3)
137
+ }, 20000)
93
138
 
94
139
  test('textarea with explicit children is left untouched', () => {
95
140
  const { template } = compiled(`
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Memo/effect dependency extraction must read the token stream, not the raw
3
+ * body text: a doc comment (or string literal) mentioning `otherSignal()`
4
+ * inside a computation body is not a read and must not become a dependency.
5
+ *
6
+ * Regression pin for the phantom deps surfaced in #2581's review: slider's
7
+ * `percentage` memo gained `internalValue`/`controlledValue` deps in the
8
+ * regenerated `ui/meta/slider.json` purely because an explanatory comment
9
+ * inside the memo body named those getters in call syntax.
10
+ */
11
+ import { describe, test, expect } from 'bun:test'
12
+ import { analyzeComponent } from '../analyzer'
13
+
14
+ describe('dependency extraction ignores comments and strings', () => {
15
+ test('a comment naming another getter in call syntax is not a dep', () => {
16
+ const source = `
17
+ "use client"
18
+ import { createSignal, createMemo } from '@barefootjs/client'
19
+ export function Slider() {
20
+ const [internalValue, setInternalValue] = createSignal(0)
21
+ const [controlledValue, setControlledValue] = createSignal<number | undefined>(undefined)
22
+ const [min, setMin] = createSignal(0)
23
+ const [max, setMax] = createSignal(100)
24
+ const currentValue = createMemo(() => controlledValue() ?? internalValue())
25
+ const percentage = createMemo(() => {
26
+ if (max() <= min()) return 0
27
+ // \`currentValue()\` mirrors \`controlledValue()\` and \`internalValue()\`
28
+ // in its type, so assert what's runtime-guaranteed.
29
+ return Math.max(0, Math.min(100, ((currentValue()! - min()) / (max() - min())) * 100))
30
+ })
31
+ return <div style={\`width: \${percentage()}%\`}>{currentValue()}</div>
32
+ }
33
+ `
34
+ const ctx = analyzeComponent(source, 'Slider.tsx')
35
+ const percentage = ctx.memos.find(m => m.name === 'percentage')
36
+ expect(percentage).toBeDefined()
37
+ expect(percentage!.deps.sort()).toEqual(['currentValue', 'max', 'min'])
38
+ })
39
+
40
+ test('a string literal naming a getter in call syntax is not a dep', () => {
41
+ const source = `
42
+ "use client"
43
+ import { createSignal, createMemo } from '@barefootjs/client'
44
+ export function Label() {
45
+ const [count, setCount] = createSignal(0)
46
+ const [label, setLabel] = createSignal('x')
47
+ const hint = createMemo(() => label() + ' (see count() for the number)')
48
+ return <span>{hint()}</span>
49
+ }
50
+ `
51
+ const ctx = analyzeComponent(source, 'Label.tsx')
52
+ const hint = ctx.memos.find(m => m.name === 'hint')
53
+ expect(hint).toBeDefined()
54
+ expect(hint!.deps).toEqual(['label'])
55
+ })
56
+
57
+ test('a read AFTER a template-literal substitution still registers', () => {
58
+ // Trap for token-scan implementations: without a parser driving
59
+ // `reScanTemplateToken`, the template tail after `\${...}` is mis-lexed
60
+ // as a new template opener and swallows the following statement —
61
+ // xyflow's `visibleClass` shape, where `animated()` follows a
62
+ // substitution line and silently lost its dep.
63
+ const source = `
64
+ "use client"
65
+ import { createSignal, createMemo } from '@barefootjs/client'
66
+ export function Edge() {
67
+ const [selected, setSelected] = createSignal(false)
68
+ const [animated, setAnimated] = createSignal(false)
69
+ const visibleClass = createMemo(() => {
70
+ let cls = 'edge'
71
+ if (selected()) cls += \` \${'edge-selected'}\`
72
+ if (animated()) cls += \` \${'edge-animated'}\`
73
+ return cls
74
+ })
75
+ return <div class={visibleClass()}>x</div>
76
+ }
77
+ `
78
+ const ctx = analyzeComponent(source, 'Edge.tsx')
79
+ const visibleClass = ctx.memos.find(m => m.name === 'visibleClass')
80
+ expect(visibleClass).toBeDefined()
81
+ expect(visibleClass!.deps.sort()).toEqual(['animated', 'selected'])
82
+ })
83
+
84
+ test('genuine reads inside template-literal substitutions still register', () => {
85
+ const source = `
86
+ "use client"
87
+ import { createSignal, createMemo } from '@barefootjs/client'
88
+ export function Badge() {
89
+ const [tone, setTone] = createSignal('info')
90
+ const cls = createMemo(() => \`badge badge-\${tone()}\`)
91
+ return <span class={cls()}>x</span>
92
+ }
93
+ `
94
+ const ctx = analyzeComponent(source, 'Badge.tsx')
95
+ const cls = ctx.memos.find(m => m.name === 'cls')
96
+ expect(cls).toBeDefined()
97
+ expect(cls!.deps).toEqual(['tone'])
98
+ })
99
+ })
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Regression tests for #2556: a `'use client'` file where a component
3
+ * references a same-file sibling whose body is a multi-return JSX dispatch
4
+ * (`switch` / `if`-`else` chain) previously compiled clean with ZERO
5
+ * diagnostics, but the sibling produced no template — so the emitted
6
+ * `renderChild`/`initChild`/`createComponent` call referenced a component
7
+ * name with nothing registered under it, throwing
8
+ * `ReferenceError: <Name> is not defined` at SSR/hydrate time.
9
+ *
10
+ * Root cause: `listComponentFunctions`'s #932 "preserve verbatim helper"
11
+ * bypass is gated on `!hasUseClient` — in a `'use client'` file, a
12
+ * multi-return sibling like `NavIcon` below IS added to `componentNames`
13
+ * and asked to compile as a standalone component. But `visitComponentBody`
14
+ * only folds `if`/`else`-chain multi-return bodies into `conditionalReturns`
15
+ * (#1401); a top-level `switch` statement is preserved as a verbatim init
16
+ * statement instead, so `ctx.jsxReturn` stays null and
17
+ * `compileMultipleComponents`'s Pass-1 loop silently `continue`s past it —
18
+ * no entry, no template, no error. Meanwhile the referencing sibling's IR
19
+ * still holds a `component` reference to the dropped name.
20
+ *
21
+ * Fix: BF048 detects this structurally — after Pass 1, any name in
22
+ * `componentNames` that did not make it into `entries` is cross-referenced
23
+ * against every compiled sibling's IR component-reference graph (the same
24
+ * walk `@bf-child` import markers use, `collectComponentNamesFromIR`). A
25
+ * hit fails the compile instead of shipping a silent `ReferenceError`.
26
+ */
27
+
28
+ import { describe, test, expect } from 'bun:test'
29
+ import { compileJSX } from '../compiler'
30
+ import { TestAdapter } from '../adapters/test-adapter'
31
+
32
+ const adapter = new TestAdapter()
33
+
34
+ describe('Sibling multi-return JSX dispatch produces no template in a client file (#2556)', () => {
35
+ // Higher timeout: `.map()` in the source trips `needsTypeBasedDetection`,
36
+ // and building the one-time `ts.Program` for BF023/BF024's nullable-key
37
+ // check is slow the first time the TS API is touched in a fresh process
38
+ // (e.g. this file run in isolation) — well past bun's 5s default.
39
+ test('BF048: .map() loop child referencing a switch-dispatch sibling fails the compile', () => {
40
+ const source = `
41
+ "use client"
42
+ import { createSignal } from '@barefootjs/client'
43
+ function NavIcon({ name }: { name: string }) {
44
+ switch (name) {
45
+ case 'home': return <svg><path d="M1"/></svg>
46
+ case 'bell': return <svg><path d="M2"/></svg>
47
+ default: return null
48
+ }
49
+ }
50
+ export function Shell() {
51
+ const [n, setN] = createSignal(0)
52
+ return (
53
+ <nav onClick={() => setN(n() + 1)}>
54
+ {['home', 'bell'].map(item => <NavIcon key={item} name={item} />)}
55
+ </nav>
56
+ )
57
+ }
58
+ `
59
+
60
+ const result = compileJSX(source, 'Shell.tsx', { adapter })
61
+ const errs = result.errors.filter(e => e.severity === 'error')
62
+ expect(errs.length).toBeGreaterThan(0)
63
+ const bf048 = errs.find(e => e.code === 'BF048')
64
+ expect(bf048).toBeDefined()
65
+ expect(bf048!.message).toContain('NavIcon')
66
+ expect(bf048!.message).toContain('Shell')
67
+ expect(bf048!.message).toMatch(/did not compile to a template/)
68
+ }, 20000)
69
+
70
+ test('BF048: direct JSX tag (non-loop) referencing a switch-dispatch sibling fails the compile', () => {
71
+ const source = `
72
+ "use client"
73
+ import { createSignal } from '@barefootjs/client'
74
+ function StatusIcon({ status }: { status: string }) {
75
+ switch (status) {
76
+ case 'ok': return <span class="ok">OK</span>
77
+ case 'err': return <span class="err">ERR</span>
78
+ default: return null
79
+ }
80
+ }
81
+ export function Panel() {
82
+ const [n, setN] = createSignal(0)
83
+ return <div onClick={() => setN(n() + 1)}><StatusIcon status="ok" /></div>
84
+ }
85
+ `
86
+
87
+ const result = compileJSX(source, 'Panel.tsx', { adapter })
88
+ const errs = result.errors.filter(e => e.severity === 'error')
89
+ const bf048 = errs.find(e => e.code === 'BF048')
90
+ expect(bf048).toBeDefined()
91
+ expect(bf048!.message).toContain('StatusIcon')
92
+ })
93
+
94
+ test('legal neighbor: the identical switch-dispatch shape in a non-"use client" file compiles clean (#932)', () => {
95
+ // Same shape as the failing case above, but without the 'use client'
96
+ // directive: `listComponentFunctions`'s #932 bypass keeps NavIcon OFF
97
+ // `componentNames` entirely, so it is preserved verbatim in the marked
98
+ // template rather than compiled (and dropped) as a component. BF048
99
+ // must not fire here.
100
+ const source = `
101
+ function NavIcon({ name }: { name: string }) {
102
+ switch (name) {
103
+ case 'home': return <svg><path d="M1"/></svg>
104
+ case 'bell': return <svg><path d="M2"/></svg>
105
+ default: return null
106
+ }
107
+ }
108
+ export function Shell() {
109
+ return (
110
+ <nav>
111
+ {['home', 'bell'].map(item => <NavIcon key={item} name={item} />)}
112
+ </nav>
113
+ )
114
+ }
115
+ `
116
+
117
+ const result = compileJSX(source, 'Shell.tsx', { adapter })
118
+ const errs = result.errors.filter(e => e.severity === 'error')
119
+ expect(errs.filter(e => e.code === 'BF048')).toHaveLength(0)
120
+ expect(errs).toHaveLength(0)
121
+
122
+ const markedTemplate = result.files.find(f => f.type === 'markedTemplate')
123
+ expect(markedTemplate).toBeDefined()
124
+ expect(markedTemplate!.content).toContain('function NavIcon')
125
+ })
126
+
127
+ test('legal neighbor: a non-"use client" file never trips BF048, even when a sibling lands in componentNames', () => {
128
+ // The #932 bypass only skips NON-exported multi-return functions, so an
129
+ // exported switch-dispatch sibling in a non-client file IS in
130
+ // `componentNames` and produces no entry — the same set-difference shape
131
+ // BF048 keys on. But the diagnostic's premise (the 'use client' branch
132
+ // of #932, `ReferenceError` at hydrate time) doesn't apply outside
133
+ // client files, so BF048 is gated on a compiled client entry and must
134
+ // stay silent here.
135
+ const source = `
136
+ export function NavIcon({ name }: { name: string }) {
137
+ switch (name) {
138
+ case 'home': return <svg><path d="M1"/></svg>
139
+ case 'bell': return <svg><path d="M2"/></svg>
140
+ default: return null
141
+ }
142
+ }
143
+ export function Shell() {
144
+ return (
145
+ <nav>
146
+ {['home', 'bell'].map(item => <NavIcon key={item} name={item} />)}
147
+ </nav>
148
+ )
149
+ }
150
+ `
151
+
152
+ const result = compileJSX(source, 'Shell.tsx', { adapter })
153
+ const errs = result.errors.filter(e => e.severity === 'error')
154
+ expect(errs.filter(e => e.code === 'BF048')).toHaveLength(0)
155
+ })
156
+
157
+ test('legal neighbor: a "use client" sibling whose multi-return body DOES compile (if/else chain, #1401) stays clean', () => {
158
+ // if/else-if chains fold into `conditionalReturns` (#1401) and produce
159
+ // a real ternary template, so the sibling ends up in `entries` and the
160
+ // reference resolves. BF048 must not fire.
161
+ const source = `
162
+ "use client"
163
+ import { createSignal } from '@barefootjs/client'
164
+ function Badge({ kind }: { kind: string }) {
165
+ if (kind === 'ok') return <span class="ok">ok</span>
166
+ if (kind === 'warn') return <span class="warn">warn</span>
167
+ return <span class="err">err</span>
168
+ }
169
+ export function Panel() {
170
+ const [n, setN] = createSignal(0)
171
+ return (
172
+ <div onClick={() => setN(n() + 1)}>
173
+ <Badge kind="ok" />
174
+ </div>
175
+ )
176
+ }
177
+ `
178
+
179
+ const result = compileJSX(source, 'Panel.tsx', { adapter })
180
+ const errs = result.errors.filter(e => e.severity === 'error')
181
+ expect(errs.filter(e => e.code === 'BF048')).toHaveLength(0)
182
+
183
+ const clientJs = result.files.find(f => f.type === 'clientJs')
184
+ expect(clientJs).toBeDefined()
185
+ // Both the Panel and Badge components' hydrate() registrations exist
186
+ // with real templates — the sibling reference resolves. Badge is a
187
+ // non-exported sibling so its runtime key is file-scoped
188
+ // (`Badge__<hash>`); match on the `name: 'Badge'` metadata instead of
189
+ // the registry key.
190
+ expect(clientJs!.content).toMatch(/hydrate\('Badge[^']*',\s*\{[^}]*template:/s)
191
+ expect(clientJs!.content).toContain("name: 'Badge'")
192
+ })
193
+
194
+ test('legal neighbor: single-component "use client" multi-return root (#1401) is unaffected', () => {
195
+ // Only one component in the file, so `compileJSX` never enters the
196
+ // multi-component path where BF048 is computed at all.
197
+ const source = `
198
+ "use client"
199
+ import { createSignal } from '@barefootjs/client'
200
+ export function Toggle(props: { asChild?: boolean }) {
201
+ const [open, setOpen] = createSignal(false)
202
+ if (props.asChild) {
203
+ return <span onClick={() => setOpen(!open())}>child</span>
204
+ }
205
+ return <button onClick={() => setOpen(!open())}>toggle</button>
206
+ }
207
+ `
208
+
209
+ const result = compileJSX(source, 'Toggle.tsx', { adapter })
210
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
211
+ const clientJs = result.files.find(f => f.type === 'clientJs')
212
+ expect(clientJs).toBeDefined()
213
+ expect(clientJs!.content).toContain('template:')
214
+ })
215
+
216
+ test('legal neighbor: a component-scope local JSX factory does not trip BF048', () => {
217
+ // `listComponentFunctions` recurses into function bodies, so a local
218
+ // factory like `Inner` below lands in `componentNames` and produces no
219
+ // standalone template — but its call sites are handled by the
220
+ // JSX-function-inlining pass, not `createComponent`, so it is NOT the
221
+ // dropped-sibling shape. BF048's uncompiled-sibling set is restricted
222
+ // to module top-level declarations precisely so this stays legal (the
223
+ // first BF048 cut flagged it and broke the ir-dynamic-tag corpus).
224
+ // Two components so the multi-component path (where BF048 runs) engages.
225
+ const source = `
226
+ "use client"
227
+ import { createSignal } from '@barefootjs/client'
228
+ export function Demo() {
229
+ const Inner = () => <span>x</span>
230
+ const [n, setN] = createSignal(0)
231
+ return <div onClick={() => setN(n() + 1)}><Inner /></div>
232
+ }
233
+ export function Other() {
234
+ return <p>other</p>
235
+ }
236
+ `
237
+
238
+ const result = compileJSX(source, 'demo.tsx', { adapter })
239
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
240
+ })
241
+ })