@barefootjs/jsx 0.29.0 → 0.30.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 (90) hide show
  1. package/dist/adapters/child-scope.d.ts +17 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +990 -367
  9. package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
  15. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  21. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/html-template.d.ts +2 -3
  25. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  27. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/reactivity.d.ts +1 -10
  29. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/types.d.ts +12 -0
  31. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  32. package/dist/jsx-to-ir.d.ts.map +1 -1
  33. package/dist/prop-rewrite.d.ts +24 -7
  34. package/dist/prop-rewrite.d.ts.map +1 -1
  35. package/dist/rich-type-evidence.d.ts.map +1 -1
  36. package/dist/ssr-defaults.d.ts.map +1 -1
  37. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  38. package/dist/types.d.ts +78 -0
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
  42. package/src/__tests__/branch-loop-plain.test.ts +28 -45
  43. package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
  44. package/src/__tests__/csr-template-scope-soundness.test.ts +167 -0
  45. package/src/__tests__/early-return-branch-switch.test.ts +96 -0
  46. package/src/__tests__/form-control-value-ssr.test.ts +102 -0
  47. package/src/__tests__/lazy-conditional.test.ts +190 -0
  48. package/src/__tests__/lazy-preamble.test.ts +297 -0
  49. package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
  50. package/src/__tests__/loop-item-root-scope.test.ts +213 -0
  51. package/src/__tests__/map-multi-return-body.test.ts +30 -3
  52. package/src/__tests__/nested-loop-conditional.test.ts +27 -6
  53. package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
  54. package/src/__tests__/preamble-declarations.test.ts +207 -0
  55. package/src/__tests__/ssr-defaults.test.ts +44 -0
  56. package/src/adapters/child-scope.ts +22 -0
  57. package/src/adapters/loop-bound-names.ts +6 -0
  58. package/src/analyzer-context.ts +43 -0
  59. package/src/analyzer.ts +33 -3
  60. package/src/index.ts +1 -0
  61. package/src/ir-to-client-js/collect-elements.ts +51 -10
  62. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
  63. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
  64. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
  65. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
  66. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
  67. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
  68. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  69. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
  70. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
  71. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
  72. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  73. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
  74. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
  75. package/src/ir-to-client-js/csr-substitute.ts +6 -1
  76. package/src/ir-to-client-js/emit-registration.ts +1 -1
  77. package/src/ir-to-client-js/html-template.ts +51 -40
  78. package/src/ir-to-client-js/index.ts +1 -1
  79. package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
  80. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
  81. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  82. package/src/ir-to-client-js/reactivity.ts +25 -0
  83. package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
  84. package/src/ir-to-client-js/types.ts +12 -0
  85. package/src/jsx-to-ir.ts +409 -24
  86. package/src/prop-rewrite.ts +149 -19
  87. package/src/rich-type-evidence.ts +6 -7
  88. package/src/ssr-defaults.ts +7 -2
  89. package/src/to-locale-date-lowering.ts +8 -2
  90. package/src/types.ts +80 -0
@@ -109,23 +109,19 @@ describe('plain `.map()` inside a conditional branch (#1065)', () => {
109
109
  expect(renderItemSection).not.toMatch(/const\s+cls\s*=\s*flag\b/)
110
110
  })
111
111
 
112
- test('regression #1065: single-line renderItem shape (no reactive effects) wraps preamble', () => {
113
- // The plain branch-loop emitter has two emission shapes — a multi-line
114
- // body when `loop.childReactive*` is non-empty, and a single-line body
115
- // (everything packed into one `mapArray(...)` line) when there are no
116
- // reactive effects on item children. The previous two tests had a
117
- // `{cell.value}` reactive text inside the `<li>` body, which forced
118
- // the multi-line branch; the single-line branch's preamble wrap was
119
- // therefore untested and could have silently regressed.
112
+ test('regression #1065: a reactive-effect-free branch loop wraps its preamble', () => {
113
+ // The #1065 obligation is that the preamble goes through
114
+ // `wrapLoopParamAsAccessor`, so a bare item read (`cell.flag`) becomes
115
+ // `cell().flag` and stays consistent with the already-wrapped template
116
+ // literal. `mapPreambleWrapped` is computed once in
117
+ // `plan/build-branch-loop.ts` and feeds every emission shape, so that is
118
+ // what this asserts.
120
119
  //
121
- // This source has no reactive expressions inside the loop item, so the
122
- // emitter takes the single-line path:
123
- //
124
- // mapArray(...,(cell, idx, __existing) => {
125
- // if (__existing) return __existing;
126
- // const cls = cell().flag ? 'on' : 'off'; ← must be wrapped
127
- // ...
128
- // })
120
+ // The SHAPE this source takes moved with the §9.5 preamble widening: a
121
+ // call-free `const` preamble no longer refuses the lazy row graph, so
122
+ // this loop now emits `mapArrayLazy` with the preamble in `createRow`
123
+ // rather than the eager single-line `mapArray(...)` body. The wrap is
124
+ // asserted where the preamble actually lands.
129
125
  const source = `
130
126
  'use client'
131
127
  import { createSignal } from '@barefootjs/client'
@@ -155,28 +151,21 @@ describe('plain `.map()` inside a conditional branch (#1065)', () => {
155
151
  expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
156
152
  const js = result.files.find(f => f.type === 'clientJs')!.content
157
153
 
158
- const renderItemSection = js.slice(
159
- js.indexOf('__disposers.push(createDisposableEffect'),
160
- js.indexOf('return () => __disposers'),
161
- )
162
- expect(renderItemSection.length).toBeGreaterThan(0)
163
- // Confirm we're in the single-line branch by asserting all of
164
- // `mapArray(...)` lives on one source line — multi-line would split
165
- // across newlines.
166
- const mapArrayLine = renderItemSection
167
- .split('\n')
168
- .find(l => l.includes('mapArray('))
169
- expect(mapArrayLine).toBeDefined()
170
- expect(mapArrayLine!).toContain(' return __existing;')
171
- expect(mapArrayLine!).toContain('cell().flag')
172
- expect(mapArrayLine!).not.toContain(' cell.flag')
154
+ const createRow = js.slice(js.indexOf('createRow:'), js.indexOf('applyItem:'))
155
+ expect(createRow.length).toBeGreaterThan(0)
156
+ expect(createRow).toContain('cell().flag')
157
+ expect(createRow).not.toContain(' cell.flag')
158
+ // The preamble must precede the clone: the row template interpolates
159
+ // `cls`.
160
+ expect(createRow.indexOf('const cls =')).toBeLessThan(createRow.indexOf('const __el ='))
173
161
  })
174
162
 
175
- test('regression #1065: destructured + single-line shape rewrites bindings to __bfItem()', () => {
163
+ test('regression #1065: a destructured param rewrites preamble bindings to __bfItem()', () => {
176
164
  // Cross-product of the two sub-features: destructured callback param
177
- // (#951) AND single-line emission shape (no reactive effects). Both
178
- // wrap pathways must compose so the preamble stays consistent with
179
- // the template literal's already-wrapped reads.
165
+ // (#951) AND a reactive-effect-free loop item. Both wrap pathways must
166
+ // compose so the preamble stays consistent with the template literal's
167
+ // already-wrapped reads. Same shape note as the test above — this loop
168
+ // is lazy-eligible since the §9.5 preamble widening.
180
169
  const source = `
181
170
  'use client'
182
171
  import { createSignal } from '@barefootjs/client'
@@ -206,17 +195,11 @@ describe('plain `.map()` inside a conditional branch (#1065)', () => {
206
195
  expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
207
196
  const js = result.files.find(f => f.type === 'clientJs')!.content
208
197
 
209
- const renderItemSection = js.slice(
210
- js.indexOf('__disposers.push(createDisposableEffect'),
211
- js.indexOf('return () => __disposers'),
212
- )
213
- const mapArrayLine = renderItemSection
214
- .split('\n')
215
- .find(l => l.includes('mapArray('))
216
- expect(mapArrayLine).toBeDefined()
217
- expect(mapArrayLine!).toContain('__bfItem().flag')
198
+ const createRow = js.slice(js.indexOf('createRow:'), js.indexOf('applyItem:'))
199
+ expect(createRow.length).toBeGreaterThan(0)
200
+ expect(createRow).toContain('__bfItem().flag')
218
201
  // Bare `flag` reference (without `__bfItem().` prefix) would mean the
219
202
  // wrap pass missed the destructured binding.
220
- expect(mapArrayLine!).not.toMatch(/\bcls\s*=\s*flag\b/)
203
+ expect(createRow).not.toMatch(/\bcls\s*=\s*flag\b/)
221
204
  })
222
205
  })
@@ -129,9 +129,15 @@ describe('CSR template: Stage-2 / post-substitution inline-safety divergence (#2
129
129
 
130
130
  // No bare `cls` identifier left in the template — it was inlined.
131
131
  expect(templateBody).not.toMatch(/(?<![-.\w$])cls(?![\w$])/)
132
- // The module-scope object literal was substituted in place of `sizeMap`,
133
- // and the prop was correctly bridged to `_p.size` — not marked unsafe.
134
- expect(templateBody).toContain(`{ sm: 'h-4', md: 'h-6' })[_p.size]`)
132
+ // The record was substituted in place of `sizeMap`, and the prop was
133
+ // correctly bridged to `_p.size` — not marked unsafe. Since #2477 the
134
+ // one-hop const resolves at IR-build time through the shared `lookup`
135
+ // template part (the same route as an inline `sizeMap[size]`, #2300),
136
+ // so the baked record is the JSON-normalised lookup form rather than
137
+ // the CSR-substitution splice of the source text. The guard this test
138
+ // exists for — no over-suppression, no bare identifier, no
139
+ // `undefined` — is unchanged.
140
+ expect(templateBody).toContain(`({"sm": "h-4", "md": "h-6"})[_p.size]`)
135
141
  expect(templateBody).not.toContain('undefined')
136
142
  })
137
143
  })
@@ -0,0 +1,167 @@
1
+ /**
2
+ * CSR template scope soundness (#2468 / adapter-tests scope-gate holes).
3
+ *
4
+ * The `hydrate('X', { template: (_p) => ... })` lambda runs at module
5
+ * scope, so every identifier it references must be either `_p`, a
6
+ * template-local binding, or a global. These tests pin the three
7
+ * emission paths that leaked init-scoped bindings into the lambda
8
+ * (each one a guaranteed `ReferenceError` on CSR mount):
9
+ *
10
+ * A. a memo body inlined into the template kept its bare destructured
11
+ * prop refs (`(value * 10)` instead of `(_p.value * 10)`);
12
+ * B. a component prop carrying a template literal over a destructured
13
+ * prop rendered into `renderChild(...)` un-rewritten
14
+ * (`${className}` instead of `${_p.className}`);
15
+ * C. a getter-elided signal (`const [, setActive] = createSignal(0)`)
16
+ * was dropped from init entirely while its setter stayed referenced
17
+ * by the emitted handler.
18
+ */
19
+
20
+ import { describe, test, expect } from 'bun:test'
21
+ import { compileJSX } from '../index'
22
+ import { HonoAdapter } from '../../../adapter-hono/src/adapter'
23
+
24
+ function clientJsOf(source: string): string {
25
+ const result = compileJSX(source, 'scope-soundness.tsx', { adapter: new HonoAdapter() })
26
+ const errors = result.errors.filter(e => e.severity === 'error')
27
+ expect(errors).toEqual([])
28
+ const clientJs = result.files.filter(f => f.type === 'clientJs').map(f => f.content).join('\n')
29
+ expect(clientJs).not.toBe('')
30
+ return clientJs
31
+ }
32
+
33
+ /** The registration template lambda's source, extracted per component. */
34
+ function templateLambdaOf(clientJs: string, name: string): string {
35
+ const start = clientJs.indexOf(`hydrate('${name}'`)
36
+ expect(start).toBeGreaterThan(-1)
37
+ const end = clientJs.indexOf('\n', start)
38
+ return clientJs.slice(start, end === -1 ? undefined : end)
39
+ }
40
+
41
+ describe('CSR template scope soundness (#2468)', () => {
42
+ test('A: memo body inlined into the template rewrites destructured prop refs to _p.*', () => {
43
+ const clientJs = clientJsOf(`
44
+ "use client"
45
+ import { createMemo } from '@barefootjs/client'
46
+
47
+ export function Derived({ value }: { value: number }) {
48
+ const doubled = createMemo(() => value * 10)
49
+ return <span>{doubled()}</span>
50
+ }
51
+ `)
52
+ const template = templateLambdaOf(clientJs, 'Derived')
53
+ expect(template).toContain('_p.value * 10')
54
+ expect(template).not.toMatch(/[^.\w]value \* 10/)
55
+ })
56
+
57
+ test('B: component-prop template literal rewrites destructured prop refs to _p.*', () => {
58
+ const clientJs = clientJsOf(`
59
+ "use client"
60
+ import { Slot } from './slot'
61
+
62
+ const base = 'chip'
63
+
64
+ interface BaseProps { className?: string }
65
+ interface ChipProps extends BaseProps { asChild?: boolean; children?: unknown }
66
+
67
+ export function Chip({ className = '', asChild = false, children, ...props }: ChipProps) {
68
+ const classes = \`\${base} \${className}\`
69
+ if (asChild) {
70
+ return <Slot className={classes} {...props}>{children}</Slot>
71
+ }
72
+ return <span class={classes} {...props}>{children}</span>
73
+ }
74
+ `)
75
+ const template = templateLambdaOf(clientJs, 'Chip')
76
+ // The renderChild props object must read the prop off _p, exactly
77
+ // like the sibling element attribute already does.
78
+ expect(template).not.toMatch(/\$\{className\}/)
79
+ expect(template).toContain('_p.className')
80
+ })
81
+
82
+ test('C: a getter-elided signal declaration still lands in init', () => {
83
+ const clientJs = clientJsOf(`
84
+ "use client"
85
+ import { createSignal } from '@barefootjs/client'
86
+
87
+ export function Fire() {
88
+ const [, setFlag] = createSignal(0)
89
+ return <button onClick={() => setFlag(1)}>go</button>
90
+ }
91
+ `)
92
+ // The setter is referenced by the click handler, so its declaring
93
+ // statement must exist in the emitted init.
94
+ expect(clientJs).toMatch(/setFlag\s*\]\s*=\s*createSignal\(0\)/)
95
+ })
96
+ })
97
+
98
+ /**
99
+ * Scope-aware prop rewrite (#2482 audit): `rewriteBarePropRefs` must
100
+ * not touch a name where a binding INSIDE the expression shadows the
101
+ * prop — a nested callback's parameter above all. The pre-fix global
102
+ * word-boundary regex rewrote the parameter declaration itself,
103
+ * emitting `.map((_p.title) => _p.title.a)`: a parse error that killed
104
+ * the whole client bundle.
105
+ */
106
+ describe('scope-aware prop rewrite (nested callback param sharing a prop name)', () => {
107
+ test('memo computation keeps the callback param bare', () => {
108
+ const clientJs = clientJsOf(`
109
+ "use client"
110
+ import { createSignal, createMemo } from '@barefootjs/client'
111
+
112
+ export function Titles({ title }: { title: string }) {
113
+ const [items, setItems] = createSignal([{ a: 'x' }])
114
+ const joined = createMemo(() => items().map((title) => title.a).join(','))
115
+ return <div><span>{joined()}</span><button onClick={() => setItems([])}>x</button></div>
116
+ }
117
+ `)
118
+ expect(clientJs).not.toMatch(/\(\s*_p\.title\s*\)\s*=>/)
119
+ expect(clientJs).not.toMatch(/_p\.title\.a/)
120
+ })
121
+
122
+ test('signal initializer keeps the callback param bare', () => {
123
+ const clientJs = clientJsOf(`
124
+ "use client"
125
+ import { createSignal } from '@barefootjs/client'
126
+
127
+ export function Titles({ title }: { title: string }) {
128
+ const [joined, setJoined] = createSignal([{ a: 'x' }].map((title) => title.a).join(','))
129
+ return <div><span>{joined()}</span><button onClick={() => setJoined('')}>x</button></div>
130
+ }
131
+ `)
132
+ expect(clientJs).not.toMatch(/\(\s*_p\.title\s*\)\s*=>/)
133
+ })
134
+
135
+ test('mixed usage rewrites exactly the genuine outer reference', () => {
136
+ const clientJs = clientJsOf(`
137
+ "use client"
138
+ import { createSignal, createMemo } from '@barefootjs/client'
139
+
140
+ export function Titles({ title }: { title: string }) {
141
+ const [items, setItems] = createSignal([{ a: 'x' }])
142
+ const label = createMemo(() => title + ':' + items().map((title) => title.a).join(','))
143
+ return <div><span>{label()}</span><button onClick={() => setItems([])}>x</button></div>
144
+ }
145
+ `)
146
+ const template = templateLambdaOf(clientJs, 'Titles')
147
+ // outer ref rewritten…
148
+ expect(template).toContain('_p.title +')
149
+ // …inner param and its references untouched
150
+ expect(template).toMatch(/\(\s*title\s*\)\s*=>\s*title\.a/)
151
+ expect(template).not.toMatch(/\(\s*_p\.title\s*\)\s*=>/)
152
+ })
153
+
154
+ test('a loop-shadowed record const is not folded into a template span', () => {
155
+ const clientJs = clientJsOf(`
156
+ const tone = { a: 'outer-a', b: 'outer-b' }
157
+
158
+ export function Tones({ items, k }: { items: { id: number; a: string; b: string }[]; k: string }) {
159
+ return <ul>{items.map((tone) => <li key={tone.id} data-t={\`t \${tone[k]}\`}>{tone.a}</li>)}</ul>
160
+ }
161
+ `)
162
+ const template = templateLambdaOf(clientJs, 'Tones')
163
+ // The span must read the row binding, not the outer record literal.
164
+ expect(template).toContain('tone[_p.k]')
165
+ expect(template).not.toContain('outer-a')
166
+ })
167
+ })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Signal-conditioned early return lowers to the root-ternary plan (#2463).
3
+ *
4
+ * `if (loading()) return <A/>; return <B/>` is semantically the root
5
+ * ternary `return loading() ? <A/> : <B/>`, and the IRIfStatement
6
+ * contract (spec/compiler.md) says the client JS handles all branches
7
+ * and switches at runtime. Before this fix the statement form emitted
8
+ * NO branch-switch effect — clicking called the setter and nothing
9
+ * subscribed, so the UI could never leave the SSR branch — and the CSR
10
+ * template lambda referenced the init-scoped signal (`ReferenceError`
11
+ * on CSR mount; the adapter-tests scope gate pinned it).
12
+ *
13
+ * The fix: a conditional-return chain whose conditions are ALL reactive
14
+ * (signal/memo reads) and that declares no branch-local scope variables
15
+ * is built as the same IRConditional chain the root ternary produces,
16
+ * wrapped in the synthetic display:contents scope element. Chains with
17
+ * static/prop conditions or branch-local declarations keep the
18
+ * IRIfStatement path (#1401/#1414 semantics) unchanged.
19
+ */
20
+
21
+ import { describe, test, expect } from 'bun:test'
22
+ import { compileJSX } from '../index'
23
+ import { HonoAdapter } from '../../../adapter-hono/src/adapter'
24
+
25
+ function clientJsOf(source: string): string {
26
+ const result = compileJSX(source, 'early-return.tsx', { adapter: new HonoAdapter() })
27
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
28
+ return result.files.filter(f => f.type === 'clientJs').map(f => f.content).join('\n')
29
+ }
30
+
31
+ const EARLY = `
32
+ "use client"
33
+ import { createSignal } from '@barefootjs/client'
34
+
35
+ export function LoadGate() {
36
+ const [loading, setLoading] = createSignal(true)
37
+ if (loading()) {
38
+ return <button onClick={() => setLoading(false)}>Loading...</button>
39
+ }
40
+ return <p>Ready</p>
41
+ }
42
+ `
43
+
44
+ const TERNARY = `
45
+ "use client"
46
+ import { createSignal } from '@barefootjs/client'
47
+
48
+ export function LoadGate() {
49
+ const [loading, setLoading] = createSignal(true)
50
+ return loading() ? <button onClick={() => setLoading(false)}>Loading...</button> : <p>Ready</p>
51
+ }
52
+ `
53
+
54
+ describe('signal-conditioned early return (#2463)', () => {
55
+ test('emits the branch-switch insert() plan', () => {
56
+ const clientJs = clientJsOf(EARLY)
57
+ // The statement form must subscribe to the signal and swap branches
58
+ // at runtime, exactly like the expression form.
59
+ expect(clientJs).toContain('insert(')
60
+ expect(clientJs).toContain('() => loading()')
61
+ })
62
+
63
+ test('CSR template lambda is scope-sound (signal substituted to its initial)', () => {
64
+ const clientJs = clientJsOf(EARLY)
65
+ const template = clientJs.match(/hydrate\('LoadGate'.*/)?.[0] ?? ''
66
+ // The module-scope lambda must not reference the init-scoped signal.
67
+ expect(template).not.toContain('loading()')
68
+ expect(template).toContain('(true) ?')
69
+ })
70
+
71
+ test('statement form and expression form lower to the same plan shape', () => {
72
+ const early = clientJsOf(EARLY)
73
+ const ternary = clientJsOf(TERNARY)
74
+ // Same runtime imports (insert present in both) and both templates
75
+ // wrapped in the synthetic scope element.
76
+ for (const js of [early, ternary]) {
77
+ expect(js).toContain('insert(')
78
+ expect(js).toContain('display:contents')
79
+ }
80
+ })
81
+
82
+ test('a prop-conditioned chain keeps the IRIfStatement path', () => {
83
+ const clientJs = clientJsOf(`
84
+ export function Badge({ active }: { active: boolean }) {
85
+ if (active) {
86
+ return <strong>on</strong>
87
+ }
88
+ return <em>off</em>
89
+ }
90
+ `)
91
+ // Static condition: no runtime branch switching, no synthetic wrapper —
92
+ // the pre-#2463 emission is unchanged.
93
+ expect(clientJs).not.toContain('insert(')
94
+ expect(clientJs).not.toContain('display:contents')
95
+ })
96
+ })
@@ -0,0 +1,102 @@
1
+ /**
2
+ * SSR projection of controlled form-control `value` (#2464 / #2465).
3
+ *
4
+ * `value` is not an attribute on `<textarea>` or `<select>` — emitting it
5
+ * verbatim ships invalid HTML that browsers ignore, so no-JS and
6
+ * pre-hydration users saw an empty textarea / the wrong option until the
7
+ * hydrate-time `.value` effect snapped it. The shared-IR lowering marks
8
+ * the attr `clientOnly` (SSR skips it; the property binding is unchanged)
9
+ * and projects the value into element content (textarea) or per-option
10
+ * `selected` comparisons (select — the shape `select-option-selected`
11
+ * already proves across every adapter).
12
+ */
13
+
14
+ import { describe, test, expect } from 'bun:test'
15
+ import { compileJSX } from '../index'
16
+ import { HonoAdapter } from '../../../adapter-hono/src/adapter'
17
+
18
+ function compiled(source: string): { template: string; clientJs: string } {
19
+ const result = compileJSX(source, 'form-value.tsx', { adapter: new HonoAdapter() })
20
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
21
+ return {
22
+ template: result.files.find(f => f.type === 'markedTemplate')?.content ?? '',
23
+ clientJs: result.files.filter(f => f.type === 'clientJs').map(f => f.content).join('\n'),
24
+ }
25
+ }
26
+
27
+ describe('controlled form-control value SSR lowering (#2464/#2465)', () => {
28
+ test('textarea value lowers to element content, keeps the .value binding', () => {
29
+ const { template, clientJs } = compiled(`
30
+ "use client"
31
+ import { createSignal } from '@barefootjs/client'
32
+ export function NoteBox() {
33
+ const [note, setNote] = createSignal('hi')
34
+ return <textarea value={note()} onInput={(e) => setNote(e.target.value)} />
35
+ }
36
+ `)
37
+ expect(template).toContain('<textarea')
38
+ expect(template).not.toContain('value={')
39
+ expect(template).toContain('{note()}')
40
+ expect(clientJs).toContain('.value = __val')
41
+ // CSR registration template interpolates slotless children raw, so the
42
+ // client-only variant must escape — a value containing `</textarea>`
43
+ // must not break out of the element on CSR mount (Copilot review).
44
+ expect(clientJs).toMatch(/<textarea[^`]*\$\{escapeText\(/)
45
+ })
46
+
47
+ test('select value lowers to selected on statically-valued options', () => {
48
+ const { template } = compiled(`
49
+ "use client"
50
+ import { createSignal } from '@barefootjs/client'
51
+ export function Pick() {
52
+ const [v, setV] = createSignal('b')
53
+ return (
54
+ <select value={v()} onChange={(e) => setV(e.target.value)}>
55
+ <option value="a">A</option>
56
+ <optgroup label="rest">
57
+ <option value="b">B</option>
58
+ </optgroup>
59
+ </select>
60
+ )
61
+ }
62
+ `)
63
+ expect(template).not.toMatch(/<select[^>]*value=/)
64
+ expect(template).toContain(`((v()) === "a")`)
65
+ expect(template).toContain(`((v()) === "b")`)
66
+ })
67
+
68
+ test('an authored selected wins; dynamic option loops are left to hydrate', () => {
69
+ const { template } = compiled(`
70
+ "use client"
71
+ import { createSignal } from '@barefootjs/client'
72
+ export function Pick() {
73
+ const [v, setV] = createSignal('b')
74
+ const [opts] = createSignal(['a', 'b'])
75
+ return (
76
+ <div>
77
+ <select value={v()}>
78
+ <option value="a" selected={false}>A</option>
79
+ </select>
80
+ <select value={v()}>
81
+ {opts().map((o) => <option key={o} value={o}>{o}</option>)}
82
+ </select>
83
+ </div>
84
+ )
85
+ }
86
+ `)
87
+ // The authored selected={false} is untouched (exactly one selected on
88
+ // that option), and the invalid select value attr is gone everywhere.
89
+ expect(template).toContain('selected={(false) || undefined}')
90
+ expect(template).not.toContain('(v()) === "a"')
91
+ expect(template).not.toMatch(/<select[^>]*value=/)
92
+ })
93
+
94
+ test('textarea with explicit children is left untouched', () => {
95
+ const { template } = compiled(`
96
+ export function Fixed() {
97
+ return <textarea value={undefined}>seed</textarea>
98
+ }
99
+ `)
100
+ expect(template).toContain('>seed</textarea>')
101
+ })
102
+ })