@barefootjs/jsx 0.31.5 → 0.31.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/compiler.d.ts.map +1 -1
  2. package/dist/errors.d.ts +1 -0
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +104 -9
  7. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/client-only-elision.d.ts +11 -5
  9. package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  12. package/dist/rich-type-evidence.d.ts +86 -0
  13. package/dist/rich-type-evidence.d.ts.map +1 -1
  14. package/dist/rich-type-refusal.d.ts +59 -11
  15. package/dist/rich-type-refusal.d.ts.map +1 -1
  16. package/dist/types.d.ts +71 -0
  17. package/dist/types.d.ts.map +1 -1
  18. package/package.json +2 -2
  19. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +117 -17
  20. package/src/__tests__/client-only-date-lowering.test.ts +182 -0
  21. package/src/__tests__/doc-examples.test.ts +1 -0
  22. package/src/__tests__/prop-references.test.ts +72 -0
  23. package/src/__tests__/rich-type-method-refusal.test.ts +79 -2
  24. package/src/__tests__/rich-type-prop-serialization.test.ts +238 -0
  25. package/src/compiler.ts +3 -1
  26. package/src/errors.ts +13 -0
  27. package/src/index.ts +6 -0
  28. package/src/ir-to-client-js/build-references.ts +17 -0
  29. package/src/ir-to-client-js/client-only-elision.ts +11 -5
  30. package/src/ir-to-client-js/emit-reactive.ts +29 -8
  31. package/src/ir-to-client-js/html-template.ts +64 -0
  32. package/src/rich-type-evidence.ts +104 -0
  33. package/src/rich-type-refusal.ts +177 -23
  34. package/src/types.ts +77 -0
@@ -810,6 +810,36 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
810
810
  // no-slotId branch below already uses — `elidedPath` (not this
811
811
  // string) is what the claim plan resolves against, so no marker
812
812
  // anchor is needed here at all.
813
+ //
814
+ // This branch evaluates `node.expr` eagerly, while the sibling
815
+ // top-level emitter (`generateCsrTemplateWithOpts`) emits NOTHING
816
+ // for its `markerless` case. That difference is safe only because
817
+ // this branch is UNREACHABLE today, which rests on three facts —
818
+ // all three must hold, so check them before widening either one:
819
+ //
820
+ // 1. `markerless` is only ever set by `markElided`, which
821
+ // `client-only-elision.ts` calls exclusively under
822
+ // `child.clientOnly && child.slotId`. So today
823
+ // `markerless === true` IMPLIES `clientOnly && slotId` — it is
824
+ // never some other, eagerly-renderable kind of markerless slot.
825
+ // 2. That walk never descends into a loop or conditional: its
826
+ // `default:` case freezes the level and returns without
827
+ // recursing (only `element` recurses).
828
+ // 3. This function only ever runs ON loop bodies and conditional
829
+ // branches (every caller passes `l.children[0]`, a `renderLeaf`,
830
+ // or `whenTrue`/`whenFalse`), and by its own docstring "does not
831
+ // honour `clientOnly`".
832
+ //
833
+ // (2) + (3) mean no marked node reaches here; (1) means that if one
834
+ // ever did, eager evaluation would be WRONG — it would render a
835
+ // deferred `/* @client */` read at template time instead of leaving
836
+ // it to init's createEffect. So if #2483 widens elision to cover
837
+ // loop/conditional subtrees, this branch stops being dead code and
838
+ // must gain the same `clientOnly` deferral check the sibling emitter
839
+ // has; it cannot simply keep evaluating. The two checks were left
840
+ // separate rather than collapsed into a shared helper precisely
841
+ // because their `clientOnly` semantics differ — see that function's
842
+ // own comment (#2617).
813
843
  if (node.markerless) {
814
844
  const bare = wrapInterpolation(wrapExpr(node.expr))
815
845
  return `\${${bare}}`
@@ -1780,6 +1810,19 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1780
1810
 
1781
1811
  case 'expression': {
1782
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
+ }
1783
1826
  const wrapped = transformExpr(node.expr, node.templateExpr)
1784
1827
  // Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
1785
1828
  const value = node.joinArrayChild
@@ -2363,6 +2406,27 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2363
2406
  // the SSR adapters' `renderExpression` byte-for-byte (byte parity):
2364
2407
  // empty at CSR mount too, since the expression is evaluated only
2365
2408
  // once init's createEffect runs, same as the SSR case.
2409
+ //
2410
+ // Slot unification Step B (`markerless`, decided once by
2411
+ // `client-only-elision.ts` before this CSR pass runs; mirrors the
2412
+ // nested `if (expr.markerless) return ''` shape all nine SSR
2413
+ // adapters' `renderExpression` use inside their own identical
2414
+ // `clientOnly && slotId` branch — see `spec/slot-unification.md`
2415
+ // §5a): when the marker pair itself was ALSO elided, drop it and
2416
+ // emit nothing. The claim plan resolves the elided slot via
2417
+ // `elidedPath` (a precomputed child-index path), not a marker
2418
+ // scan, so no anchor comment is needed here at all — matches SSR's
2419
+ // fully-empty output for this case byte-for-byte (#2617; this is
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).
2429
+ if (node.markerless) return ''
2366
2430
  return `<!--bf:${node.slotId}--><!--/-->`
2367
2431
  }
2368
2432
  {
@@ -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
 
package/src/types.ts CHANGED
@@ -2192,9 +2192,62 @@ export interface CompilerError {
2192
2192
  suggestion?: ErrorSuggestion
2193
2193
  }
2194
2194
 
2195
+ /**
2196
+ * The kind of escape available from a refusal — the way a user gets their
2197
+ * legitimate, in-subset JSX to compile on an adapter that cannot host it
2198
+ * (#2613).
2199
+ *
2200
+ * Lives here, next to `ErrorSuggestion`, because both halves of the
2201
+ * "loud-or-escapable" contract must speak the SAME enum: the diagnostic
2202
+ * CLAIMS kinds (`ErrorSuggestion.escape`) and a conformance fixture
2203
+ * DEMONSTRATES them (`JSXFixture.escapes`, which re-exports this type —
2204
+ * `@barefootjs/adapter-tests` depends on this package, not the reverse).
2205
+ * `escape-coverage.test.ts` then checks claims are a subset of what the
2206
+ * twins prove, which is only meaningful while the two share one union.
2207
+ */
2208
+ export type EscapeKind = 'client-directive' | 'prop-precompute' | 'rewrite'
2209
+
2210
+ /**
2211
+ * What an escape costs at SSR. `'none'` — full server render, the result
2212
+ * is present in the server HTML. `'client-render'` — the region is EMPTY
2213
+ * in server HTML until hydration.
2214
+ *
2215
+ * Output-equivalence is explicitly NOT the bar for an escape (#2613): a
2216
+ * `/* @client *\/` region is definitionally not equivalent, and that is a
2217
+ * legitimate trade the user chooses. Making the cost typed and visible is
2218
+ * the honest substitute for pretending it doesn't exist — every renderer
2219
+ * that surfaces an escape surfaces its cost from THIS map, so the trade
2220
+ * can never be quietly dropped on the way to a user.
2221
+ */
2222
+ export type EscapeSsrCost = 'none' | 'client-render'
2223
+
2224
+ export const ESCAPE_SSR_COST: Record<EscapeKind, EscapeSsrCost> = {
2225
+ // `/* @client */` — compiles and hydrates correctly, renders nothing at SSR.
2226
+ 'client-directive': 'client-render',
2227
+ // The refused computation moves to an already-computed prop.
2228
+ 'prop-precompute': 'none',
2229
+ // The source is restructured into an equivalent in-subset shape.
2230
+ rewrite: 'none',
2231
+ }
2232
+
2195
2233
  export interface ErrorSuggestion {
2196
2234
  message: string
2197
2235
  replacement?: string
2236
+ /**
2237
+ * The escape kinds this diagnostic CLAIMS are available, structured
2238
+ * (#2613 increment 3). Additive and one-way: `message` stays
2239
+ * authoritative for humans — several sites have genuinely good
2240
+ * site-specific prose no enum should flatten — while this field is
2241
+ * authoritative for machines (`bf compat`'s legend, the docs matrix,
2242
+ * claim verification). New and edited refusal sites populate it; older
2243
+ * sites migrate opportunistically, so ABSENT means "not yet declared",
2244
+ * never "no escape exists".
2245
+ *
2246
+ * Order carries the recommendation: list a `ssrCost: 'none'` escape
2247
+ * before a `'client-render'` one, matching the prose rule that a
2248
+ * full-SSR way out is offered first.
2249
+ */
2250
+ escape?: ReadonlyArray<{ kind: EscapeKind }>
2198
2251
  }
2199
2252
 
2200
2253
  /**
@@ -2211,6 +2264,30 @@ export interface ConformancePin {
2211
2264
  severity: 'error' | 'warning'
2212
2265
  /** Tracking issue URL (known-limitation label) for this refusal, when one exists. */
2213
2266
  issue?: string
2267
+ /**
2268
+ * Present when THIS adapter has no verified escape yet for this
2269
+ * refusal — the per-adapter half of #2613's "loud-or-escapable" floor
2270
+ * (`packages/compat/src/__tests__/escape-coverage.test.ts`). `issue` is
2271
+ * the tracking pointer for closing the gap (fall back to
2272
+ * https://github.com/piconic-ai/barefootjs/issues/2613 itself when no
2273
+ * more specific issue exists yet).
2274
+ *
2275
+ * Declared here, next to the refusal it qualifies, so an adapter's own
2276
+ * package is the sole place that states what it knows about its own
2277
+ * refusal — no central cross-adapter ledger to keep in sync (that was
2278
+ * the architectural defect increment 1 shipped with: a `packages/compat`
2279
+ * test hardcoding every adapter's id, which made adapters non-additive).
2280
+ *
2281
+ * Absent means the adapter believes an escape is owed here — either
2282
+ * already satisfied (the refused fixture's `escapes` twin compiles
2283
+ * clean, unpinned, non-divergent, and not CSR-skipped on THIS adapter)
2284
+ * or a pending gap the floor test won't let merge silently.
2285
+ *
2286
+ * Shrink-only, same discipline `KNOWN_HOLES` established: once a
2287
+ * working twin exists here, a lingering `unescapable` becomes a STALE
2288
+ * declaration and the floor test fails loudly on it, naming this pin.
2289
+ */
2290
+ unescapable?: { issue: string }
2214
2291
  }
2215
2292
  /** Keyed by shared-fixture id (`JSXFixture.id`). */
2216
2293
  export type ConformancePins = Record<string, ReadonlyArray<ConformancePin>>