@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.
@@ -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 dateLoweringMatcher = getReactiveDateLoweringMatcher(ctx)
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 = lowerToLocaleCallsInReactiveExpr(
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
- const expression = rewriteDestructuredPropsInExpr(attr.expression, ctx)
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
- // the second of the two top-level CSR emitters `irToHtmlTemplate`
2408
- // and `generateCsrTemplateWithOpts` that must each consult
2409
- // `markerless` see that function's own check at this file's
2410
- // `case 'expression'` for why the two aren't collapsed into one).
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.
@@ -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