@barefootjs/jsx 0.31.6 → 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.js +96 -9
- package/dist/ir-to-client-js/build-references.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/package.json +2 -2
- package/src/__tests__/client-only-date-lowering.test.ts +182 -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/ir-to-client-js/build-references.ts +17 -0
- package/src/ir-to-client-js/emit-reactive.ts +29 -8
- package/src/ir-to-client-js/html-template.ts +22 -4
- package/src/rich-type-evidence.ts +104 -0
- package/src/rich-type-refusal.ts +177 -23
|
@@ -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
|
}
|
|
@@ -1810,6 +1810,19 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
|
|
|
1810
1810
|
|
|
1811
1811
|
case 'expression': {
|
|
1812
1812
|
if (node.expr === 'null' || node.expr === 'undefined') return ''
|
|
1813
|
+
// `/* @client */` defers the expression to hydrate — init's
|
|
1814
|
+
// clientOnlyElements effect owns the value (#2645). Mirror
|
|
1815
|
+
// `generateCsrTemplateWithOpts`'s identical branch byte-for-byte:
|
|
1816
|
+
// empty marker pair for SSR parity, or nothing at all when the
|
|
1817
|
+
// elision pass dropped the markers (`markerless` — the claim plan
|
|
1818
|
+
// resolves via `elidedPath`, slot unification Step B). Without this,
|
|
1819
|
+
// this builder inlined the (possibly lowered) expression value
|
|
1820
|
+
// directly into the static template, breaking SSR/CSR byte parity —
|
|
1821
|
+
// SSR renders the region empty, this builder rendered it populated.
|
|
1822
|
+
if (node.clientOnly && node.slotId) {
|
|
1823
|
+
if (node.markerless) return ''
|
|
1824
|
+
return `<!--bf:${node.slotId}--><!--/-->`
|
|
1825
|
+
}
|
|
1813
1826
|
const wrapped = transformExpr(node.expr, node.templateExpr)
|
|
1814
1827
|
// Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
|
|
1815
1828
|
const value = node.joinArrayChild
|
|
@@ -2404,10 +2417,15 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
2404
2417
|
// `elidedPath` (a precomputed child-index path), not a marker
|
|
2405
2418
|
// scan, so no anchor comment is needed here at all — matches SSR's
|
|
2406
2419
|
// fully-empty output for this case byte-for-byte (#2617; this is
|
|
2407
|
-
//
|
|
2408
|
-
//
|
|
2409
|
-
// `
|
|
2410
|
-
//
|
|
2420
|
+
// one of three whole-component/CSR-path emitters — alongside
|
|
2421
|
+
// `irToHtmlTemplate` (loop/conditional bodies) and
|
|
2422
|
+
// `irToComponentTemplateWithOpts` (the static whole-component
|
|
2423
|
+
// template — #2645 added its own identical branch after this
|
|
2424
|
+
// exact gap let a `/* @client */` text expression inline its
|
|
2425
|
+
// value into the static template, breaking SSR/CSR byte parity)
|
|
2426
|
+
// — that must each consult `markerless` — see those functions'
|
|
2427
|
+
// own checks at this file's `case 'expression'` for why the
|
|
2428
|
+
// three aren't collapsed into one).
|
|
2411
2429
|
if (node.markerless) return ''
|
|
2412
2430
|
return `<!--bf:${node.slotId}--><!--/-->`
|
|
2413
2431
|
}
|
|
@@ -48,6 +48,72 @@ export const HOST_RICH_TYPE_NAMES: ReadonlySet<string> = new Set([
|
|
|
48
48
|
'Function',
|
|
49
49
|
])
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The subset of `HOST_RICH_TYPE_NAMES` whose `toJSON()` output is accepted
|
|
53
|
+
* by the type's own one-argument constructor, so a value that crossed the
|
|
54
|
+
* `bf-p` hydration boundary as JSON can be revived with `new T(jsonValue)`
|
|
55
|
+
* (#2636). `Date.prototype.toJSON()` returns an ISO string `new Date()`
|
|
56
|
+
* re-parses; `URL.prototype.toJSON()` returns an `href` string `new URL()`
|
|
57
|
+
* re-parses. Every other host rich type fails this test:
|
|
58
|
+
* - `Map` / `Set` / `WeakMap` / `WeakSet` — `JSON.stringify` drops all
|
|
59
|
+
* entries, serializing to `{}`; there is no envelope to revive FROM.
|
|
60
|
+
* - `URLSearchParams` / `RegExp` / `Promise` / `Error` — likewise
|
|
61
|
+
* serialize to `{}` (or, for `Error`, an empty-looking object missing
|
|
62
|
+
* `message`/`stack` under most engines' own `toJSON`-less default).
|
|
63
|
+
* - `Symbol` / `Function` — dropped entirely by `JSON.stringify` (become
|
|
64
|
+
* `undefined` in an object, elided in an array).
|
|
65
|
+
* - `BigInt` — `JSON.stringify` throws `TypeError` before a hydrate-time
|
|
66
|
+
* revival could ever run.
|
|
67
|
+
*
|
|
68
|
+
* Used only to decide which escape suggestion `rich-type-refusal.ts`'s
|
|
69
|
+
* `pushDiagnostic` may offer: a bare `/* @client *\/` recommendation is
|
|
70
|
+
* unsound for every host rich type here (see that module's docstring), but
|
|
71
|
+
* wrapping the receiver in `new T(...)` — `{/* @client *\/ new
|
|
72
|
+
* Date(createdAt).getUTCFullYear()}` — is a genuine, hydrate-safe escape
|
|
73
|
+
* for this subset only.
|
|
74
|
+
*
|
|
75
|
+
* A GENERAL typed-prop revival mechanism across the rest of
|
|
76
|
+
* `HOST_RICH_TYPE_NAMES` — a `bf-p` wire envelope reviving `Map`/`Set`/
|
|
77
|
+
* `URLSearchParams`/`RegExp`/`BigInt` the way `Date`/`URL` revive via their
|
|
78
|
+
* own constructor — was evaluated and DEFERRED (#2642), not rejected on
|
|
79
|
+
* technical grounds. Two decisions worth recording here so a future
|
|
80
|
+
* contributor doesn't re-litigate them from scratch:
|
|
81
|
+
* - A value-shaped sentinel envelope (`{ $map: [[k,v],...] }`, detected
|
|
82
|
+
* by `parseProps`) was rejected: the type signal lives in the VALUE, so
|
|
83
|
+
* a user prop that happens to share the sentinel's shape would be
|
|
84
|
+
* silently misrevived on every adapter, not just ones that emit
|
|
85
|
+
* envelopes — the only sound fix is a user-data escaping rule
|
|
86
|
+
* implemented in all 9 adapters' serializers, which is the actual
|
|
87
|
+
* protocol cost, paid by every payload, not just rich-typed ones.
|
|
88
|
+
* - If ever built, the sanctioned shape is TYPE-DIRECTED USE-SITE
|
|
89
|
+
* REVIVAL — the generalization of `date()` (`packages/client/src/
|
|
90
|
+
* runtime/date.ts`): plain-JSON canonical wire shapes per type, with
|
|
91
|
+
* the compiler (which already resolves prop types here) emitting a
|
|
92
|
+
* revival call at each prop's client-JS extraction site, not a
|
|
93
|
+
* value-sniffing `parseProps` reviver.
|
|
94
|
+
* `WeakMap` / `WeakSet` / `Promise` / `Symbol` / `Function` are excluded
|
|
95
|
+
* from that future scope permanently, independent of mechanism — they are
|
|
96
|
+
* structurally impossible to serialize (non-enumerable by spec, not data,
|
|
97
|
+
* or identity-is-the-semantics), not merely unrevived today.
|
|
98
|
+
*/
|
|
99
|
+
export const JSON_REVIVABLE_RICH_TYPE_NAMES: ReadonlySet<string> = new Set(['Date', 'URL'])
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The complement of `JSON_REVIVABLE_RICH_TYPE_NAMES` within `HOST_RICH_TYPE_NAMES`
|
|
103
|
+
* — every host rich type whose `JSON.stringify` output is NOT revivable via its
|
|
104
|
+
* own constructor. Used by `checkRichTypePropSerialization`
|
|
105
|
+
* (`rich-type-refusal.ts`, #2643) to flag a rich-typed prop that a client
|
|
106
|
+
* reads but that will cross the `bf-p` hydration boundary de-riched or
|
|
107
|
+
* (for `BigInt`) fail to serialize at all — a distinct failure from the
|
|
108
|
+
* method-call refusal above: whether or not client code goes on to call a
|
|
109
|
+
* method on the value is irrelevant here, since the method-call refusal
|
|
110
|
+
* only walks template-lowered expression positions and never sees a
|
|
111
|
+
* handler/effect body regardless.
|
|
112
|
+
*/
|
|
113
|
+
export const JSON_UNSAFE_RICH_TYPE_NAMES: ReadonlySet<string> = new Set(
|
|
114
|
+
[...HOST_RICH_TYPE_NAMES].filter((n) => !JSON_REVIVABLE_RICH_TYPE_NAMES.has(n)),
|
|
115
|
+
)
|
|
116
|
+
|
|
51
117
|
/**
|
|
52
118
|
* Strip generic type arguments from a `TypeInfo.raw` string (`Map<string,
|
|
53
119
|
* string>` → `Map`) so a parametrized host type still matches the bare-name
|
|
@@ -115,6 +181,44 @@ function lookupProperty(objType: TypeInfo | null, propName: string, meta: Eviden
|
|
|
115
181
|
return prop ? stripUnion(prop.type) : null
|
|
116
182
|
}
|
|
117
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Resolve a prop's declared type straight off `propsType` by its SOURCE
|
|
186
|
+
* name (`lookupProperty`'s public face for `checkRichTypePropSerialization`,
|
|
187
|
+
* which has no receiver expression to walk — only a `propsParams` entry).
|
|
188
|
+
*/
|
|
189
|
+
export function resolvePropDeclaredType(propName: string, meta: EvidenceMetadata): TypeInfo | null {
|
|
190
|
+
return lookupProperty(meta.propsType, propName, meta)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The JSON-unsafe type name a declared prop type resolves to, or `null` if
|
|
195
|
+
* it isn't one. Recognizes:
|
|
196
|
+
* - an interface-kind type whose `baseTypeName` is in
|
|
197
|
+
* `JSON_UNSAFE_RICH_TYPE_NAMES` (caller must still apply the in-file
|
|
198
|
+
* `typeDefinitions` shadow guard — this function has no `meta` to check
|
|
199
|
+
* it against, mirroring `checkRichTypeMethodCalls`'s own split between
|
|
200
|
+
* type resolution and shadow-checking);
|
|
201
|
+
* - the KEYWORD spellings `bigint` / `symbol`, which `typeNodeToTypeInfo`
|
|
202
|
+
* lowers to `{ kind: 'unknown', raw: '<keyword>' }` (only the object-form
|
|
203
|
+
* `BigInt` / `Symbol` type references reach `kind: 'interface'` and match
|
|
204
|
+
* the catalogue above) — an exact-equality check on the AST-derived raw
|
|
205
|
+
* text, the same class of raw use as `baseTypeName`, not a type-syntax
|
|
206
|
+
* parse. Closes this module's own documented conservative miss, but only
|
|
207
|
+
* for THIS check — `HOST_RICH_TYPE_NAMES`/method-call refusal still miss
|
|
208
|
+
* the keyword spellings, unchanged.
|
|
209
|
+
*/
|
|
210
|
+
export function jsonUnsafeTypeName(type: TypeInfo | null): string | null {
|
|
211
|
+
if (!type) return null
|
|
212
|
+
if (type.kind === 'interface') {
|
|
213
|
+
const name = baseTypeName(type.raw)
|
|
214
|
+
return JSON_UNSAFE_RICH_TYPE_NAMES.has(name) ? name : null
|
|
215
|
+
}
|
|
216
|
+
if (type.kind === 'unknown' && (type.raw === 'bigint' || type.raw === 'symbol')) {
|
|
217
|
+
return type.raw
|
|
218
|
+
}
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
118
222
|
/**
|
|
119
223
|
* Resolve the TypeInfo of a receiver expression, using only propsType /
|
|
120
224
|
* propsParams / typeDefinitions and the caller-supplied local bindings.
|
package/src/rich-type-refusal.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rich-type
|
|
2
|
+
* Rich-type refusals: two sibling checks over a prop typed as a built-in
|
|
3
|
+
* "host rich type" (`Date`, `Map`, …), for the two distinct ways such a
|
|
4
|
+
* value breaks that have nothing to do with each other structurally.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* `checkRichTypeMethodCalls` (#2273, BF021): a METHOD CALL on the receiver
|
|
7
|
+
* has no catalogued lowering — no adapter can emit `.toISOString()` into a
|
|
8
|
+
* template. Left unchecked, such a call transliterates into the target
|
|
9
|
+
* template's own dot-call syntax and dies at request time (a Go template
|
|
10
|
+
* `.CreatedAt.ToISOString` panic, a Jinja `AttributeError`, …), once per
|
|
11
|
+
* adapter, only once someone renders the page. This module makes that gap
|
|
12
|
+
* loud at compile time instead: `checkRichTypeMethodCalls` walks every
|
|
13
|
+
* expression position the compiler already treats as template-lowered and
|
|
14
|
+
* pushes BF021 for any call this build has no evidence a lowering plugin
|
|
13
15
|
* (or `/* @client *\/`) will handle.
|
|
14
16
|
*
|
|
17
|
+
* `checkRichTypePropSerialization` (#2643, BF049): a rich-typed prop is used
|
|
18
|
+
* anywhere in this component's own CLIENT code (a handler, an effect) —
|
|
19
|
+
* regardless of whether a method is ever called on it. `checkRichTypeMethodCalls`
|
|
20
|
+
* only walks expression positions reachable through template lowering (JSX
|
|
21
|
+
* text/attribute positions rendered at SSR); a handler or effect body is a
|
|
22
|
+
* different code path it never analyzes, so even a method call there (e.g.
|
|
23
|
+
* `data.get(...)` inside an `onClick`) is just as invisible to it as a bare
|
|
24
|
+
* read. Either way the prop still crosses the `bf-p` hydration boundary as
|
|
25
|
+
* JSON: it arrives de-riched (`Map`/`Set` → `{}`) or fails to serialize
|
|
26
|
+
* (`BigInt` throws at SSR render). See that function's own doc for
|
|
27
|
+
* why this is metadata-driven rather than an IR walk.
|
|
28
|
+
*
|
|
15
29
|
* Deliberately conservative in both directions:
|
|
16
30
|
* - `resolveReceiverType` (rich-type-evidence.ts) returns `null` — no
|
|
17
31
|
* opinion — for any receiver shape it can't prove a type for, so an
|
|
@@ -22,6 +36,19 @@
|
|
|
22
36
|
* claims (`prepareLoweringMatchers`) is exempt — the seam #2274 and
|
|
23
37
|
* later plugins use to catalogue a rich-type API without touching this
|
|
24
38
|
* module.
|
|
39
|
+
*
|
|
40
|
+
* Every receiver this module can prove a type for is rooted at `propsType`
|
|
41
|
+
* (`resolveReceiverType` only resolves a bare prop, a `props.x` chain, or a
|
|
42
|
+
* loop item of a prop array) — so it always crosses the `bf-p` hydration
|
|
43
|
+
* boundary as JSON. That means `/* @client *\/`, the escape this module used
|
|
44
|
+
* to recommend unconditionally, is UNSOUND on its own: the receiver arrives
|
|
45
|
+
* de-riched (a `Date` prop as an ISO string, a `Map`/`Set` as `{}`), so the
|
|
46
|
+
* spliced call throws or silently misbehaves at hydrate (#2636).
|
|
47
|
+
* `buildSuggestion` never offers a bare `/* @client *\/` for this reason —
|
|
48
|
+
* only a pre-compute-server-side escape (sound for every host rich type)
|
|
49
|
+
* and, for `Date`/`URL` only, a `/* @client *\/` block that explicitly
|
|
50
|
+
* revives the receiver first (`new Date(createdAt)...`), since those two
|
|
51
|
+
* types' `toJSON()` output round-trips through their own constructor.
|
|
25
52
|
*/
|
|
26
53
|
|
|
27
54
|
import type {
|
|
@@ -32,12 +59,20 @@ import type {
|
|
|
32
59
|
TypeInfo,
|
|
33
60
|
AttrValue,
|
|
34
61
|
IRTemplatePart,
|
|
62
|
+
EscapeKind,
|
|
35
63
|
} from './types.ts'
|
|
36
64
|
import type { ParsedExpr } from './expression-parser.ts'
|
|
37
65
|
import { parseExpression } from './expression-parser.ts'
|
|
38
66
|
import { prepareLoweringMatchers, type LoweringMatcher } from './lowering-registry.ts'
|
|
39
67
|
import { ErrorCodes } from './errors.ts'
|
|
40
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
resolveReceiverType,
|
|
70
|
+
baseTypeName,
|
|
71
|
+
HOST_RICH_TYPE_NAMES,
|
|
72
|
+
JSON_REVIVABLE_RICH_TYPE_NAMES,
|
|
73
|
+
resolvePropDeclaredType,
|
|
74
|
+
jsonUnsafeTypeName,
|
|
75
|
+
} from './rich-type-evidence.ts'
|
|
41
76
|
|
|
42
77
|
type Bindings = ReadonlyMap<string, TypeInfo | null>
|
|
43
78
|
const EMPTY_BINDINGS: Bindings = new Map()
|
|
@@ -59,6 +94,72 @@ export function checkRichTypeMethodCalls(root: IRNode, metadata: IRMetadata, err
|
|
|
59
94
|
walkNode(root, metadata, EMPTY_BINDINGS, matchers, errors, seen)
|
|
60
95
|
}
|
|
61
96
|
|
|
97
|
+
/**
|
|
98
|
+
* BF049 (#2643): flag a prop typed as a JSON-unsafe host rich type
|
|
99
|
+
* (`Map`, `Set`, `BigInt`, …) that this component's own client code reads —
|
|
100
|
+
* regardless of whether a method is ever called on it, since
|
|
101
|
+
* `checkRichTypeMethodCalls` only walks template-lowered expression
|
|
102
|
+
* positions and never sees a handler/effect body either way. The prop still
|
|
103
|
+
* crosses the `bf-p` hydration boundary as JSON: it arrives de-riched
|
|
104
|
+
* (`Map`/`Set` → `{}`, every entry silently
|
|
105
|
+
* dropped) or fails to serialize at all (`BigInt` throws `TypeError` at SSR
|
|
106
|
+
* render, killing the whole page).
|
|
107
|
+
*
|
|
108
|
+
* Metadata-driven, not an IR walk — the fire condition is "this prop WILL BE
|
|
109
|
+
* SERIALIZED into bf-p", which is exactly what `ir.metadata.clientAnalysis`
|
|
110
|
+
* (`analyzeClientNeeds`) already decided, and what every adapter's own
|
|
111
|
+
* prop-serialization step (e.g. Hono's `propsToSerialize` filter in
|
|
112
|
+
* `hono-adapter.ts`) reads off the SAME metadata. Mirroring that filter here
|
|
113
|
+
* — rather than re-walking JSX attribute positions — means this check fires
|
|
114
|
+
* in lockstep with what actually gets serialized, on every adapter, without
|
|
115
|
+
* duplicating per-adapter serialization logic into the compiler.
|
|
116
|
+
*/
|
|
117
|
+
export function checkRichTypePropSerialization(root: IRNode, metadata: IRMetadata, errors: CompilerError[], declLoc?: SourceLocation): void {
|
|
118
|
+
if (!metadata.propsType || !metadata.clientAnalysis?.needsInit) return
|
|
119
|
+
const usedProps = new Set(metadata.clientAnalysis.usedProps)
|
|
120
|
+
const loc = declLoc ?? root.loc
|
|
121
|
+
for (const param of metadata.propsParams) {
|
|
122
|
+
if (param.isRest || param.name.startsWith('on') || param.name.startsWith('__')) continue
|
|
123
|
+
if (!usedProps.has(param.name)) continue
|
|
124
|
+
const declared = resolvePropDeclaredType(param.sourceName ?? param.name, metadata)
|
|
125
|
+
const typeName = jsonUnsafeTypeName(declared)
|
|
126
|
+
if (!typeName) continue
|
|
127
|
+
// In-file shadow guard, mirroring `checkExpr`'s identical check for the
|
|
128
|
+
// method-call refusal — a local `interface Map { … }` wins.
|
|
129
|
+
if (metadata.typeDefinitions.some((d) => d.name === typeName)) continue
|
|
130
|
+
pushPropSerializationDiagnostic(errors, loc, param.name, typeName, declared!.raw)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function pushPropSerializationDiagnostic(
|
|
135
|
+
errors: CompilerError[],
|
|
136
|
+
loc: SourceLocation,
|
|
137
|
+
propName: string,
|
|
138
|
+
typeName: string,
|
|
139
|
+
declaredRaw: string,
|
|
140
|
+
): void {
|
|
141
|
+
const consequence =
|
|
142
|
+
typeName === 'bigint' || typeName === 'BigInt'
|
|
143
|
+
? "JSON.stringify throws at SSR render ('Do not know how to serialize a BigInt'), failing the whole page"
|
|
144
|
+
: typeName === 'symbol' || typeName === 'Symbol' || typeName === 'Function'
|
|
145
|
+
? 'JSON.stringify drops the value entirely, so the client reads undefined at hydrate'
|
|
146
|
+
: 'it serializes de-riched (e.g. a Map or Set becomes {} with every entry silently dropped), so the client hydrates against corrupt data'
|
|
147
|
+
errors.push({
|
|
148
|
+
code: ErrorCodes.RICH_TYPE_PROP_NOT_HYDRATABLE,
|
|
149
|
+
severity: 'error',
|
|
150
|
+
message: `Prop '${propName}' is typed '${declaredRaw}' and is read by this component's own client code, so it must cross the bf-p hydration boundary as JSON — and ${typeName} cannot: ${consequence}.`,
|
|
151
|
+
loc,
|
|
152
|
+
suggestion: {
|
|
153
|
+
message:
|
|
154
|
+
'Pre-compute a JSON-serializable value server-side — a string, number, boolean, array, or plain object ' +
|
|
155
|
+
'(e.g. pass [...map.entries()] and rebuild the Map client-side where needed) — and pass that as the prop ' +
|
|
156
|
+
'instead. /* @client */ is NOT an escape here: the prop still crosses the bf-p boundary as JSON and arrives ' +
|
|
157
|
+
'de-riched (#2636).',
|
|
158
|
+
escape: [{ kind: 'prop-precompute' }],
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
62
163
|
function isLoweringClaimed(matchers: readonly LoweringMatcher[], callee: ParsedExpr, args: readonly ParsedExpr[]): boolean {
|
|
63
164
|
return matchers.some((m) => m(callee, args) !== null)
|
|
64
165
|
}
|
|
@@ -83,6 +184,69 @@ function receiverRootIsProp(expr: ParsedExpr, bindings: Bindings): boolean {
|
|
|
83
184
|
return root.kind === 'identifier' && !bindings.has(root.name)
|
|
84
185
|
}
|
|
85
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Build this refusal's escape suggestion (#2636).
|
|
189
|
+
*
|
|
190
|
+
* Every receiver `checkRichTypeMethodCalls` can flag is prop-rooted (see the
|
|
191
|
+
* module docstring's `resolveReceiverType` note), so it crosses the `bf-p`
|
|
192
|
+
* hydration boundary as JSON with no type-aware revival — a bare
|
|
193
|
+
* `/* @client *\/` recommendation is unsound for ALL of them: the receiver
|
|
194
|
+
* arrives at hydrate de-riched (a `Date` prop as its ISO string, a `Map`/`Set`
|
|
195
|
+
* as `{}`), so the spliced-verbatim call throws or silently misbehaves. This
|
|
196
|
+
* function therefore never recommends a bare `/* @client *\/`. Two real
|
|
197
|
+
* escapes exist instead:
|
|
198
|
+
* - Pre-compute server-side and pass the result as an already-serializable
|
|
199
|
+
* prop — sound for every host rich type, always offered first
|
|
200
|
+
* (`ssrCost: 'none'` before `'client-render'`, per `ErrorSuggestion`).
|
|
201
|
+
* - For `Date` / `URL` only (`JSON_REVIVABLE_RICH_TYPE_NAMES`), a
|
|
202
|
+
* `/* @client *\/` block that explicitly REVIVES the receiver —
|
|
203
|
+
* `new Date(createdAt).getUTCFullYear()` — is genuinely hydrate-safe,
|
|
204
|
+
* because both types' `toJSON()` output round-trips through their own
|
|
205
|
+
* one-arg constructor. Every other host rich type has no such escape:
|
|
206
|
+
* the suggestion says so, rather than staying silent about it.
|
|
207
|
+
*/
|
|
208
|
+
function buildSuggestion(
|
|
209
|
+
method: string,
|
|
210
|
+
receiverPath: string,
|
|
211
|
+
receiver: string,
|
|
212
|
+
typeName: string,
|
|
213
|
+
): { message: string; escape: ReadonlyArray<{ kind: EscapeKind }> } {
|
|
214
|
+
const revivalExpr =
|
|
215
|
+
receiverPath === '<expression>'
|
|
216
|
+
? `wrapping the receiver in new ${typeName}(...) before calling .${method}()`
|
|
217
|
+
: `{/* @client */ new ${typeName}(${receiverPath}).${method}(...)}`
|
|
218
|
+
const revivalReason = `a bare /* @client */ crashes at hydrate because ${receiver} crosses the bf-p boundary as JSON and arrives as a plain string, not a ${typeName} instance (#2636)`
|
|
219
|
+
|
|
220
|
+
// `toLocaleDateString` has a catalogued explicit-input form (#2324 slice
|
|
221
|
+
// 2) — point the fix at it instead of the generic escape hatches alone.
|
|
222
|
+
// The implicit-environment forms (zero-arg, locale-only, non-literal
|
|
223
|
+
// locale, an unverifiable timeZone literal) stay refused by design;
|
|
224
|
+
// canonical IANA zone literals compile since #2344.
|
|
225
|
+
if (method === 'toLocaleDateString' && typeName === 'Date') {
|
|
226
|
+
return {
|
|
227
|
+
message:
|
|
228
|
+
"Pass a literal locale and an explicit literal timeZone — .toLocaleDateString('ja-JP', { timeZone: 'UTC' }), a fixed '±HH:MM' offset, or a canonical IANA zone ID like 'Asia/Tokyo' (exact case; #2344) — to compile it to the format_date helper; for a runtime locale, resolve the pattern in your i18n layer and use formatDate(date, pattern, tz) from @barefootjs/client. " +
|
|
229
|
+
`Alternatively pre-compute server-side, or evaluate client-only by ${revivalExpr} — ${revivalReason}.`,
|
|
230
|
+
escape: [{ kind: 'rewrite' }, { kind: 'prop-precompute' }, { kind: 'client-directive' }],
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (JSON_REVIVABLE_RICH_TYPE_NAMES.has(typeName)) {
|
|
235
|
+
return {
|
|
236
|
+
message: `Pre-compute the value server-side and pass it as a prop. Alternatively, evaluate client-only by ${revivalExpr} — ${revivalReason}.`,
|
|
237
|
+
escape: [{ kind: 'prop-precompute' }, { kind: 'client-directive' }],
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
message:
|
|
243
|
+
`Pre-compute the value server-side and pass the result — a string, number, array, or plain object — as a prop. ` +
|
|
244
|
+
`/* @client */ is NOT a safe escape here: ${receiver} cannot cross the bf-p hydration boundary as JSON — it arrives de-riched ` +
|
|
245
|
+
`(e.g. a Map or Set serializes to {}), so the call throws or silently returns the wrong result at hydrate (#2636).`,
|
|
246
|
+
escape: [{ kind: 'prop-precompute' }],
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
86
250
|
function pushDiagnostic(
|
|
87
251
|
errors: CompilerError[],
|
|
88
252
|
seen: Set<string>,
|
|
@@ -96,23 +260,13 @@ function pushDiagnostic(
|
|
|
96
260
|
if (seen.has(key)) return
|
|
97
261
|
seen.add(key)
|
|
98
262
|
const receiver = isProp ? `prop '${receiverPath}'` : `'${receiverPath}'`
|
|
99
|
-
|
|
100
|
-
// 2) — point the fix at it instead of the generic escape hatches alone.
|
|
101
|
-
// The implicit-environment forms (zero-arg, locale-only, non-literal
|
|
102
|
-
// locale, an unverifiable timeZone literal) stay refused by design;
|
|
103
|
-
// canonical IANA zone literals compile since #2344.
|
|
104
|
-
const suggestion =
|
|
105
|
-
method === 'toLocaleDateString' && typeName === 'Date'
|
|
106
|
-
? "Pass a literal locale and an explicit literal timeZone — .toLocaleDateString('ja-JP', { timeZone: 'UTC' }), a fixed '±HH:MM' offset, or a canonical IANA zone ID like 'Asia/Tokyo' (exact case; #2344) — to compile it to the format_date helper; for a runtime locale, resolve the pattern in your i18n layer and use formatDate(date, pattern, tz) from @barefootjs/client. Alternatively add /* @client */ or pre-compute server-side."
|
|
107
|
-
: 'Add /* @client */ to evaluate this expression on the client only, or pre-compute the value server-side.'
|
|
263
|
+
const suggestion = buildSuggestion(method, receiverPath, receiver, typeName)
|
|
108
264
|
errors.push({
|
|
109
265
|
code: ErrorCodes.UNSUPPORTED_JSX_PATTERN,
|
|
110
266
|
severity: 'error',
|
|
111
267
|
message: `Expression cannot be compiled to marked template: method '.${method}()' on ${receiver} of host type '${typeName}' has no catalogued lowering.`,
|
|
112
268
|
loc,
|
|
113
|
-
suggestion
|
|
114
|
-
message: suggestion,
|
|
115
|
-
},
|
|
269
|
+
suggestion,
|
|
116
270
|
})
|
|
117
271
|
}
|
|
118
272
|
|