@barefootjs/jsx 0.2.0 → 0.4.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/debug.d.ts +78 -2
- package/dist/debug.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +331 -15
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/auto-defer-brand.test.ts +284 -0
- package/src/__tests__/debug.test.ts +346 -1
- package/src/debug.ts +450 -20
- package/src/index.ts +10 -1
- package/src/ir-to-client-js/html-template.ts +17 -0
- package/src/jsx-to-ir.ts +39 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@barefootjs/jsx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "JSX compiler for BarefootJS - transforms JSX to server HTML + client JS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -49,10 +49,10 @@
|
|
|
49
49
|
"directory": "packages/jsx"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@barefootjs/shared": "0.
|
|
52
|
+
"@barefootjs/shared": "0.4.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@barefootjs/client": "0.2.0",
|
|
55
|
+
"@barefootjs/client": ">=0.2.0",
|
|
56
56
|
"typescript": "^5.0.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-defer of reactive brand-package bindings (#1638).
|
|
3
|
+
*
|
|
4
|
+
* Controlled bindings to `@barefootjs/form` state — `value={field.value()}`,
|
|
5
|
+
* `disabled={form.isSubmitting()}`, `{field.error() && …}` — read per-instance
|
|
6
|
+
* init-scope state (`createForm` calls `createSignal` internally) that the
|
|
7
|
+
* module-scope SSR template lambda cannot evaluate. Previously each binding
|
|
8
|
+
* raised BF061 and forced a manual `/* @client */`. The compiler now treats
|
|
9
|
+
* such reads as implicitly client-only: the SSR template skips them and a
|
|
10
|
+
* hydrate-time effect applies the value — exactly what `/* @client */` did.
|
|
11
|
+
*
|
|
12
|
+
* These tests need a real TypeChecker that resolves the `Reactive<T>` brand,
|
|
13
|
+
* so they build a ts.Program with virtual form-library type defs and inject
|
|
14
|
+
* it into `compileJSX` (the brand is invisible to the regex fallback).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, test, expect } from 'bun:test'
|
|
18
|
+
import ts from 'typescript'
|
|
19
|
+
import path from 'path'
|
|
20
|
+
import { compileJSX } from '../compiler'
|
|
21
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
22
|
+
import { extractTemplateBody, extractInitBody } from './staged-ir/helpers'
|
|
23
|
+
|
|
24
|
+
const adapter = new TestAdapter()
|
|
25
|
+
|
|
26
|
+
const FORM_DEFS = `
|
|
27
|
+
export type Reactive<T> = T & { readonly __reactive: true };
|
|
28
|
+
export type Memo<T> = Reactive<() => T>;
|
|
29
|
+
|
|
30
|
+
export interface FieldReturn<V> {
|
|
31
|
+
value: Reactive<() => V>;
|
|
32
|
+
error: Reactive<() => string>;
|
|
33
|
+
setValue: (value: V) => void;
|
|
34
|
+
handleInput: (e: Event) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FormReturn {
|
|
38
|
+
field: (name: string) => FieldReturn<string>;
|
|
39
|
+
isSubmitting: Reactive<() => boolean>;
|
|
40
|
+
handleSubmit: (e: Event) => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export declare function createForm(opts?: unknown): FormReturn;
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
interface BrandCompileResult {
|
|
47
|
+
errors: string[]
|
|
48
|
+
templateBody: string
|
|
49
|
+
initBody: string
|
|
50
|
+
clientJs: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compile `componentSource` with a TypeChecker that resolves the form brand.
|
|
55
|
+
* The component must `import { createForm } from './_form-defs'`.
|
|
56
|
+
*/
|
|
57
|
+
function compileWithBrand(componentSource: string): BrandCompileResult {
|
|
58
|
+
const baseDir = path.resolve(__dirname)
|
|
59
|
+
const componentPath = path.join(baseDir, '_brand-component.tsx')
|
|
60
|
+
const defsPath = path.join(baseDir, '_form-defs.ts')
|
|
61
|
+
|
|
62
|
+
const virtualFiles = new Map<string, string>([
|
|
63
|
+
[componentPath, componentSource],
|
|
64
|
+
[defsPath, FORM_DEFS],
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
const compilerOptions: ts.CompilerOptions = {
|
|
68
|
+
target: ts.ScriptTarget.Latest,
|
|
69
|
+
module: ts.ModuleKind.ESNext,
|
|
70
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
71
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
72
|
+
strict: true,
|
|
73
|
+
skipLibCheck: true,
|
|
74
|
+
noEmit: true,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const defaultHost = ts.createCompilerHost(compilerOptions)
|
|
78
|
+
const host: ts.CompilerHost = {
|
|
79
|
+
...defaultHost,
|
|
80
|
+
getSourceFile(fileName, languageVersion) {
|
|
81
|
+
const resolved = path.resolve(fileName)
|
|
82
|
+
const content = virtualFiles.get(resolved)
|
|
83
|
+
if (content !== undefined) {
|
|
84
|
+
const kind = resolved.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
85
|
+
return ts.createSourceFile(fileName, content, languageVersion, true, kind)
|
|
86
|
+
}
|
|
87
|
+
return defaultHost.getSourceFile(fileName, languageVersion)
|
|
88
|
+
},
|
|
89
|
+
fileExists(fileName) {
|
|
90
|
+
return virtualFiles.has(path.resolve(fileName)) || defaultHost.fileExists(fileName)
|
|
91
|
+
},
|
|
92
|
+
readFile(fileName) {
|
|
93
|
+
return virtualFiles.get(path.resolve(fileName)) ?? defaultHost.readFile(fileName)
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const program = ts.createProgram([componentPath], compilerOptions, host)
|
|
98
|
+
const result = compileJSX(componentSource, componentPath, { adapter, program })
|
|
99
|
+
const clientJs = result.files.find((f) => f.type === 'clientJs')?.content ?? ''
|
|
100
|
+
return {
|
|
101
|
+
errors: result.errors.map((e) => `[${e.code}] ${e.message}`),
|
|
102
|
+
templateBody: extractTemplateBody(clientJs),
|
|
103
|
+
initBody: extractInitBody(clientJs),
|
|
104
|
+
clientJs,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe('auto-defer brand-package reactive bindings (#1638)', () => {
|
|
109
|
+
test('sanity: the injected program resolves the Reactive<T> brand', () => {
|
|
110
|
+
// If the brand did not resolve, the regex fallback would not flag
|
|
111
|
+
// `email.value()` reactive and nothing below would be meaningful.
|
|
112
|
+
const { errors } = compileWithBrand(`
|
|
113
|
+
'use client'
|
|
114
|
+
import { createForm } from './_form-defs'
|
|
115
|
+
|
|
116
|
+
export function Probe() {
|
|
117
|
+
const form = createForm()
|
|
118
|
+
const email = form.field('email')
|
|
119
|
+
return <input value={email.value()} />
|
|
120
|
+
}
|
|
121
|
+
`)
|
|
122
|
+
// No BF050 (we supplied a program) and, crucially, no BF061.
|
|
123
|
+
expect(errors.find((e) => e.startsWith('[BF061]'))).toBeUndefined()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('element attribute `value={field.value()}` does not raise BF061', () => {
|
|
127
|
+
const { errors, templateBody, initBody } = compileWithBrand(`
|
|
128
|
+
'use client'
|
|
129
|
+
import { createForm } from './_form-defs'
|
|
130
|
+
|
|
131
|
+
export function SignupForm() {
|
|
132
|
+
const form = createForm()
|
|
133
|
+
const email = form.field('email')
|
|
134
|
+
return <input value={email.value()} />
|
|
135
|
+
}
|
|
136
|
+
`)
|
|
137
|
+
|
|
138
|
+
expect(errors.find((e) => e.startsWith('[BF061]'))).toBeUndefined()
|
|
139
|
+
// SSR template must not carry the deferred attribute...
|
|
140
|
+
expect(templateBody).not.toContain('value=')
|
|
141
|
+
// ...but the element keeps a slot marker and init wires the value.
|
|
142
|
+
expect(templateBody).toMatch(/bf="s\d+"/)
|
|
143
|
+
expect(initBody).toMatch(/setAttribute\(['"]value['"]|\.value\s*=/)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('multiple controlled bindings each defer (value + disabled)', () => {
|
|
147
|
+
const { errors, templateBody } = compileWithBrand(`
|
|
148
|
+
'use client'
|
|
149
|
+
import { createForm } from './_form-defs'
|
|
150
|
+
|
|
151
|
+
export function SignupForm() {
|
|
152
|
+
const form = createForm()
|
|
153
|
+
const email = form.field('email')
|
|
154
|
+
return <input value={email.value()} disabled={form.isSubmitting()} />
|
|
155
|
+
}
|
|
156
|
+
`)
|
|
157
|
+
|
|
158
|
+
expect(errors.find((e) => e.startsWith('[BF061]'))).toBeUndefined()
|
|
159
|
+
expect(templateBody).not.toContain('value=')
|
|
160
|
+
expect(templateBody).not.toContain('disabled=')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('conditional condition `field.error() && <p/>` defers instead of BF061', () => {
|
|
164
|
+
const { errors } = compileWithBrand(`
|
|
165
|
+
'use client'
|
|
166
|
+
import { createForm } from './_form-defs'
|
|
167
|
+
|
|
168
|
+
export function SignupForm() {
|
|
169
|
+
const form = createForm()
|
|
170
|
+
const email = form.field('email')
|
|
171
|
+
return (
|
|
172
|
+
<form>
|
|
173
|
+
<input value={email.value()} />
|
|
174
|
+
{email.error() && <p>{email.error()}</p>}
|
|
175
|
+
</form>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
`)
|
|
179
|
+
|
|
180
|
+
expect(errors.find((e) => e.startsWith('[BF061]'))).toBeUndefined()
|
|
181
|
+
expect(errors.find((e) => e.startsWith('[BF060]'))).toBeUndefined()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('native createSignal getter is NOT deferred (keeps SSR value)', () => {
|
|
185
|
+
// Same component imports the brand package (so a TypeChecker is present),
|
|
186
|
+
// but `count()` is a real signal with a derivable initial value — it must
|
|
187
|
+
// keep rendering server-side, not get stripped to a hydrate-only binding.
|
|
188
|
+
const { templateBody } = compileWithBrand(`
|
|
189
|
+
'use client'
|
|
190
|
+
import { createForm } from './_form-defs'
|
|
191
|
+
import { createSignal } from '@barefootjs/client'
|
|
192
|
+
|
|
193
|
+
export function Mixed() {
|
|
194
|
+
const form = createForm()
|
|
195
|
+
const [count, setCount] = createSignal(0)
|
|
196
|
+
return <input data-count={count()} />
|
|
197
|
+
}
|
|
198
|
+
`)
|
|
199
|
+
|
|
200
|
+
// The signal-backed attribute is still emitted into the SSR template.
|
|
201
|
+
expect(templateBody).toContain('data-count=')
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('client hydrate template defers brand conditionals (#1645)', () => {
|
|
206
|
+
// The `template: (_p) => ...` lambda runs at module scope when the
|
|
207
|
+
// component is client-rendered via `createComponent` (not when hydrating
|
|
208
|
+
// existing SSR DOM). It cannot reproduce per-instance `createForm` state,
|
|
209
|
+
// so an auto-deferred conditional must emit empty cond markers — exactly
|
|
210
|
+
// like the SSR adapter — and let `init`'s `insert()` populate the branch.
|
|
211
|
+
|
|
212
|
+
test('non-inlinable createForm (onSubmit): no undefined.field, emits cond markers', () => {
|
|
213
|
+
const { templateBody } = compileWithBrand(`
|
|
214
|
+
'use client'
|
|
215
|
+
import { createForm } from './_form-defs'
|
|
216
|
+
|
|
217
|
+
export function SignupForm() {
|
|
218
|
+
const form = createForm({
|
|
219
|
+
onSubmit: async (data) => { await fetch('/signup', { method: 'POST', body: JSON.stringify(data) }) },
|
|
220
|
+
})
|
|
221
|
+
const email = form.field('email')
|
|
222
|
+
return (
|
|
223
|
+
<form>
|
|
224
|
+
<input value={email.value()} />
|
|
225
|
+
{email.error() && <p>{email.error()}</p>}
|
|
226
|
+
</form>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
`)
|
|
230
|
+
|
|
231
|
+
// Never re-derive the form at module scope: `undefined.field(...)` throws.
|
|
232
|
+
expect(templateBody).not.toContain('undefined.field')
|
|
233
|
+
expect(templateBody).not.toContain('.field(')
|
|
234
|
+
// The deferred conditional collapses to the same empty markers SSR emits.
|
|
235
|
+
expect(templateBody).toMatch(/<!--bf-cond-start:s\d+--><!--bf-cond-end:s\d+-->/)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('inlinable createForm (no onSubmit): no re-inlined createForm in template', () => {
|
|
239
|
+
const { templateBody } = compileWithBrand(`
|
|
240
|
+
'use client'
|
|
241
|
+
import { createForm } from './_form-defs'
|
|
242
|
+
|
|
243
|
+
export function SignupForm() {
|
|
244
|
+
const form = createForm({ defaultValues: { email: '' } })
|
|
245
|
+
const email = form.field('email')
|
|
246
|
+
return (
|
|
247
|
+
<form>
|
|
248
|
+
<input value={email.value()} />
|
|
249
|
+
{email.error() && <p>{email.error()}</p>}
|
|
250
|
+
</form>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
`)
|
|
254
|
+
|
|
255
|
+
// A re-inlined `createForm({...})` would build a throwaway instance on
|
|
256
|
+
// every template render (error always '', never the live instance).
|
|
257
|
+
expect(templateBody).not.toContain('createForm(')
|
|
258
|
+
expect(templateBody).not.toContain('undefined.field')
|
|
259
|
+
expect(templateBody).toMatch(/<!--bf-cond-start:s\d+--><!--bf-cond-end:s\d+-->/)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('init still wires the deferred conditional via insert()', () => {
|
|
263
|
+
const { initBody } = compileWithBrand(`
|
|
264
|
+
'use client'
|
|
265
|
+
import { createForm } from './_form-defs'
|
|
266
|
+
|
|
267
|
+
export function SignupForm() {
|
|
268
|
+
const form = createForm()
|
|
269
|
+
const email = form.field('email')
|
|
270
|
+
return (
|
|
271
|
+
<form>
|
|
272
|
+
<input value={email.value()} />
|
|
273
|
+
{email.error() && <p>{email.error()}</p>}
|
|
274
|
+
</form>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
`)
|
|
278
|
+
|
|
279
|
+
// The reactive binding lives in init (where `email`/`form` are in scope),
|
|
280
|
+
// not in the module-scope template lambda.
|
|
281
|
+
expect(initBody).toMatch(/insert\(/)
|
|
282
|
+
expect(initBody).toContain('email.error()')
|
|
283
|
+
})
|
|
284
|
+
})
|
|
@@ -11,14 +11,20 @@ import {
|
|
|
11
11
|
buildEventSummary,
|
|
12
12
|
buildComponentAnalysis,
|
|
13
13
|
buildLoopSummary,
|
|
14
|
+
buildWhyUpdate,
|
|
14
15
|
traceUpdatePath,
|
|
15
16
|
formatComponentGraph,
|
|
16
17
|
formatUpdatePath,
|
|
17
18
|
formatEventSummary,
|
|
18
19
|
formatLoopSummary,
|
|
20
|
+
formatWhyUpdate,
|
|
19
21
|
formatSignalTrace,
|
|
20
22
|
generateStaticTrace,
|
|
21
23
|
graphToJSON,
|
|
24
|
+
describeFallback,
|
|
25
|
+
formatFallbackExplanations,
|
|
26
|
+
buildComponentSummary,
|
|
27
|
+
formatComponentSummary,
|
|
22
28
|
} from '../debug'
|
|
23
29
|
|
|
24
30
|
const counterSource = `
|
|
@@ -815,7 +821,7 @@ describe('buildEventSummary', () => {
|
|
|
815
821
|
expect(click.setterCalls).toHaveLength(1)
|
|
816
822
|
expect(click.setterCalls[0].setter).toBe('setTodos')
|
|
817
823
|
expect(click.setterCalls[0].signal).toBe('todos')
|
|
818
|
-
expect(click.setterCalls[0].via).
|
|
824
|
+
expect(click.setterCalls[0].via).toEqual(['addTodo'])
|
|
819
825
|
})
|
|
820
826
|
|
|
821
827
|
test('includes component prop events (Button.onClick)', () => {
|
|
@@ -1055,3 +1061,342 @@ describe('formatLoopSummary', () => {
|
|
|
1055
1061
|
expect(textBindings.length).toBeGreaterThan(0)
|
|
1056
1062
|
})
|
|
1057
1063
|
})
|
|
1064
|
+
|
|
1065
|
+
// =============================================================================
|
|
1066
|
+
// Why-update analysis (bf debug why-update)
|
|
1067
|
+
// =============================================================================
|
|
1068
|
+
|
|
1069
|
+
describe('buildWhyUpdate', () => {
|
|
1070
|
+
test('traces attribute binding back to signal and event handler', () => {
|
|
1071
|
+
const source = `
|
|
1072
|
+
'use client'
|
|
1073
|
+
import { createSignal } from '@barefootjs/client'
|
|
1074
|
+
|
|
1075
|
+
export function Panel() {
|
|
1076
|
+
const [color, setColor] = createSignal('red')
|
|
1077
|
+
return (
|
|
1078
|
+
<div>
|
|
1079
|
+
<div style={color()} />
|
|
1080
|
+
<button onClick={() => setColor('blue')}>Change</button>
|
|
1081
|
+
</div>
|
|
1082
|
+
)
|
|
1083
|
+
}
|
|
1084
|
+
`
|
|
1085
|
+
const result = buildWhyUpdate(source, 'Panel.tsx', 'style')
|
|
1086
|
+
expect(result).not.toBeNull()
|
|
1087
|
+
expect(result!.binding).toBe('style')
|
|
1088
|
+
expect(result!.expression).toContain('color()')
|
|
1089
|
+
expect(result!.deps).toHaveLength(1)
|
|
1090
|
+
expect(result!.deps[0].name).toBe('color')
|
|
1091
|
+
expect(result!.deps[0].kind).toBe('signal')
|
|
1092
|
+
expect(result!.deps[0].changedBy.length).toBeGreaterThan(0)
|
|
1093
|
+
expect(result!.deps[0].changedBy[0].setter).toBe('setColor')
|
|
1094
|
+
})
|
|
1095
|
+
|
|
1096
|
+
test('traces through memo dependencies', () => {
|
|
1097
|
+
const source = `
|
|
1098
|
+
'use client'
|
|
1099
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
1100
|
+
|
|
1101
|
+
export function Dashboard() {
|
|
1102
|
+
const [count, setCount] = createSignal(0)
|
|
1103
|
+
const doubled = createMemo(() => count() * 2)
|
|
1104
|
+
return (
|
|
1105
|
+
<div>
|
|
1106
|
+
<span>{doubled()}</span>
|
|
1107
|
+
<button onClick={() => setCount(n => n + 1)}>+</button>
|
|
1108
|
+
</div>
|
|
1109
|
+
)
|
|
1110
|
+
}
|
|
1111
|
+
`
|
|
1112
|
+
const result = buildWhyUpdate(source, 'Dashboard.tsx', 's0')
|
|
1113
|
+
expect(result).not.toBeNull()
|
|
1114
|
+
const memoDep = result!.deps.find(d => d.kind === 'memo')
|
|
1115
|
+
expect(memoDep).toBeDefined()
|
|
1116
|
+
expect(memoDep!.name).toBe('doubled')
|
|
1117
|
+
expect(memoDep!.dependsOn).toContain('count')
|
|
1118
|
+
const signalDep = result!.deps.find(d => d.kind === 'signal')
|
|
1119
|
+
expect(signalDep).toBeDefined()
|
|
1120
|
+
expect(signalDep!.name).toBe('count')
|
|
1121
|
+
expect(signalDep!.changedBy[0].setter).toBe('setCount')
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
test('returns null for unknown binding', () => {
|
|
1125
|
+
const result = buildWhyUpdate(counterSource, 'Counter.tsx', 'nonExistent')
|
|
1126
|
+
expect(result).toBeNull()
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
test('traces indirect setter via local function', () => {
|
|
1130
|
+
const source = `
|
|
1131
|
+
'use client'
|
|
1132
|
+
import { createSignal } from '@barefootjs/client'
|
|
1133
|
+
|
|
1134
|
+
export function App() {
|
|
1135
|
+
const [items, setItems] = createSignal([])
|
|
1136
|
+
function addItem(text: string) {
|
|
1137
|
+
setItems(prev => [...prev, text])
|
|
1138
|
+
}
|
|
1139
|
+
return (
|
|
1140
|
+
<div>
|
|
1141
|
+
<span>{items().length}</span>
|
|
1142
|
+
<button onClick={() => addItem('test')}>Add</button>
|
|
1143
|
+
</div>
|
|
1144
|
+
)
|
|
1145
|
+
}
|
|
1146
|
+
`
|
|
1147
|
+
const result = buildWhyUpdate(source, 'App.tsx', 's0')
|
|
1148
|
+
expect(result).not.toBeNull()
|
|
1149
|
+
const signalDep = result!.deps.find(d => d.name === 'items')
|
|
1150
|
+
expect(signalDep).toBeDefined()
|
|
1151
|
+
expect(signalDep!.changedBy[0].via).toEqual(['addItem'])
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
test('includes classification and wrapReason for fallback bindings', () => {
|
|
1155
|
+
const source = `
|
|
1156
|
+
'use client'
|
|
1157
|
+
import { createSignal } from '@barefootjs/client'
|
|
1158
|
+
import { formatTitle } from './format'
|
|
1159
|
+
|
|
1160
|
+
export function Page() {
|
|
1161
|
+
const [, setFoo] = createSignal(0)
|
|
1162
|
+
const page = 'home'
|
|
1163
|
+
return <h1 onClick={() => setFoo(1)}>{formatTitle(page)}</h1>
|
|
1164
|
+
}
|
|
1165
|
+
`
|
|
1166
|
+
const graph = buildComponentGraph(source, 'Page.tsx')
|
|
1167
|
+
const fallback = graph.domBindings.find(d => d.classification === 'fallback' && d.type === 'text')
|
|
1168
|
+
expect(fallback).toBeDefined()
|
|
1169
|
+
const result = buildWhyUpdate(source, 'Page.tsx', fallback!.slotId)
|
|
1170
|
+
expect(result).not.toBeNull()
|
|
1171
|
+
expect(result!.classification).toBe('fallback')
|
|
1172
|
+
expect(result!.wrapReason).toBeDefined()
|
|
1173
|
+
expect(result!.deps).toHaveLength(0)
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
test('returns ambiguous result when multiple bindings share the same label', () => {
|
|
1177
|
+
const source = `
|
|
1178
|
+
'use client'
|
|
1179
|
+
import { createSignal } from '@barefootjs/client'
|
|
1180
|
+
|
|
1181
|
+
export function TwoStyles() {
|
|
1182
|
+
const [a, setA] = createSignal('red')
|
|
1183
|
+
const [b, setB] = createSignal('blue')
|
|
1184
|
+
return (
|
|
1185
|
+
<div>
|
|
1186
|
+
<div style={a()} />
|
|
1187
|
+
<div style={b()} />
|
|
1188
|
+
<button onClick={() => setA('green')}>A</button>
|
|
1189
|
+
</div>
|
|
1190
|
+
)
|
|
1191
|
+
}
|
|
1192
|
+
`
|
|
1193
|
+
const result = buildWhyUpdate(source, 'TwoStyles.tsx', 'style')
|
|
1194
|
+
expect(result).not.toBeNull()
|
|
1195
|
+
expect(result!.ambiguous).toBeDefined()
|
|
1196
|
+
expect(result!.ambiguous!.length).toBeGreaterThan(1)
|
|
1197
|
+
})
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
describe('formatWhyUpdate', () => {
|
|
1201
|
+
test('produces readable output', () => {
|
|
1202
|
+
const source = `
|
|
1203
|
+
'use client'
|
|
1204
|
+
import { createSignal } from '@barefootjs/client'
|
|
1205
|
+
|
|
1206
|
+
export function Panel() {
|
|
1207
|
+
const [color, setColor] = createSignal('red')
|
|
1208
|
+
return (
|
|
1209
|
+
<div>
|
|
1210
|
+
<div style={color()} />
|
|
1211
|
+
<button onClick={() => setColor('blue')}>Change</button>
|
|
1212
|
+
</div>
|
|
1213
|
+
)
|
|
1214
|
+
}
|
|
1215
|
+
`
|
|
1216
|
+
const result = buildWhyUpdate(source, 'Panel.tsx', 'style')!
|
|
1217
|
+
const output = formatWhyUpdate(result)
|
|
1218
|
+
expect(output).toContain('style updates because:')
|
|
1219
|
+
expect(output).toContain('color changes from:')
|
|
1220
|
+
expect(output).toContain('setColor')
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
test('shows fallback note when binding is fallback-wrapped', () => {
|
|
1224
|
+
const source = `
|
|
1225
|
+
'use client'
|
|
1226
|
+
import { createSignal } from '@barefootjs/client'
|
|
1227
|
+
import { formatTitle } from './format'
|
|
1228
|
+
|
|
1229
|
+
export function Page() {
|
|
1230
|
+
const [, setFoo] = createSignal(0)
|
|
1231
|
+
return <h1 onClick={() => setFoo(1)}>{formatTitle('x')}</h1>
|
|
1232
|
+
}
|
|
1233
|
+
`
|
|
1234
|
+
const graph = buildComponentGraph(source, 'Page.tsx')
|
|
1235
|
+
const fallback = graph.domBindings.find(d => d.classification === 'fallback' && d.type === 'text')
|
|
1236
|
+
expect(fallback).toBeDefined()
|
|
1237
|
+
const result = buildWhyUpdate(source, 'Page.tsx', fallback!.slotId)!
|
|
1238
|
+
const output = formatWhyUpdate(result)
|
|
1239
|
+
expect(output).toContain('fallback-wrapped binding')
|
|
1240
|
+
expect(output).toContain('could not statically prove')
|
|
1241
|
+
})
|
|
1242
|
+
})
|
|
1243
|
+
|
|
1244
|
+
// =============================================================================
|
|
1245
|
+
// Fallback explanations (bf debug fallbacks improved, #1611 TODO 5)
|
|
1246
|
+
// =============================================================================
|
|
1247
|
+
|
|
1248
|
+
describe('describeFallback', () => {
|
|
1249
|
+
test('provides human-readable reason for opaque function call', () => {
|
|
1250
|
+
const source = `
|
|
1251
|
+
'use client'
|
|
1252
|
+
import { createSignal } from '@barefootjs/client'
|
|
1253
|
+
import { formatTitle } from './format'
|
|
1254
|
+
|
|
1255
|
+
export function Page() {
|
|
1256
|
+
const [, setFoo] = createSignal(0)
|
|
1257
|
+
const page = 'home'
|
|
1258
|
+
return <h1 onClick={() => setFoo(1)}>{formatTitle(page)}</h1>
|
|
1259
|
+
}
|
|
1260
|
+
`
|
|
1261
|
+
const graph = buildComponentGraph(source, 'Page.tsx')
|
|
1262
|
+
const fallback = graph.domBindings.find(d => d.classification === 'fallback' && d.type === 'text')
|
|
1263
|
+
expect(fallback).toBeDefined()
|
|
1264
|
+
const ex = describeFallback(fallback!)
|
|
1265
|
+
expect(ex.reason).toContain('opaque function call')
|
|
1266
|
+
expect(ex.reason).toContain('text interpolation')
|
|
1267
|
+
expect(ex.suggestion).toContain('createMemo')
|
|
1268
|
+
expect(ex.isEventHandler).toBe(false)
|
|
1269
|
+
})
|
|
1270
|
+
|
|
1271
|
+
test('identifies event handler props as safe fallbacks', () => {
|
|
1272
|
+
const source = `
|
|
1273
|
+
'use client'
|
|
1274
|
+
import { createSignal } from '@barefootjs/client'
|
|
1275
|
+
import { Button } from './Button'
|
|
1276
|
+
|
|
1277
|
+
export function Counter() {
|
|
1278
|
+
const [count, setCount] = createSignal(0)
|
|
1279
|
+
return <Button onClick={() => setCount(0)}>Reset</Button>
|
|
1280
|
+
}
|
|
1281
|
+
`
|
|
1282
|
+
const graph = buildComponentGraph(source, 'Counter.tsx')
|
|
1283
|
+
const fallback = graph.domBindings.find(d =>
|
|
1284
|
+
d.classification === 'fallback' && d.label.includes('onClick'),
|
|
1285
|
+
)
|
|
1286
|
+
expect(fallback).toBeDefined()
|
|
1287
|
+
const ex = describeFallback(fallback!)
|
|
1288
|
+
expect(ex.isEventHandler).toBe(true)
|
|
1289
|
+
expect(ex.suggestion).toContain('safe to ignore')
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
test('includes source location', () => {
|
|
1293
|
+
const source = `
|
|
1294
|
+
'use client'
|
|
1295
|
+
import { createSignal } from '@barefootjs/client'
|
|
1296
|
+
import { format } from './fmt'
|
|
1297
|
+
|
|
1298
|
+
export function Tag() {
|
|
1299
|
+
const [, setFoo] = createSignal(0)
|
|
1300
|
+
return <button class={format('x')} onClick={() => setFoo(1)}>x</button>
|
|
1301
|
+
}
|
|
1302
|
+
`
|
|
1303
|
+
const graph = buildComponentGraph(source, 'Tag.tsx')
|
|
1304
|
+
const fallback = graph.domBindings.find(d => d.classification === 'fallback' && d.type === 'attribute')
|
|
1305
|
+
expect(fallback).toBeDefined()
|
|
1306
|
+
const ex = describeFallback(fallback!)
|
|
1307
|
+
expect(ex.loc).toBeDefined()
|
|
1308
|
+
expect(ex.loc!.line).toBeGreaterThan(0)
|
|
1309
|
+
})
|
|
1310
|
+
})
|
|
1311
|
+
|
|
1312
|
+
describe('formatFallbackExplanations', () => {
|
|
1313
|
+
test('produces detailed output with reason and suggestion', () => {
|
|
1314
|
+
const source = `
|
|
1315
|
+
'use client'
|
|
1316
|
+
import { createSignal } from '@barefootjs/client'
|
|
1317
|
+
import { formatTitle } from './format'
|
|
1318
|
+
|
|
1319
|
+
export function Page() {
|
|
1320
|
+
const [, setFoo] = createSignal(0)
|
|
1321
|
+
const page = 'home'
|
|
1322
|
+
return <h1 onClick={() => setFoo(1)}>{formatTitle(page)}</h1>
|
|
1323
|
+
}
|
|
1324
|
+
`
|
|
1325
|
+
const graph = buildComponentGraph(source, 'Page.tsx')
|
|
1326
|
+
const fallbacks = graph.domBindings.filter(d => d.classification === 'fallback')
|
|
1327
|
+
const output = formatFallbackExplanations(graph.componentName, fallbacks)
|
|
1328
|
+
expect(output).toContain('fallback:')
|
|
1329
|
+
expect(output).toContain('expression:')
|
|
1330
|
+
expect(output).toContain('reason:')
|
|
1331
|
+
expect(output).toContain('suggestion:')
|
|
1332
|
+
expect(output).toContain('runtime deps:')
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
test('returns clean message for zero fallbacks', () => {
|
|
1336
|
+
const output = formatFallbackExplanations('Counter', [])
|
|
1337
|
+
expect(output).toContain('no fallback-wrapped expressions')
|
|
1338
|
+
})
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
// =============================================================================
|
|
1342
|
+
// Component summary (bf debug summary, #1611 TODO 6)
|
|
1343
|
+
// =============================================================================
|
|
1344
|
+
|
|
1345
|
+
describe('buildComponentSummary', () => {
|
|
1346
|
+
test('counts signals, memos, effects, and bindings for a client component', () => {
|
|
1347
|
+
const summary = buildComponentSummary(dashboardSource, 'Dashboard.tsx')
|
|
1348
|
+
expect(summary.componentName).toBe('Dashboard')
|
|
1349
|
+
expect(summary.hydrated).toBe(true)
|
|
1350
|
+
expect(summary.clientBundle).toBe('Dashboard.client.js')
|
|
1351
|
+
expect(summary.signals).toBe(1)
|
|
1352
|
+
expect(summary.memos).toBe(1)
|
|
1353
|
+
expect(summary.effects).toBe(1)
|
|
1354
|
+
expect(summary.eventHandlers).toBeGreaterThanOrEqual(1)
|
|
1355
|
+
expect(summary.dynamicTextBindings).toBeGreaterThanOrEqual(1)
|
|
1356
|
+
})
|
|
1357
|
+
|
|
1358
|
+
test('reports non-hydrated for stateless component', () => {
|
|
1359
|
+
const source = `
|
|
1360
|
+
export function Card(props: { title: string }) {
|
|
1361
|
+
return <div>{props.title}</div>
|
|
1362
|
+
}
|
|
1363
|
+
`
|
|
1364
|
+
const summary = buildComponentSummary(source, 'Card.tsx')
|
|
1365
|
+
expect(summary.hydrated).toBe(false)
|
|
1366
|
+
expect(summary.clientBundle).toBeNull()
|
|
1367
|
+
expect(summary.signals).toBe(0)
|
|
1368
|
+
})
|
|
1369
|
+
|
|
1370
|
+
test('counts loops', () => {
|
|
1371
|
+
const summary = buildComponentSummary(todoSource, 'TodoList.tsx')
|
|
1372
|
+
expect(summary.loops).toBeGreaterThanOrEqual(1)
|
|
1373
|
+
})
|
|
1374
|
+
|
|
1375
|
+
test('counts fallbacks', () => {
|
|
1376
|
+
const source = `
|
|
1377
|
+
'use client'
|
|
1378
|
+
import { createSignal } from '@barefootjs/client'
|
|
1379
|
+
import { formatTitle } from './format'
|
|
1380
|
+
|
|
1381
|
+
export function Page() {
|
|
1382
|
+
const [, setFoo] = createSignal(0)
|
|
1383
|
+
return <h1 onClick={() => setFoo(1)}>{formatTitle('x')}</h1>
|
|
1384
|
+
}
|
|
1385
|
+
`
|
|
1386
|
+
const summary = buildComponentSummary(source, 'Page.tsx')
|
|
1387
|
+
expect(summary.fallbacks).toBeGreaterThanOrEqual(1)
|
|
1388
|
+
})
|
|
1389
|
+
})
|
|
1390
|
+
|
|
1391
|
+
describe('formatComponentSummary', () => {
|
|
1392
|
+
test('produces readable output', () => {
|
|
1393
|
+
const summary = buildComponentSummary(dashboardSource, 'Dashboard.tsx')
|
|
1394
|
+
const output = formatComponentSummary(summary)
|
|
1395
|
+
expect(output).toContain('Dashboard')
|
|
1396
|
+
expect(output).toContain('hydrated: yes')
|
|
1397
|
+
expect(output).toContain('signals: 1')
|
|
1398
|
+
expect(output).toContain('memos: 1')
|
|
1399
|
+
expect(output).toContain('event handlers:')
|
|
1400
|
+
expect(output).toContain('dynamic text bindings:')
|
|
1401
|
+
})
|
|
1402
|
+
})
|