@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.
- package/dist/adapters/child-scope.d.ts +17 -0
- package/dist/adapters/child-scope.d.ts.map +1 -0
- package/dist/adapters/loop-bound-names.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +990 -367
- package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +2 -3
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +1 -10
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +12 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +24 -7
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/rich-type-evidence.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts.map +1 -1
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
- package/src/__tests__/branch-loop-plain.test.ts +28 -45
- package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
- package/src/__tests__/csr-template-scope-soundness.test.ts +167 -0
- package/src/__tests__/early-return-branch-switch.test.ts +96 -0
- package/src/__tests__/form-control-value-ssr.test.ts +102 -0
- package/src/__tests__/lazy-conditional.test.ts +190 -0
- package/src/__tests__/lazy-preamble.test.ts +297 -0
- package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
- package/src/__tests__/loop-item-root-scope.test.ts +213 -0
- package/src/__tests__/map-multi-return-body.test.ts +30 -3
- package/src/__tests__/nested-loop-conditional.test.ts +27 -6
- package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
- package/src/__tests__/preamble-declarations.test.ts +207 -0
- package/src/__tests__/ssr-defaults.test.ts +44 -0
- package/src/adapters/child-scope.ts +22 -0
- package/src/adapters/loop-bound-names.ts +6 -0
- package/src/analyzer-context.ts +43 -0
- package/src/analyzer.ts +33 -3
- package/src/index.ts +1 -0
- package/src/ir-to-client-js/collect-elements.ts +51 -10
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
- package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
- package/src/ir-to-client-js/csr-substitute.ts +6 -1
- package/src/ir-to-client-js/emit-registration.ts +1 -1
- package/src/ir-to-client-js/html-template.ts +51 -40
- package/src/ir-to-client-js/index.ts +1 -1
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
- package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
- package/src/ir-to-client-js/reactivity.ts +25 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
- package/src/ir-to-client-js/types.ts +12 -0
- package/src/jsx-to-ir.ts +409 -24
- package/src/prop-rewrite.ts +149 -19
- package/src/rich-type-evidence.ts +6 -7
- package/src/ssr-defaults.ts +7 -2
- package/src/to-locale-date-lowering.ts +8 -2
- 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:
|
|
113
|
-
// The
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// `
|
|
118
|
-
//
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
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
|
|
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
|
|
178
|
-
//
|
|
179
|
-
//
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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(
|
|
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
|
|
133
|
-
//
|
|
134
|
-
|
|
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
|
+
})
|