@barefootjs/jsx 0.31.6 → 0.31.8
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 +105 -11
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-registration.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__/env-signal-template-prelude.test.ts +87 -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/csr-substitute.ts +3 -2
- package/src/ir-to-client-js/emit-reactive.ts +29 -8
- package/src/ir-to-client-js/emit-registration.ts +51 -3
- 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
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
|
|