@barefootjs/jsx 0.31.5 → 0.31.7
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/compiler.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +104 -9
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/client-only-elision.d.ts +11 -5
- package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/rich-type-evidence.d.ts +86 -0
- package/dist/rich-type-evidence.d.ts.map +1 -1
- package/dist/rich-type-refusal.d.ts +59 -11
- package/dist/rich-type-refusal.d.ts.map +1 -1
- package/dist/types.d.ts +71 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +117 -17
- package/src/__tests__/client-only-date-lowering.test.ts +182 -0
- package/src/__tests__/doc-examples.test.ts +1 -0
- package/src/__tests__/prop-references.test.ts +72 -0
- package/src/__tests__/rich-type-method-refusal.test.ts +79 -2
- package/src/__tests__/rich-type-prop-serialization.test.ts +238 -0
- package/src/compiler.ts +3 -1
- package/src/errors.ts +13 -0
- package/src/index.ts +6 -0
- package/src/ir-to-client-js/build-references.ts +17 -0
- package/src/ir-to-client-js/client-only-elision.ts +11 -5
- package/src/ir-to-client-js/emit-reactive.ts +29 -8
- package/src/ir-to-client-js/html-template.ts +64 -0
- package/src/rich-type-evidence.ts +104 -0
- package/src/rich-type-refusal.ts +177 -23
- package/src/types.ts +77 -0
|
@@ -311,6 +311,78 @@ describe('ClientJS generation with semantic prop refs', () => {
|
|
|
311
311
|
})
|
|
312
312
|
})
|
|
313
313
|
|
|
314
|
+
// Issue #2634 regression test: a prop used ONLY inside a `/* @client */`
|
|
315
|
+
// expression was never extracted from `_p` — the identifier reached
|
|
316
|
+
// `createEffect`'s body unbound, throwing `ReferenceError` at hydrate.
|
|
317
|
+
// `buildReferencesGraph` (build-references.ts) deliberately skips adding a
|
|
318
|
+
// `template-closure` edge for a clientOnly expression (it isn't template-
|
|
319
|
+
// reachable), but was never wired to `ctx.clientOnlyElements` either, so no
|
|
320
|
+
// edge of ANY kind reached `neededProps` and `emitPropsExtraction` had no
|
|
321
|
+
// evidence the prop was used at all.
|
|
322
|
+
describe('Issue #2634 regression', () => {
|
|
323
|
+
test('a destructured prop used only inside /* @client */ is extracted from _p', () => {
|
|
324
|
+
const source = `
|
|
325
|
+
interface Props {
|
|
326
|
+
label: string
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function LabelClientOnly({ label }: Props) {
|
|
330
|
+
return <div>{/* @client */ label.toUpperCase()}</div>
|
|
331
|
+
}
|
|
332
|
+
`
|
|
333
|
+
|
|
334
|
+
const result = compileJSX(source, 'LabelClientOnly.tsx', { adapter })
|
|
335
|
+
|
|
336
|
+
expect(result.errors).toHaveLength(0)
|
|
337
|
+
const clientJs = result.files.find((f) => f.type === 'clientJs')
|
|
338
|
+
expect(clientJs).toBeDefined()
|
|
339
|
+
// The prop must be bound before the createEffect body reads it.
|
|
340
|
+
expect(clientJs?.content).toContain('const label = _p.label')
|
|
341
|
+
// A bare `label` reference with no preceding binding is exactly the
|
|
342
|
+
// unbound-identifier shape that threw ReferenceError at hydrate.
|
|
343
|
+
expect(clientJs?.content).toMatch(/const label = _p\.label[\s\S]*label\.toUpperCase\(\)/)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test('a props-object member used only inside /* @client */ needs no extraction (already _p.x)', () => {
|
|
347
|
+
const source = `
|
|
348
|
+
interface Props {
|
|
349
|
+
label: string
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function LabelClientOnly(props: Props) {
|
|
353
|
+
return <div>{/* @client */ props.label.toUpperCase()}</div>
|
|
354
|
+
}
|
|
355
|
+
`
|
|
356
|
+
|
|
357
|
+
const result = compileJSX(source, 'LabelClientOnly.tsx', { adapter })
|
|
358
|
+
|
|
359
|
+
expect(result.errors).toHaveLength(0)
|
|
360
|
+
const clientJs = result.files.find((f) => f.type === 'clientJs')
|
|
361
|
+
expect(clientJs).toBeDefined()
|
|
362
|
+
expect(clientJs?.content).toContain('_p.label.toUpperCase()')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('a prop used both in a normal reactive position AND inside /* @client */ is still extracted once', () => {
|
|
366
|
+
const source = `
|
|
367
|
+
interface Props {
|
|
368
|
+
label: string
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function LabelBoth({ label }: Props) {
|
|
372
|
+
return <div><span>{label}</span><span>{/* @client */ label.toUpperCase()}</span></div>
|
|
373
|
+
}
|
|
374
|
+
`
|
|
375
|
+
|
|
376
|
+
const result = compileJSX(source, 'LabelBoth.tsx', { adapter })
|
|
377
|
+
|
|
378
|
+
expect(result.errors).toHaveLength(0)
|
|
379
|
+
const clientJs = result.files.find((f) => f.type === 'clientJs')
|
|
380
|
+
expect(clientJs).toBeDefined()
|
|
381
|
+
const matches = clientJs?.content.match(/const label = _p\.label/g) ?? []
|
|
382
|
+
expect(matches).toHaveLength(1)
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
314
386
|
// Issue #257 regression test: Double props prefix in template literals
|
|
315
387
|
describe('Issue #257 regression', () => {
|
|
316
388
|
test('does NOT double-wrap props.xxx when already prefixed', () => {
|
|
@@ -173,14 +173,78 @@ describe('rich-type method-call refusal — fires (BF021)', () => {
|
|
|
173
173
|
expect(errors[0].message).toContain("'Map'")
|
|
174
174
|
})
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
// #2636: bare /* @client */ crashes at hydrate for every rich-type
|
|
177
|
+
// receiver here (the prop arrives de-riched over the JSON bf-p boundary),
|
|
178
|
+
// so the suggestion must never recommend it bare. Date/URL (JSON-revivable
|
|
179
|
+
// via their own one-arg constructor) get an explicit-revival @client form
|
|
180
|
+
// instead; every other host rich type gets pre-compute only.
|
|
181
|
+
test('Date generic method: pre-compute lead + explicit-revival @client escape, never a bare @client', () => {
|
|
177
182
|
const errors = bf021(`
|
|
178
183
|
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
179
184
|
return <div>{createdAt.toISOString()}</div>
|
|
180
185
|
}
|
|
181
186
|
`)
|
|
182
187
|
expect(errors[0].severity).toBe('error')
|
|
183
|
-
|
|
188
|
+
const message = errors[0].suggestion?.message ?? ''
|
|
189
|
+
expect(message).toContain('Pre-compute')
|
|
190
|
+
expect(message).toContain('new Date(createdAt)')
|
|
191
|
+
expect(message).not.toMatch(/Add \/\* @client \*\//)
|
|
192
|
+
expect(errors[0].suggestion?.escape).toEqual([{ kind: 'prop-precompute' }, { kind: 'client-directive' }])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('Map method: pre-compute only, explicit no-safe-@client-escape warning', () => {
|
|
196
|
+
const errors = bf021(`
|
|
197
|
+
export function Foo({ m }: { m: Map<string, string> }) {
|
|
198
|
+
return <div>{m.get('x')}</div>
|
|
199
|
+
}
|
|
200
|
+
`)
|
|
201
|
+
const message = errors[0].suggestion?.message ?? ''
|
|
202
|
+
expect(message).toContain('Pre-compute')
|
|
203
|
+
expect(message).toContain('NOT a safe escape')
|
|
204
|
+
expect(message).not.toContain('new Map(')
|
|
205
|
+
expect(errors[0].suggestion?.escape).toEqual([{ kind: 'prop-precompute' }])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Zero-arg toLocaleDateString() is BF021's one case where the refusal is
|
|
209
|
+
// deliberately ADAPTER-UNIFORM rather than a DSL-adapter-only gap (unlike
|
|
210
|
+
// BF101's "JS-runtime executes verbatim" carve-out): `TestAdapter extends
|
|
211
|
+
// JsxAdapter` (adapter-hono's own base class) here, and it still refuses.
|
|
212
|
+
// See #2356's decision comment and `date-method-uncatalogued.ts`'s
|
|
213
|
+
// docstring — a JS-runtime adapter's hydrate leg re-evaluates a
|
|
214
|
+
// prop-derived expression against a JSON-de-riched receiver (a `Date`
|
|
215
|
+
// prop arrives as its ISO string), so "the SSR runtime can evaluate the
|
|
216
|
+
// call" does not make the compiled artifact sound; the compiler doesn't
|
|
217
|
+
// special-case this adapter class here on purpose.
|
|
218
|
+
test('zero-arg toLocaleDateString() refuses even on a JsxAdapter (#2356 — adapter-uniform by design)', () => {
|
|
219
|
+
const errors = bf021(`
|
|
220
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
221
|
+
return <div>{createdAt.toLocaleDateString()}</div>
|
|
222
|
+
}
|
|
223
|
+
`)
|
|
224
|
+
expect(errors).toHaveLength(1)
|
|
225
|
+
expect(errors[0].message).toContain("'.toLocaleDateString()'")
|
|
226
|
+
expect(errors[0].message).toContain("'createdAt'")
|
|
227
|
+
// The suggestion for this specific method+type points at the
|
|
228
|
+
// literal-locale/timeZone escape (#2324) ahead of the generic
|
|
229
|
+
// pre-compute-or-revival fallback every other BF021 carries.
|
|
230
|
+
const message = errors[0].suggestion?.message ?? ''
|
|
231
|
+
expect(message).toContain('literal locale')
|
|
232
|
+
// #2636: even this branch's @client escape must be the explicit-revival
|
|
233
|
+
// form, never a bare /* @client */.
|
|
234
|
+
expect(message).toContain('new Date(createdAt)')
|
|
235
|
+
expect(errors[0].suggestion?.escape).toEqual([{ kind: 'rewrite' }, { kind: 'prop-precompute' }, { kind: 'client-directive' }])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('URL generic method: revivable tier applies past Date (#2636)', () => {
|
|
239
|
+
const errors = bf021(`
|
|
240
|
+
export function Foo({ href }: { href: URL }) {
|
|
241
|
+
return <div>{href.toString()}</div>
|
|
242
|
+
}
|
|
243
|
+
`)
|
|
244
|
+
expect(errors).toHaveLength(1)
|
|
245
|
+
const message = errors[0].suggestion?.message ?? ''
|
|
246
|
+
expect(message).toContain('new URL(href)')
|
|
247
|
+
expect(errors[0].suggestion?.escape).toEqual([{ kind: 'prop-precompute' }, { kind: 'client-directive' }])
|
|
184
248
|
})
|
|
185
249
|
})
|
|
186
250
|
|
|
@@ -194,6 +258,19 @@ describe('rich-type method-call refusal — silent (no BF021)', () => {
|
|
|
194
258
|
expect(errors).toHaveLength(0)
|
|
195
259
|
})
|
|
196
260
|
|
|
261
|
+
// Pins the premise the revivable-tier suggestion (#2636) depends on: a
|
|
262
|
+
// /* @client */ block that wraps the receiver in `new Date(...)` first is
|
|
263
|
+
// itself a call-result receiver (resolveReceiverType → null), so it never
|
|
264
|
+
// fires BF021 — the same reason a bare @client call never fires above.
|
|
265
|
+
test('/* @client */ with explicit new Date(...) revival wrapper', () => {
|
|
266
|
+
const errors = bf021(`
|
|
267
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
268
|
+
return <div>{/* @client */ new Date(createdAt).getUTCFullYear()}</div>
|
|
269
|
+
}
|
|
270
|
+
`)
|
|
271
|
+
expect(errors).toHaveLength(0)
|
|
272
|
+
})
|
|
273
|
+
|
|
197
274
|
test('/* @client */-wrapped conditional branch', () => {
|
|
198
275
|
const errors = bf021(`
|
|
199
276
|
export function Foo({ d }: { d: Date | null }) {
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich-type prop serialization refusal (BF049, #2643).
|
|
3
|
+
*
|
|
4
|
+
* Sibling of BF021 (`rich-type-method-refusal.test.ts`) for a DIFFERENT
|
|
5
|
+
* failure shape: whether or not a method is called is irrelevant here. A
|
|
6
|
+
* rich-typed prop (`Map`, `Set`, `BigInt`, …) used anywhere in this
|
|
7
|
+
* component's own client code (a handler, an effect) — merely read, or even
|
|
8
|
+
* method-called (`data.get(...)`, `tags.has(...)`, below) — is invisible to
|
|
9
|
+
* BF021 either way, since BF021 only walks template-lowered expression
|
|
10
|
+
* positions and never analyzes a handler/effect body. But the prop still
|
|
11
|
+
* crosses the `bf-p` hydration boundary as JSON, where it either arrives
|
|
12
|
+
* de-riched (`Map`/`Set` → `{}`, entries silently dropped) or fails to
|
|
13
|
+
* serialize at all (`BigInt` throws at SSR render).
|
|
14
|
+
*
|
|
15
|
+
* `checkRichTypePropSerialization` is metadata-driven (mirrors the adapter's
|
|
16
|
+
* own `propsToSerialize` filter), not a lowering-plugin-aware IR walk, so
|
|
17
|
+
* this suite doesn't need the plugin-registry snapshot dance
|
|
18
|
+
* `rich-type-method-refusal.test.ts` uses — no lowering matcher can exempt a
|
|
19
|
+
* prop from serialization the way one exempts a method call from BF021.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, test, expect } from 'bun:test'
|
|
23
|
+
import { compileJSX } from '../compiler'
|
|
24
|
+
import { ErrorCodes } from '../errors'
|
|
25
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
26
|
+
|
|
27
|
+
const adapter = new TestAdapter()
|
|
28
|
+
|
|
29
|
+
function bf049(source: string, filePath = 'Test.tsx') {
|
|
30
|
+
const result = compileJSX(source, filePath, { adapter })
|
|
31
|
+
return result.errors.filter((e) => e.code === ErrorCodes.RICH_TYPE_PROP_NOT_HYDRATABLE)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('rich-type prop serialization refusal — fires (BF049)', () => {
|
|
35
|
+
test('Map prop read in a client event handler (destructured)', () => {
|
|
36
|
+
const errors = bf049(`
|
|
37
|
+
'use client'
|
|
38
|
+
export function Foo({ data }: { data: Map<string, number> }) {
|
|
39
|
+
return <button onClick={() => console.log(data.get('x'))}>go</button>
|
|
40
|
+
}
|
|
41
|
+
`)
|
|
42
|
+
expect(errors).toHaveLength(1)
|
|
43
|
+
expect(errors[0].severity).toBe('error')
|
|
44
|
+
expect(errors[0].message).toContain("Prop 'data'")
|
|
45
|
+
expect(errors[0].message).toContain("'Map<string, number>'")
|
|
46
|
+
expect(errors[0].message).toContain('Map cannot')
|
|
47
|
+
expect(errors[0].suggestion?.escape).toEqual([{ kind: 'prop-precompute' }])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('Set prop read in a client event handler', () => {
|
|
51
|
+
const errors = bf049(`
|
|
52
|
+
'use client'
|
|
53
|
+
export function Foo({ tags }: { tags: Set<string> }) {
|
|
54
|
+
return <button onClick={() => console.log(tags.has('x'))}>go</button>
|
|
55
|
+
}
|
|
56
|
+
`)
|
|
57
|
+
expect(errors).toHaveLength(1)
|
|
58
|
+
expect(errors[0].message).toContain("Prop 'tags'")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('object-form BigInt prop throws at SSR — message names the consequence', () => {
|
|
62
|
+
const errors = bf049(`
|
|
63
|
+
'use client'
|
|
64
|
+
export function Foo({ n }: { n: BigInt }) {
|
|
65
|
+
return <button onClick={() => console.log(n)}>go</button>
|
|
66
|
+
}
|
|
67
|
+
`)
|
|
68
|
+
expect(errors).toHaveLength(1)
|
|
69
|
+
expect(errors[0].message).toContain('JSON.stringify throws at SSR render')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('bigint KEYWORD prop also fires — closes the keyword-spelling gap BF021 leaves open', () => {
|
|
73
|
+
const errors = bf049(`
|
|
74
|
+
'use client'
|
|
75
|
+
export function Foo({ n }: { n: bigint }) {
|
|
76
|
+
return <button onClick={() => console.log(n)}>go</button>
|
|
77
|
+
}
|
|
78
|
+
`)
|
|
79
|
+
expect(errors).toHaveLength(1)
|
|
80
|
+
expect(errors[0].message).toContain("'bigint'")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('symbol keyword prop fires', () => {
|
|
84
|
+
const errors = bf049(`
|
|
85
|
+
'use client'
|
|
86
|
+
export function Foo({ s }: { s: symbol }) {
|
|
87
|
+
return <button onClick={() => console.log(s)}>go</button>
|
|
88
|
+
}
|
|
89
|
+
`)
|
|
90
|
+
expect(errors).toHaveLength(1)
|
|
91
|
+
expect(errors[0].message).toContain('drops the value entirely')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('props-object mode (props.data)', () => {
|
|
95
|
+
const errors = bf049(`
|
|
96
|
+
'use client'
|
|
97
|
+
export function Foo(props: { data: Map<string, number> }) {
|
|
98
|
+
return <button onClick={() => console.log(props.data)}>go</button>
|
|
99
|
+
}
|
|
100
|
+
`)
|
|
101
|
+
expect(errors).toHaveLength(1)
|
|
102
|
+
expect(errors[0].message).toContain("Prop 'data'")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('renamed destructured prop ({ data: d }) — bf-p key is the source name', () => {
|
|
106
|
+
const errors = bf049(`
|
|
107
|
+
'use client'
|
|
108
|
+
export function Foo({ data: d }: { data: Map<string, number> }) {
|
|
109
|
+
return <button onClick={() => console.log(d)}>go</button>
|
|
110
|
+
}
|
|
111
|
+
`)
|
|
112
|
+
expect(errors).toHaveLength(1)
|
|
113
|
+
expect(errors[0].message).toContain("Prop 'd'")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('optional Map prop (Map<...> | undefined) still resolves via stripUnion', () => {
|
|
117
|
+
const errors = bf049(`
|
|
118
|
+
'use client'
|
|
119
|
+
export function Foo({ data }: { data?: Map<string, number> }) {
|
|
120
|
+
return <button onClick={() => console.log(data)}>go</button>
|
|
121
|
+
}
|
|
122
|
+
`)
|
|
123
|
+
expect(errors).toHaveLength(1)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('RegExp prop client-read fires (loses data on JSON round-trip, not merely "degrades to something")', () => {
|
|
127
|
+
const errors = bf049(`
|
|
128
|
+
'use client'
|
|
129
|
+
export function Foo({ pattern }: { pattern: RegExp }) {
|
|
130
|
+
return <button onClick={() => console.log(pattern.test('x'))}>go</button>
|
|
131
|
+
}
|
|
132
|
+
`)
|
|
133
|
+
expect(errors).toHaveLength(1)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('Function-typed (object-form) prop client-read fires', () => {
|
|
137
|
+
const errors = bf049(`
|
|
138
|
+
'use client'
|
|
139
|
+
export function Foo({ cb }: { cb: Function }) {
|
|
140
|
+
return <button onClick={() => cb()}>go</button>
|
|
141
|
+
}
|
|
142
|
+
`)
|
|
143
|
+
expect(errors).toHaveLength(1)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('rich-type prop serialization refusal — silent (no BF049)', () => {
|
|
148
|
+
test('Map prop never read by client code (server-only component)', () => {
|
|
149
|
+
const errors = bf049(`
|
|
150
|
+
export function Foo({ data }: { data: Map<string, number> }) {
|
|
151
|
+
return <div>hi</div>
|
|
152
|
+
}
|
|
153
|
+
`)
|
|
154
|
+
expect(errors).toHaveLength(0)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('Map prop declared but unused by the client init', () => {
|
|
158
|
+
const errors = bf049(`
|
|
159
|
+
'use client'
|
|
160
|
+
export function Foo({ data, label }: { data: Map<string, number>; label: string }) {
|
|
161
|
+
return <button onClick={() => console.log(label)}>go</button>
|
|
162
|
+
}
|
|
163
|
+
`)
|
|
164
|
+
expect(errors).toHaveLength(0)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('Date prop client-read is silent — Date is JSON-revivable, not JSON-unsafe', () => {
|
|
168
|
+
const errors = bf049(`
|
|
169
|
+
'use client'
|
|
170
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
171
|
+
return <button onClick={() => console.log(createdAt)}>go</button>
|
|
172
|
+
}
|
|
173
|
+
`)
|
|
174
|
+
expect(errors).toHaveLength(0)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('URL prop client-read is silent — same revivable-subset exemption', () => {
|
|
178
|
+
const errors = bf049(`
|
|
179
|
+
'use client'
|
|
180
|
+
export function Foo({ href }: { href: URL }) {
|
|
181
|
+
return <button onClick={() => console.log(href)}>go</button>
|
|
182
|
+
}
|
|
183
|
+
`)
|
|
184
|
+
expect(errors).toHaveLength(0)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('two-arm union (RegExp | string) is silent — the InputOTP pattern-prop shape', () => {
|
|
188
|
+
const errors = bf049(`
|
|
189
|
+
'use client'
|
|
190
|
+
export function Foo({ pattern }: { pattern: RegExp | string }) {
|
|
191
|
+
return <button onClick={() => console.log(pattern)}>go</button>
|
|
192
|
+
}
|
|
193
|
+
`)
|
|
194
|
+
expect(errors).toHaveLength(0)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('in-file interface Map shadow', () => {
|
|
198
|
+
const errors = bf049(`
|
|
199
|
+
'use client'
|
|
200
|
+
interface Map { iso: string }
|
|
201
|
+
export function Foo({ data }: { data: Map }) {
|
|
202
|
+
return <button onClick={() => console.log(data)}>go</button>
|
|
203
|
+
}
|
|
204
|
+
`)
|
|
205
|
+
expect(errors).toHaveLength(0)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('on-prefixed prop (a function prop) is silent — excluded from serialization entirely', () => {
|
|
209
|
+
const errors = bf049(`
|
|
210
|
+
'use client'
|
|
211
|
+
export function Foo({ onGo }: { onGo: () => void }) {
|
|
212
|
+
return <button onClick={onGo}>go</button>
|
|
213
|
+
}
|
|
214
|
+
`)
|
|
215
|
+
expect(errors).toHaveLength(0)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('plain string/number/array props are silent', () => {
|
|
219
|
+
const errors = bf049(`
|
|
220
|
+
'use client'
|
|
221
|
+
export function Foo({ label, count, items }: { label: string; count: number; items: string[] }) {
|
|
222
|
+
return <button onClick={() => console.log(label, count, items)}>go</button>
|
|
223
|
+
}
|
|
224
|
+
`)
|
|
225
|
+
expect(errors).toHaveLength(0)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('a local type alias of a rich type is a conservative miss (documented, covered by the runtime backstop)', () => {
|
|
229
|
+
const errors = bf049(`
|
|
230
|
+
'use client'
|
|
231
|
+
type RichMap = Map<string, number>
|
|
232
|
+
export function Foo({ data }: { data: RichMap }) {
|
|
233
|
+
return <button onClick={() => console.log(data)}>go</button>
|
|
234
|
+
}
|
|
235
|
+
`)
|
|
236
|
+
expect(errors).toHaveLength(0)
|
|
237
|
+
})
|
|
238
|
+
})
|
package/src/compiler.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { applyCssLayerPrefix, applyCssLayerPrefixToFile } from './css-layer-pref
|
|
|
26
26
|
import { preprocessInlineJsxCallbacks } from './preprocess-inline-jsx-callbacks.ts'
|
|
27
27
|
import { extractSsrDefaults } from './ssr-defaults.ts'
|
|
28
28
|
import { computeSsrSeedPlan } from './ssr-seed-plan.ts'
|
|
29
|
-
import { checkRichTypeMethodCalls } from './rich-type-refusal.ts'
|
|
29
|
+
import { checkRichTypeMethodCalls, checkRichTypePropSerialization } from './rich-type-refusal.ts'
|
|
30
30
|
import { ErrorCodes, createError } from './errors.ts'
|
|
31
31
|
import { collectComponentNamesFromIR } from './ir-to-client-js/child-components.ts'
|
|
32
32
|
|
|
@@ -160,6 +160,7 @@ function compileMultipleComponents(
|
|
|
160
160
|
|
|
161
161
|
componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
|
|
162
162
|
checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
|
|
163
|
+
checkRichTypePropSerialization(componentIR.root, componentIR.metadata, errors, ctx.propsDestructuring?.loc)
|
|
163
164
|
|
|
164
165
|
// Slot unification Step B — see the single-component path's identical
|
|
165
166
|
// call for why this must run before adapter.generate/generateClientJs.
|
|
@@ -777,6 +778,7 @@ export function compileJSX(
|
|
|
777
778
|
// Pre-compute client JS analysis for adapter optimization
|
|
778
779
|
componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
|
|
779
780
|
checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
|
|
781
|
+
checkRichTypePropSerialization(componentIR.root, componentIR.metadata, errors, ctx.propsDestructuring?.loc)
|
|
780
782
|
|
|
781
783
|
// Slot unification Step B (`spec/slot-unification.md` §5 Step B): decide
|
|
782
784
|
// marker elision ONCE, mutating `componentIR.root` in place, before either
|
package/src/errors.ts
CHANGED
|
@@ -53,6 +53,16 @@ export const ErrorCodes = {
|
|
|
53
53
|
// helper verbatim instead of compiling it as a component — so this code
|
|
54
54
|
// fires only for the client-component compilation path.
|
|
55
55
|
SIBLING_COMPONENT_NOT_COMPILED: 'BF048',
|
|
56
|
+
// A prop typed as a host rich type whose `JSON.stringify` output is not
|
|
57
|
+
// revivable (`Map`, `Set`, `BigInt`, …) is used by this component's own
|
|
58
|
+
// client code (a handler, an effect) — regardless of whether a method is
|
|
59
|
+
// called on it, since `checkRichTypeMethodCalls`'s BF021 only walks
|
|
60
|
+
// template-lowered expression positions and never sees a handler/effect
|
|
61
|
+
// body either way. The prop still crosses the `bf-p` hydration boundary as
|
|
62
|
+
// JSON and arrives de-riched (or, for `BigInt`, fails to serialize at all,
|
|
63
|
+
// throwing at SSR render). Sibling of BF021 for the "client-side use"
|
|
64
|
+
// shape, which BF021's template-only walk can never reach (#2643).
|
|
65
|
+
RICH_TYPE_PROP_NOT_HYDRATABLE: 'BF049',
|
|
56
66
|
|
|
57
67
|
// Import errors (BF050-BF059)
|
|
58
68
|
SHARED_PROGRAM_REQUIRED: 'BF050',
|
|
@@ -164,6 +174,9 @@ const errorMessages: Record<ErrorCode, string> = {
|
|
|
164
174
|
"verbatim, see #932), or rewrite it as a single-return ternary/conditional chain so the " +
|
|
165
175
|
"component pipeline can compile it.",
|
|
166
176
|
|
|
177
|
+
[ErrorCodes.RICH_TYPE_PROP_NOT_HYDRATABLE]:
|
|
178
|
+
'Rich-typed prop cannot cross the bf-p hydration boundary as JSON.',
|
|
179
|
+
|
|
167
180
|
[ErrorCodes.SHARED_PROGRAM_REQUIRED]:
|
|
168
181
|
'Shared ts.Program required for type-based reactivity classification. This source imports a Reactive<T>-branded library (e.g. @barefootjs/form) whose getters cannot be classified by regex alone. Pass `options.program` (built via `createProgramForCorpus`) so the analyzer can resolve the brand through the TypeChecker.',
|
|
169
182
|
|
package/src/index.ts
CHANGED
|
@@ -57,6 +57,9 @@ export type {
|
|
|
57
57
|
TypeDefinition,
|
|
58
58
|
SourceLocation,
|
|
59
59
|
CompilerError,
|
|
60
|
+
ErrorSuggestion,
|
|
61
|
+
EscapeKind,
|
|
62
|
+
EscapeSsrCost,
|
|
60
63
|
ConformancePin,
|
|
61
64
|
ConformancePins,
|
|
62
65
|
RenderDivergences,
|
|
@@ -186,6 +189,9 @@ export interface BarefootPaths {
|
|
|
186
189
|
// AttrValue constructors
|
|
187
190
|
export { AttrValueOf } from './types.ts'
|
|
188
191
|
|
|
192
|
+
// Per-escape-kind SSR cost — the one place every renderer reads the trade from (#2613)
|
|
193
|
+
export { ESCAPE_SSR_COST } from './types.ts'
|
|
194
|
+
|
|
189
195
|
// CSS Layer Prefixer
|
|
190
196
|
export { applyCssLayerPrefix } from './css-layer-prefixer.ts'
|
|
191
197
|
|
|
@@ -129,6 +129,23 @@ export function buildReferencesGraph(ctx: ClientJsContext, irRoot: IRNode): Refe
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// No identifiers.ts precedent — `clientOnlyElements` (Slot unification
|
|
133
|
+
// Step B) postdates the file this phase mirrors. A `/* @client */`
|
|
134
|
+
// expression's Phase 3 IR-walk visitor below deliberately adds no edge
|
|
135
|
+
// at all (never mind which context) so a referenced name isn't
|
|
136
|
+
// misclassified as template-reachable; without a substitute edge here,
|
|
137
|
+
// a prop used ONLY inside such an expression never reaches
|
|
138
|
+
// `neededProps` (`analyzeClientNeeds`/`init-declarations.ts`), so
|
|
139
|
+
// `emitPropsExtraction` never binds it and the emitted `createEffect`
|
|
140
|
+
// reads an unbound identifier — a real `ReferenceError` at hydrate,
|
|
141
|
+
// not a hypothetical (#2634). `'init-body'` is the same context an
|
|
142
|
+
// event handler's identifiers use (few lines up) — correct here for
|
|
143
|
+
// the same reason: the expression runs in `initX`'s scope, never the
|
|
144
|
+
// template closure.
|
|
145
|
+
for (const elem of ctx.clientOnlyElements) {
|
|
146
|
+
addExprEdges(ROOT_SOURCE, elem.expression, 'init-body')
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
// identifiers.ts L107-135
|
|
133
150
|
for (const elem of ctx.loopElements) {
|
|
134
151
|
addExprEdges(ROOT_SOURCE, elem.array, 'template-closure')
|
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
* marker elision"): decide, EXACTLY ONCE and BEFORE either `adapter.generate`
|
|
4
4
|
* (SSR) or `generateClientJs` (CSR) run, which `/* @client *\/` text slots
|
|
5
5
|
* can drop their `<!--bf:sN-->…<!--/-->` marker pair entirely from both
|
|
6
|
-
* outputs. Every consumer
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* outputs. Every consumer reads the single `IRExpression.markerless` flag
|
|
7
|
+
* this pass writes; nobody re-derives the decision, per CLAUDE.md's "Never
|
|
8
|
+
* add compiler options/hooks for tool-specific output rewriting" spirit:
|
|
9
|
+
* one door in, everyone reads it.
|
|
10
|
+
*
|
|
11
|
+
* The consumers are all nine SSR adapters' `renderExpression`, plus BOTH
|
|
12
|
+
* top-level CSR emitters in `html-template.ts` — `irToHtmlTemplate`'s
|
|
13
|
+
* `case 'expression'` and `generateCsrTemplateWithOpts`'s own. The second
|
|
14
|
+
* of those was wired up only by #2617; the flag shipped with just the
|
|
15
|
+
* first, which is exactly how the divergence that issue fixed went
|
|
16
|
+
* unnoticed.
|
|
11
17
|
*
|
|
12
18
|
* Scope — deliberately the NARROWEST slice of §3(b)'s elision rule that is
|
|
13
19
|
* fully sound today, not the general case:
|
|
@@ -282,10 +282,28 @@ function lowerDateCallsInReactiveExpr(expr: string, matcher: LoweringMatcher | n
|
|
|
282
282
|
return restore(result)
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Bind both catalogued-lowering rewrites (#2292 Date accessors, #2324
|
|
287
|
+
* literal-locale `toLocaleDateString`) once per emit pass, composed into a
|
|
288
|
+
* single `(expr) => expr` function — identity when this context carries no
|
|
289
|
+
* Date evidence (`ctx.propsType` unset). Shared by every reactive-emission
|
|
290
|
+
* site so a catalogued method call re-evaluated at hydrate routes through
|
|
291
|
+
* the same runtime helper the static template lowering uses, instead of
|
|
292
|
+
* splicing the raw call verbatim against a JSON-de-riched receiver
|
|
293
|
+
* (#2640/#2641 — the `/* @client *\/`-expression and reactive-attribute
|
|
294
|
+
* sites used to skip this; `emitDynamicTextUpdates` below is the original,
|
|
295
|
+
* always-correct site this generalizes).
|
|
296
|
+
*/
|
|
297
|
+
function makeCataloguedCallLowerer(ctx: ClientJsContext): (expr: string) => string {
|
|
298
|
+
const dateMatcher = getReactiveDateLoweringMatcher(ctx)
|
|
299
|
+
const toLocaleMatcher = getReactiveToLocaleMatcher(ctx)
|
|
300
|
+
if (!dateMatcher && !toLocaleMatcher) return (expr) => expr
|
|
301
|
+
return (expr) => lowerToLocaleCallsInReactiveExpr(lowerDateCallsInReactiveExpr(expr, dateMatcher), toLocaleMatcher)
|
|
302
|
+
}
|
|
303
|
+
|
|
285
304
|
/** Emit createEffect blocks that update text nodes for reactive expressions. */
|
|
286
305
|
export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): void {
|
|
287
|
-
const
|
|
288
|
-
const toLocaleMatcher = getReactiveToLocaleMatcher(ctx)
|
|
306
|
+
const lower = makeCataloguedCallLowerer(ctx)
|
|
289
307
|
// Group elements by expression to consolidate effects with same dependencies
|
|
290
308
|
const byExpression = new Map<string, typeof ctx.dynamicElements>()
|
|
291
309
|
for (const elem of ctx.dynamicElements) {
|
|
@@ -297,10 +315,7 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
|
|
|
297
315
|
}
|
|
298
316
|
|
|
299
317
|
for (const [rawExpr, elems] of byExpression) {
|
|
300
|
-
const expr =
|
|
301
|
-
lowerDateCallsInReactiveExpr(rawExpr, dateLoweringMatcher),
|
|
302
|
-
toLocaleMatcher,
|
|
303
|
-
)
|
|
318
|
+
const expr = lower(rawExpr)
|
|
304
319
|
// Separate conditional vs non-conditional elements
|
|
305
320
|
const conditionalElems = elems.filter(e => e.insideConditional)
|
|
306
321
|
const normalElems = elems.filter(e => !e.insideConditional)
|
|
@@ -380,6 +395,7 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
|
|
|
380
395
|
* already known, so there's nothing to disambiguate from other content).
|
|
381
396
|
*/
|
|
382
397
|
export function emitClientOnlyExpressions(lines: string[], ctx: ClientJsContext): void {
|
|
398
|
+
const lower = makeCataloguedCallLowerer(ctx)
|
|
383
399
|
for (const elem of ctx.clientOnlyElements) {
|
|
384
400
|
// Slot unification Step B: `elem.elidedPath`, when present, was proven
|
|
385
401
|
// safe by `client-only-elision.ts` before this pass ran — use the real
|
|
@@ -391,7 +407,7 @@ export function emitClientOnlyExpressions(lines: string[], ctx: ClientJsContext)
|
|
|
391
407
|
lines.push(` // @client: ${elem.slotId}`)
|
|
392
408
|
lines.push(` { const ${writer} = lazySlots(__scope, ${claimPlanLiteral(slots)})`)
|
|
393
409
|
lines.push(` createEffect(() => {`)
|
|
394
|
-
lines.push(` ${writer}('${elem.slotId}', ${elem.expression})`)
|
|
410
|
+
lines.push(` ${writer}('${elem.slotId}', ${lower(elem.expression)})`)
|
|
395
411
|
lines.push(` }${bindingIdArg(ctx, elem.slotId)}) }`)
|
|
396
412
|
lines.push('')
|
|
397
413
|
}
|
|
@@ -400,6 +416,7 @@ export function emitClientOnlyExpressions(lines: string[], ctx: ClientJsContext)
|
|
|
400
416
|
/** Emit createEffect blocks that sync reactive attribute values (class, value, checked, etc.). */
|
|
401
417
|
export function emitReactiveAttributeUpdates(lines: string[], ctx: ClientJsContext): void {
|
|
402
418
|
if (ctx.reactiveAttrs.length > 0) {
|
|
419
|
+
const lower = makeCataloguedCallLowerer(ctx)
|
|
403
420
|
const attrsBySlot = new Map<string, typeof ctx.reactiveAttrs>()
|
|
404
421
|
for (const attr of ctx.reactiveAttrs) {
|
|
405
422
|
if (!attrsBySlot.has(attr.slotId)) {
|
|
@@ -413,7 +430,11 @@ export function emitReactiveAttributeUpdates(lines: string[], ctx: ClientJsConte
|
|
|
413
430
|
lines.push(` createEffect(() => {`)
|
|
414
431
|
lines.push(` if (_${v}) {`)
|
|
415
432
|
for (const attr of attrs) {
|
|
416
|
-
|
|
433
|
+
// Catalogued-lowering MUST run before the bare-prop-name rewrite:
|
|
434
|
+
// the matcher needs the source-form receiver (a bare identifier or
|
|
435
|
+
// `props.x`), the exact two shapes `resolveReceiverType` supports —
|
|
436
|
+
// same ordering `jsx-to-ir.ts`'s static-template path documents.
|
|
437
|
+
const expression = rewriteDestructuredPropsInExpr(lower(attr.expression), ctx)
|
|
417
438
|
for (const stmt of emitAttrUpdate(`_${v}`, attr.attrName, expression, attr)) {
|
|
418
439
|
lines.push(` ${stmt}`)
|
|
419
440
|
}
|