@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.
- 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 +890 -357
- 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/composite-loop.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/control-flow/stringify/template-parse.d.ts +14 -0
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.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/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.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/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/dist/value-references.d.ts +17 -4
- package/dist/value-references.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 +96 -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__/ir-to-client-js/imports.test.ts +18 -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__/loop-row-creation-paths.test.ts +169 -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/__tests__/value-references.test.ts +88 -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/composite-loop.ts +4 -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/control-flow/stringify/template-parse.ts +23 -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/imports.ts +5 -0
- 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 +400 -24
- 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
- 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:
|
|
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,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
|
+
})
|