@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.
@@ -1,17 +1,31 @@
1
1
  /**
2
- * Rich-type method-call refusal (#2273).
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
- * A method call on a prop typed as a built-in "host rich type" (`Date`,
5
- * `Map`, …) has no catalogued lowering — no adapter can emit `.toISOString()`
6
- * into a template. Left unchecked, such a call transliterates into the
7
- * target template's own dot-call syntax and dies at request time (a Go
8
- * template `.CreatedAt.ToISOString` panic, a Jinja `AttributeError`, …),
9
- * once per adapter, only once someone renders the page. This module makes
10
- * that gap loud at compile time instead: `checkRichTypeMethodCalls` walks
11
- * every expression position the compiler already treats as template-lowered
12
- * and pushes BF021 for any call this build has no evidence a lowering plugin
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 { resolveReceiverType, baseTypeName, HOST_RICH_TYPE_NAMES } from './rich-type-evidence.ts'
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
- // `toLocaleDateString` has a catalogued explicit-input form (#2324 slice
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