@barefootjs/jsx 0.28.1 → 0.30.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 (101) 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 +890 -357
  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/composite-loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +14 -0
  25. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/html-template.d.ts +2 -3
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/imports.d.ts +2 -2
  30. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  32. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/reactivity.d.ts +1 -10
  34. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/types.d.ts +12 -0
  36. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  37. package/dist/jsx-to-ir.d.ts.map +1 -1
  38. package/dist/rich-type-evidence.d.ts.map +1 -1
  39. package/dist/ssr-defaults.d.ts.map +1 -1
  40. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  41. package/dist/types.d.ts +78 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/value-references.d.ts +17 -4
  44. package/dist/value-references.d.ts.map +1 -1
  45. package/package.json +2 -2
  46. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
  47. package/src/__tests__/branch-loop-plain.test.ts +28 -45
  48. package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
  49. package/src/__tests__/csr-template-scope-soundness.test.ts +96 -0
  50. package/src/__tests__/early-return-branch-switch.test.ts +96 -0
  51. package/src/__tests__/form-control-value-ssr.test.ts +102 -0
  52. package/src/__tests__/ir-to-client-js/imports.test.ts +18 -0
  53. package/src/__tests__/lazy-conditional.test.ts +190 -0
  54. package/src/__tests__/lazy-preamble.test.ts +297 -0
  55. package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
  56. package/src/__tests__/loop-item-root-scope.test.ts +213 -0
  57. package/src/__tests__/loop-row-creation-paths.test.ts +169 -0
  58. package/src/__tests__/map-multi-return-body.test.ts +30 -3
  59. package/src/__tests__/nested-loop-conditional.test.ts +27 -6
  60. package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
  61. package/src/__tests__/preamble-declarations.test.ts +207 -0
  62. package/src/__tests__/ssr-defaults.test.ts +44 -0
  63. package/src/__tests__/value-references.test.ts +88 -0
  64. package/src/adapters/child-scope.ts +22 -0
  65. package/src/adapters/loop-bound-names.ts +6 -0
  66. package/src/analyzer-context.ts +43 -0
  67. package/src/analyzer.ts +33 -3
  68. package/src/index.ts +1 -0
  69. package/src/ir-to-client-js/collect-elements.ts +51 -10
  70. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
  71. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
  72. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
  73. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
  74. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
  75. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
  76. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  77. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
  78. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
  79. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
  80. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  81. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +4 -0
  82. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
  83. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
  84. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +23 -4
  85. package/src/ir-to-client-js/csr-substitute.ts +6 -1
  86. package/src/ir-to-client-js/emit-registration.ts +1 -1
  87. package/src/ir-to-client-js/html-template.ts +51 -40
  88. package/src/ir-to-client-js/imports.ts +5 -0
  89. package/src/ir-to-client-js/index.ts +1 -1
  90. package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
  91. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
  92. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  93. package/src/ir-to-client-js/reactivity.ts +25 -0
  94. package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
  95. package/src/ir-to-client-js/types.ts +12 -0
  96. package/src/jsx-to-ir.ts +400 -24
  97. package/src/rich-type-evidence.ts +6 -7
  98. package/src/ssr-defaults.ts +7 -2
  99. package/src/to-locale-date-lowering.ts +8 -2
  100. package/src/types.ts +80 -0
  101. package/src/value-references.ts +26 -4
@@ -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,96 @@
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
+ })
@@ -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
+ })
@@ -310,6 +310,24 @@ describe('collectExternalImports', () => {
310
310
  const result = collectExternalImports(ir, code)
311
311
  expect(result).toEqual(["import { 日本語 } from 'some-lib'"])
312
312
  })
313
+
314
+ // #2432 follow-up: a class field name is a member key, not a read — the
315
+ // same treatment as an object-literal key above.
316
+ test('a specifier that appears ONLY as a class field name is not emitted (#2432 follow-up)', () => {
317
+ const ir = makeIR([makeImport('./utils', ['helper'])])
318
+ const code = 'class Widget { helper = 1 }'
319
+ const result = collectExternalImports(ir, code)
320
+ expect(result).toEqual([])
321
+ })
322
+
323
+ // #2432 follow-up: a COMPUTED class field name (`[helper]`) IS a read of
324
+ // the binding — pins that the class-field exclusion didn't over-exclude.
325
+ test('a specifier that appears as a COMPUTED class field name IS emitted (#2432 follow-up)', () => {
326
+ const ir = makeIR([makeImport('./utils', ['helper'])])
327
+ const code = 'class Widget { [helper] = 1 }'
328
+ const result = collectExternalImports(ir, code)
329
+ expect(result).toEqual(["import { helper } from './utils'"])
330
+ })
313
331
  })
314
332
 
315
333
  describe('collectUserDomImports', () => {
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `analyzeLazyConditional` — the §9.5 "row contains a reactive conditional"
3
+ * widening (`plan/lazy-conditional.ts`).
4
+ *
5
+ * Unit half over hand-built `LoopChildConditional` values so each refusal is
6
+ * pinned to the exact shape that produces it, plus emission assertions that an
7
+ * accepted conditional is driven from the apply bodies with its arms hoisted
8
+ * once per loop. The DOM behaviour (does the swap actually land, both ways)
9
+ * lives in `packages/client/__tests__/runtime/lazy-row-conditional.test.ts` —
10
+ * emitted shape and emitted behaviour are different claims.
11
+ */
12
+
13
+ import { describe, test, expect } from 'bun:test'
14
+ import { compileJSX } from '../index.ts'
15
+ import { TestAdapter } from '../adapters/test-adapter'
16
+ import { analyzeLazyConditional } from '../ir-to-client-js/control-flow/plan/lazy-conditional.ts'
17
+ import type { LoopChildBranchSummary, LoopChildConditional } from '../ir-to-client-js/types.ts'
18
+
19
+ const bare = (over: Partial<LoopChildBranchSummary> = {}): LoopChildBranchSummary => ({
20
+ childComponents: [],
21
+ ...over,
22
+ })
23
+
24
+ function cond(over: Partial<LoopChildConditional> = {}): LoopChildConditional {
25
+ return {
26
+ slotId: 's0',
27
+ condition: 'row.done',
28
+ whenTrueHtml: '<span class="yes">done</span>',
29
+ whenFalseHtml: '<em class="no">open</em>',
30
+ whenTrue: bare(),
31
+ whenFalse: bare(),
32
+ conditionFreeIdentifiers: new Set(['row']),
33
+ ...over,
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The caller prepares each arm through `addCondAttrToTemplate` before handing it
39
+ * over — that is where `bf-c` comes from, and where the element-vs-fragment
40
+ * decision is made. These helpers stand in for it.
41
+ */
42
+ const asElement = (html: string, slotId = 's0') => html.replace(/^(<\w+)/, `$1 bf-c="${slotId}"`)
43
+ const asFragment = (html: string, slotId = 's0') =>
44
+ `<!--bf-cond-start:${slotId}-->${html}<!--bf-cond-end:${slotId}-->`
45
+
46
+ const armsOf = (c: LoopChildConditional) => ({
47
+ whenTrueHtml: asElement(c.whenTrueHtml, c.slotId),
48
+ whenFalseHtml: asElement(c.whenFalseHtml, c.slotId),
49
+ })
50
+
51
+ describe('analyzeLazyConditional — accepted', () => {
52
+ test('two wiring-free static element arms', () => {
53
+ const c = cond()
54
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
55
+ expect(r.lazySafe).toBe(true)
56
+ if (r.lazySafe) {
57
+ expect(r.facts.whenTrueHtml).toContain('bf-c="s0"')
58
+ expect(r.facts.condition).toBe('row.done')
59
+ }
60
+ })
61
+ })
62
+
63
+ describe('analyzeLazyConditional — refusals', () => {
64
+ const wiring: Array<[string, Partial<LoopChildBranchSummary>, RegExp]> = [
65
+ ['a child component', { childComponents: [{ name: 'X', slotId: null, props: [], children: [] }] }, /child components/],
66
+ ['an inner loop', { innerLoops: [{} as never] }, /an inner loop/],
67
+ ['a nested conditional', { conditionals: [{} as never] }, /a nested conditional/],
68
+ ['events', { events: [{} as never] }, /events/],
69
+ ['a reactive attr', { reactiveAttrs: [{} as never] }, /reactive attrs/],
70
+ ['reactive text', { reactiveTexts: [{} as never] }, /reactive text/],
71
+ ]
72
+
73
+ for (const [name, over, reason] of wiring) {
74
+ test(`the true arm owns ${name}`, () => {
75
+ const c = cond({ whenTrue: bare(over) })
76
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
77
+ expect(r.lazySafe).toBe(false)
78
+ if (!r.lazySafe) expect(r.reason).toMatch(reason)
79
+ })
80
+ }
81
+
82
+ test('the FALSE arm is checked too, not just the true one', () => {
83
+ const c = cond({ whenFalse: bare({ events: [{} as never] }) })
84
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
85
+ expect(r.lazySafe).toBe(false)
86
+ if (!r.lazySafe) expect(r.reason).toMatch(/its false arm owns events/)
87
+ })
88
+
89
+ test('a fragment conditional has no single node to replace', () => {
90
+ const c = cond()
91
+ const r = analyzeLazyConditional(c, '__idx', {
92
+ whenTrueHtml: asFragment('text only'),
93
+ whenFalseHtml: asElement(c.whenFalseHtml),
94
+ })
95
+ expect(r.lazySafe).toBe(false)
96
+ if (!r.lazySafe) expect(r.reason).toMatch(/fragment conditional/)
97
+ })
98
+
99
+ test('an arm that interpolates a value cannot be hoisted once per loop', () => {
100
+ const c = cond({ whenTrueHtml: '<span>${row().label}</span>' })
101
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
102
+ expect(r.lazySafe).toBe(false)
103
+ if (!r.lazySafe) expect(r.reason).toMatch(/interpolates a value/)
104
+ })
105
+
106
+ test('a condition with no analyzable identifier set refuses rather than assumes', () => {
107
+ const c = cond({ conditionFreeIdentifiers: undefined })
108
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
109
+ expect(r.lazySafe).toBe(false)
110
+ if (!r.lazySafe) expect(r.reason).toMatch(/no analyzable identifier set/)
111
+ })
112
+
113
+ test('a condition reading the loop index refuses — the apply bodies have none', () => {
114
+ const c = cond({ conditionFreeIdentifiers: new Set(['row', '__idx']) })
115
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
116
+ expect(r.lazySafe).toBe(false)
117
+ if (!r.lazySafe) expect(r.reason).toMatch(/reads the loop index parameter '__idx'/)
118
+ })
119
+ })
120
+
121
+ // --- emission ---------------------------------------------------------------
122
+
123
+ function clientJs(source: string, file: string): string {
124
+ const result = compileJSX(source, file, { adapter: new TestAdapter() })
125
+ const js = result.files.find(f => f.path.endsWith('.client.js'))
126
+ if (!js) throw new Error(`no client JS emitted for ${file}`)
127
+ return js.content
128
+ }
129
+
130
+ const ROWS = (arm: string) => `
131
+ 'use client'
132
+ import { createSignal } from '@barefootjs/client'
133
+ type Row = { id: number; label: string; done: boolean }
134
+ export function CondRows(props: { rows: Row[] }) {
135
+ const [rows, setRows] = createSignal<Row[]>(props.rows)
136
+ return (
137
+ <ul>
138
+ {rows().map(row => (
139
+ <li key={row.id}>${arm}</li>
140
+ ))}
141
+ </ul>
142
+ )
143
+ }
144
+ `
145
+
146
+ describe('lazy row emission — wiring-free row conditional', () => {
147
+ const js = clientJs(
148
+ ROWS('{row.done ? <span class="yes">done</span> : <em class="no">open</em>}'),
149
+ 'CondRows.tsx',
150
+ )
151
+
152
+ test('the loop is lazy and calls no per-row insert()', () => {
153
+ expect(js).toContain('mapArrayLazy(')
154
+ expect(js).not.toMatch(/\bmapArray\(/)
155
+ expect(js).not.toContain('insert(')
156
+ })
157
+
158
+ test('both arms are parsed once per LOOP, outside every plan body', () => {
159
+ const beforeCall = js.slice(0, js.indexOf('mapArrayLazy('))
160
+ expect(beforeCall).toMatch(/const __cbt_l0_s\d+ = document\.createElement\('template'\)/)
161
+ expect(beforeCall).toMatch(/const __cbf_l0_s\d+ = document\.createElement\('template'\)/)
162
+ })
163
+
164
+ test('createRow only records the dedup boolean — the clone already has the right arm', () => {
165
+ const createRow = js.slice(js.indexOf('createRow:'), js.indexOf('applyItem:'))
166
+ expect(createRow).toMatch(/__l\[\d\] = !!\(row\(\)\.done\)/)
167
+ expect(createRow).not.toContain('replaceWith')
168
+ })
169
+
170
+ test('applyItem swaps by replaceWith and reassigns the ref', () => {
171
+ const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf("}, 'l0')"))
172
+ expect(applyItem).toContain('__c.replaceWith(__n)')
173
+ // Without this the next flip writes into the node the previous one detached.
174
+ expect(applyItem).toMatch(/__r\[\d\] = __n/)
175
+ })
176
+ })
177
+
178
+ describe('lazy row emission — conditionals that keep the eager path', () => {
179
+ const cases: Array<[string, string]> = [
180
+ ['an arm with an event', '{row.done ? <button onClick={() => {}}>x</button> : <em>open</em>}'],
181
+ ['an arm reading the item', '{row.done ? <span>{row.label}</span> : <em>open</em>}'],
182
+ ['a bare-value ternary (fragment form)', '{row.done ? "yes" : "no"}'],
183
+ ]
184
+ for (const [name, arm] of cases) {
185
+ test(name, () => {
186
+ const js = clientJs(ROWS(arm), 'EagerCond.tsx')
187
+ expect(js).not.toContain('mapArrayLazy(')
188
+ })
189
+ }
190
+ })