@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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Rich-type prop serialization refusal (BF049, #2643).
3
+ *
4
+ * Sibling of BF021 (`rich-type-method-refusal.test.ts`) for a DIFFERENT
5
+ * failure shape: whether or not a method is called is irrelevant here. A
6
+ * rich-typed prop (`Map`, `Set`, `BigInt`, …) used anywhere in this
7
+ * component's own client code (a handler, an effect) — merely read, or even
8
+ * method-called (`data.get(...)`, `tags.has(...)`, below) — is invisible to
9
+ * BF021 either way, since BF021 only walks template-lowered expression
10
+ * positions and never analyzes a handler/effect body. But the prop still
11
+ * crosses the `bf-p` hydration boundary as JSON, where it either arrives
12
+ * de-riched (`Map`/`Set` → `{}`, entries silently dropped) or fails to
13
+ * serialize at all (`BigInt` throws at SSR render).
14
+ *
15
+ * `checkRichTypePropSerialization` is metadata-driven (mirrors the adapter's
16
+ * own `propsToSerialize` filter), not a lowering-plugin-aware IR walk, so
17
+ * this suite doesn't need the plugin-registry snapshot dance
18
+ * `rich-type-method-refusal.test.ts` uses — no lowering matcher can exempt a
19
+ * prop from serialization the way one exempts a method call from BF021.
20
+ */
21
+
22
+ import { describe, test, expect } from 'bun:test'
23
+ import { compileJSX } from '../compiler'
24
+ import { ErrorCodes } from '../errors'
25
+ import { TestAdapter } from '../adapters/test-adapter'
26
+
27
+ const adapter = new TestAdapter()
28
+
29
+ function bf049(source: string, filePath = 'Test.tsx') {
30
+ const result = compileJSX(source, filePath, { adapter })
31
+ return result.errors.filter((e) => e.code === ErrorCodes.RICH_TYPE_PROP_NOT_HYDRATABLE)
32
+ }
33
+
34
+ describe('rich-type prop serialization refusal — fires (BF049)', () => {
35
+ test('Map prop read in a client event handler (destructured)', () => {
36
+ const errors = bf049(`
37
+ 'use client'
38
+ export function Foo({ data }: { data: Map<string, number> }) {
39
+ return <button onClick={() => console.log(data.get('x'))}>go</button>
40
+ }
41
+ `)
42
+ expect(errors).toHaveLength(1)
43
+ expect(errors[0].severity).toBe('error')
44
+ expect(errors[0].message).toContain("Prop 'data'")
45
+ expect(errors[0].message).toContain("'Map<string, number>'")
46
+ expect(errors[0].message).toContain('Map cannot')
47
+ expect(errors[0].suggestion?.escape).toEqual([{ kind: 'prop-precompute' }])
48
+ })
49
+
50
+ test('Set prop read in a client event handler', () => {
51
+ const errors = bf049(`
52
+ 'use client'
53
+ export function Foo({ tags }: { tags: Set<string> }) {
54
+ return <button onClick={() => console.log(tags.has('x'))}>go</button>
55
+ }
56
+ `)
57
+ expect(errors).toHaveLength(1)
58
+ expect(errors[0].message).toContain("Prop 'tags'")
59
+ })
60
+
61
+ test('object-form BigInt prop throws at SSR — message names the consequence', () => {
62
+ const errors = bf049(`
63
+ 'use client'
64
+ export function Foo({ n }: { n: BigInt }) {
65
+ return <button onClick={() => console.log(n)}>go</button>
66
+ }
67
+ `)
68
+ expect(errors).toHaveLength(1)
69
+ expect(errors[0].message).toContain('JSON.stringify throws at SSR render')
70
+ })
71
+
72
+ test('bigint KEYWORD prop also fires — closes the keyword-spelling gap BF021 leaves open', () => {
73
+ const errors = bf049(`
74
+ 'use client'
75
+ export function Foo({ n }: { n: bigint }) {
76
+ return <button onClick={() => console.log(n)}>go</button>
77
+ }
78
+ `)
79
+ expect(errors).toHaveLength(1)
80
+ expect(errors[0].message).toContain("'bigint'")
81
+ })
82
+
83
+ test('symbol keyword prop fires', () => {
84
+ const errors = bf049(`
85
+ 'use client'
86
+ export function Foo({ s }: { s: symbol }) {
87
+ return <button onClick={() => console.log(s)}>go</button>
88
+ }
89
+ `)
90
+ expect(errors).toHaveLength(1)
91
+ expect(errors[0].message).toContain('drops the value entirely')
92
+ })
93
+
94
+ test('props-object mode (props.data)', () => {
95
+ const errors = bf049(`
96
+ 'use client'
97
+ export function Foo(props: { data: Map<string, number> }) {
98
+ return <button onClick={() => console.log(props.data)}>go</button>
99
+ }
100
+ `)
101
+ expect(errors).toHaveLength(1)
102
+ expect(errors[0].message).toContain("Prop 'data'")
103
+ })
104
+
105
+ test('renamed destructured prop ({ data: d }) — bf-p key is the source name', () => {
106
+ const errors = bf049(`
107
+ 'use client'
108
+ export function Foo({ data: d }: { data: Map<string, number> }) {
109
+ return <button onClick={() => console.log(d)}>go</button>
110
+ }
111
+ `)
112
+ expect(errors).toHaveLength(1)
113
+ expect(errors[0].message).toContain("Prop 'd'")
114
+ })
115
+
116
+ test('optional Map prop (Map<...> | undefined) still resolves via stripUnion', () => {
117
+ const errors = bf049(`
118
+ 'use client'
119
+ export function Foo({ data }: { data?: Map<string, number> }) {
120
+ return <button onClick={() => console.log(data)}>go</button>
121
+ }
122
+ `)
123
+ expect(errors).toHaveLength(1)
124
+ })
125
+
126
+ test('RegExp prop client-read fires (loses data on JSON round-trip, not merely "degrades to something")', () => {
127
+ const errors = bf049(`
128
+ 'use client'
129
+ export function Foo({ pattern }: { pattern: RegExp }) {
130
+ return <button onClick={() => console.log(pattern.test('x'))}>go</button>
131
+ }
132
+ `)
133
+ expect(errors).toHaveLength(1)
134
+ })
135
+
136
+ test('Function-typed (object-form) prop client-read fires', () => {
137
+ const errors = bf049(`
138
+ 'use client'
139
+ export function Foo({ cb }: { cb: Function }) {
140
+ return <button onClick={() => cb()}>go</button>
141
+ }
142
+ `)
143
+ expect(errors).toHaveLength(1)
144
+ })
145
+ })
146
+
147
+ describe('rich-type prop serialization refusal — silent (no BF049)', () => {
148
+ test('Map prop never read by client code (server-only component)', () => {
149
+ const errors = bf049(`
150
+ export function Foo({ data }: { data: Map<string, number> }) {
151
+ return <div>hi</div>
152
+ }
153
+ `)
154
+ expect(errors).toHaveLength(0)
155
+ })
156
+
157
+ test('Map prop declared but unused by the client init', () => {
158
+ const errors = bf049(`
159
+ 'use client'
160
+ export function Foo({ data, label }: { data: Map<string, number>; label: string }) {
161
+ return <button onClick={() => console.log(label)}>go</button>
162
+ }
163
+ `)
164
+ expect(errors).toHaveLength(0)
165
+ })
166
+
167
+ test('Date prop client-read is silent — Date is JSON-revivable, not JSON-unsafe', () => {
168
+ const errors = bf049(`
169
+ 'use client'
170
+ export function Foo({ createdAt }: { createdAt: Date }) {
171
+ return <button onClick={() => console.log(createdAt)}>go</button>
172
+ }
173
+ `)
174
+ expect(errors).toHaveLength(0)
175
+ })
176
+
177
+ test('URL prop client-read is silent — same revivable-subset exemption', () => {
178
+ const errors = bf049(`
179
+ 'use client'
180
+ export function Foo({ href }: { href: URL }) {
181
+ return <button onClick={() => console.log(href)}>go</button>
182
+ }
183
+ `)
184
+ expect(errors).toHaveLength(0)
185
+ })
186
+
187
+ test('two-arm union (RegExp | string) is silent — the InputOTP pattern-prop shape', () => {
188
+ const errors = bf049(`
189
+ 'use client'
190
+ export function Foo({ pattern }: { pattern: RegExp | string }) {
191
+ return <button onClick={() => console.log(pattern)}>go</button>
192
+ }
193
+ `)
194
+ expect(errors).toHaveLength(0)
195
+ })
196
+
197
+ test('in-file interface Map shadow', () => {
198
+ const errors = bf049(`
199
+ 'use client'
200
+ interface Map { iso: string }
201
+ export function Foo({ data }: { data: Map }) {
202
+ return <button onClick={() => console.log(data)}>go</button>
203
+ }
204
+ `)
205
+ expect(errors).toHaveLength(0)
206
+ })
207
+
208
+ test('on-prefixed prop (a function prop) is silent — excluded from serialization entirely', () => {
209
+ const errors = bf049(`
210
+ 'use client'
211
+ export function Foo({ onGo }: { onGo: () => void }) {
212
+ return <button onClick={onGo}>go</button>
213
+ }
214
+ `)
215
+ expect(errors).toHaveLength(0)
216
+ })
217
+
218
+ test('plain string/number/array props are silent', () => {
219
+ const errors = bf049(`
220
+ 'use client'
221
+ export function Foo({ label, count, items }: { label: string; count: number; items: string[] }) {
222
+ return <button onClick={() => console.log(label, count, items)}>go</button>
223
+ }
224
+ `)
225
+ expect(errors).toHaveLength(0)
226
+ })
227
+
228
+ test('a local type alias of a rich type is a conservative miss (documented, covered by the runtime backstop)', () => {
229
+ const errors = bf049(`
230
+ 'use client'
231
+ type RichMap = Map<string, number>
232
+ export function Foo({ data }: { data: RichMap }) {
233
+ return <button onClick={() => console.log(data)}>go</button>
234
+ }
235
+ `)
236
+ expect(errors).toHaveLength(0)
237
+ })
238
+ })
package/src/compiler.ts CHANGED
@@ -26,7 +26,7 @@ import { applyCssLayerPrefix, applyCssLayerPrefixToFile } from './css-layer-pref
26
26
  import { preprocessInlineJsxCallbacks } from './preprocess-inline-jsx-callbacks.ts'
27
27
  import { extractSsrDefaults } from './ssr-defaults.ts'
28
28
  import { computeSsrSeedPlan } from './ssr-seed-plan.ts'
29
- import { checkRichTypeMethodCalls } from './rich-type-refusal.ts'
29
+ import { checkRichTypeMethodCalls, checkRichTypePropSerialization } from './rich-type-refusal.ts'
30
30
  import { ErrorCodes, createError } from './errors.ts'
31
31
  import { collectComponentNamesFromIR } from './ir-to-client-js/child-components.ts'
32
32
 
@@ -160,6 +160,7 @@ function compileMultipleComponents(
160
160
 
161
161
  componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
162
162
  checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
163
+ checkRichTypePropSerialization(componentIR.root, componentIR.metadata, errors, ctx.propsDestructuring?.loc)
163
164
 
164
165
  // Slot unification Step B — see the single-component path's identical
165
166
  // call for why this must run before adapter.generate/generateClientJs.
@@ -777,6 +778,7 @@ export function compileJSX(
777
778
  // Pre-compute client JS analysis for adapter optimization
778
779
  componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
779
780
  checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
781
+ checkRichTypePropSerialization(componentIR.root, componentIR.metadata, errors, ctx.propsDestructuring?.loc)
780
782
 
781
783
  // Slot unification Step B (`spec/slot-unification.md` §5 Step B): decide
782
784
  // marker elision ONCE, mutating `componentIR.root` in place, before either
package/src/errors.ts CHANGED
@@ -53,6 +53,16 @@ export const ErrorCodes = {
53
53
  // helper verbatim instead of compiling it as a component — so this code
54
54
  // fires only for the client-component compilation path.
55
55
  SIBLING_COMPONENT_NOT_COMPILED: 'BF048',
56
+ // A prop typed as a host rich type whose `JSON.stringify` output is not
57
+ // revivable (`Map`, `Set`, `BigInt`, …) is used by this component's own
58
+ // client code (a handler, an effect) — regardless of whether a method is
59
+ // called on it, since `checkRichTypeMethodCalls`'s BF021 only walks
60
+ // template-lowered expression positions and never sees a handler/effect
61
+ // body either way. The prop still crosses the `bf-p` hydration boundary as
62
+ // JSON and arrives de-riched (or, for `BigInt`, fails to serialize at all,
63
+ // throwing at SSR render). Sibling of BF021 for the "client-side use"
64
+ // shape, which BF021's template-only walk can never reach (#2643).
65
+ RICH_TYPE_PROP_NOT_HYDRATABLE: 'BF049',
56
66
 
57
67
  // Import errors (BF050-BF059)
58
68
  SHARED_PROGRAM_REQUIRED: 'BF050',
@@ -164,6 +174,9 @@ const errorMessages: Record<ErrorCode, string> = {
164
174
  "verbatim, see #932), or rewrite it as a single-return ternary/conditional chain so the " +
165
175
  "component pipeline can compile it.",
166
176
 
177
+ [ErrorCodes.RICH_TYPE_PROP_NOT_HYDRATABLE]:
178
+ 'Rich-typed prop cannot cross the bf-p hydration boundary as JSON.',
179
+
167
180
  [ErrorCodes.SHARED_PROGRAM_REQUIRED]:
168
181
  'Shared ts.Program required for type-based reactivity classification. This source imports a Reactive<T>-branded library (e.g. @barefootjs/form) whose getters cannot be classified by regex alone. Pass `options.program` (built via `createProgramForCorpus`) so the analyzer can resolve the brand through the TypeChecker.',
169
182
 
@@ -129,6 +129,23 @@ export function buildReferencesGraph(ctx: ClientJsContext, irRoot: IRNode): Refe
129
129
  }
130
130
  }
131
131
 
132
+ // No identifiers.ts precedent — `clientOnlyElements` (Slot unification
133
+ // Step B) postdates the file this phase mirrors. A `/* @client */`
134
+ // expression's Phase 3 IR-walk visitor below deliberately adds no edge
135
+ // at all (never mind which context) so a referenced name isn't
136
+ // misclassified as template-reachable; without a substitute edge here,
137
+ // a prop used ONLY inside such an expression never reaches
138
+ // `neededProps` (`analyzeClientNeeds`/`init-declarations.ts`), so
139
+ // `emitPropsExtraction` never binds it and the emitted `createEffect`
140
+ // reads an unbound identifier — a real `ReferenceError` at hydrate,
141
+ // not a hypothetical (#2634). `'init-body'` is the same context an
142
+ // event handler's identifiers use (few lines up) — correct here for
143
+ // the same reason: the expression runs in `initX`'s scope, never the
144
+ // template closure.
145
+ for (const elem of ctx.clientOnlyElements) {
146
+ addExprEdges(ROOT_SOURCE, elem.expression, 'init-body')
147
+ }
148
+
132
149
  // identifiers.ts L107-135
133
150
  for (const elem of ctx.loopElements) {
134
151
  addExprEdges(ROOT_SOURCE, elem.array, 'template-closure')
@@ -452,8 +452,9 @@ export function buildSignalMemoEnv(
452
452
  for (const s of signals) {
453
453
  // Env signals (#2057) have no static initial value to bake — their getter
454
454
  // is a live request-scoped read (`searchParams().get(k)`). Leave it in the
455
- // CSR template as a real call, exactly as the pre-#2057 bare `searchParams()`
456
- // import was (it was never a substitutable signal).
455
+ // CSR template as a real call; `emitRegistrationAndHydration`'s
456
+ // `buildTemplateDefPart` (#2654) gives the template lambda its own
457
+ // `const [<getter>] = <envFactory>()` prelude so the call resolves.
457
458
  if (s.envReader) continue
458
459
  substitutions.set(s.getter, {
459
460
  kind: 'call',
@@ -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
  }
@@ -4,7 +4,7 @@
4
4
  * and the final hydrate() call emission.
5
5
  */
6
6
 
7
- import type { ComponentIR, IRFragment, IRNode, ReferencesGraph } from '../types.ts'
7
+ import type { ComponentIR, IRFragment, IRNode, ReferencesGraph, SignalInfo } from '../types.ts'
8
8
  import type { ClientJsContext } from './types.ts'
9
9
  import { PROPS_PARAM } from './utils.ts'
10
10
  import { computeInlinability, toLegacyInlinability } from './compute-inlinability.ts'
@@ -115,6 +115,54 @@ export function csrInlinableConstantsFromCtx(ctx: ClientJsContext): Map<string,
115
115
  return out
116
116
  }
117
117
 
118
+ /**
119
+ * Build the `template:` ComponentDef entry text for a generated
120
+ * `templateHtml` string.
121
+ *
122
+ * When the component holds one or more env signals (`createSearchParams()`,
123
+ * #2057), the template lambda destructures its own copy of each getter in
124
+ * a block-body prelude before returning the template literal:
125
+ *
126
+ * template: (_p) => { const [sp] = createSearchParams(); return `...` }
127
+ *
128
+ * instead of the plain expression-body form:
129
+ *
130
+ * template: (_p) => `...`
131
+ *
132
+ * The template lambda runs at module scope (`render()` / `renderChild()`),
133
+ * but an env-signal getter is otherwise only ever destructured inside
134
+ * `init...` — so a template that calls the getter directly (`sp()`,
135
+ * `searchParams()`) ReferenceErrors the moment it runs (#2654). Before
136
+ * #2057, `searchParams` was a bare module-scope import and the same
137
+ * template-body call worked by accident; #2057 moved it behind
138
+ * `const [sp] = createSearchParams()` without updating template emission.
139
+ *
140
+ * The prelude is emitted whenever the component HAS an env signal —
141
+ * never gated on whether `templateHtml` textually mentions the getter.
142
+ * Scanning already-emitted template HTML for a getter name would be a
143
+ * string/regex parse of emitted JS, which CLAUDE.md's parse rule forbids.
144
+ * Unconditional emission is safe because `createSearchParams()`
145
+ * (`packages/client/src/reactive.ts`) only returns the shared
146
+ * `searchParamsTuple` module singleton — no side effect — so declaring it
147
+ * in a prelude the template body doesn't end up using costs nothing.
148
+ *
149
+ * `envFactory` is expected to always be set alongside `envReader` (the
150
+ * analyzer sets both together, #2057) — the `undefined` branch is a
151
+ * defensive fallback: if it's ever missing, skip that signal's prelude
152
+ * line entirely rather than guessing a canonical factory name, leaving
153
+ * that one signal's template reference exactly as unsound as before this
154
+ * fix (never worse).
155
+ */
156
+ function buildTemplateDefPart(ctx: ClientJsContext, templateHtml: string): string {
157
+ const envDecls = ctx.signals
158
+ .filter((s): s is SignalInfo & { envFactory: string } => Boolean(s.envReader) && Boolean(s.envFactory))
159
+ .map((s) => `const [${s.getter}] = ${s.envFactory}()`)
160
+ if (envDecls.length === 0) {
161
+ return `template: (${PROPS_PARAM}) => \`${templateHtml}\``
162
+ }
163
+ return `template: (${PROPS_PARAM}) => { ${envDecls.join('; ')}; return \`${templateHtml}\` }`
164
+ }
165
+
118
166
  /** Emit hydrate() call that registers component, template, and hydrates. */
119
167
  /**
120
168
  * Generate the closing brace for the init function and the hydrate() call.
@@ -157,7 +205,7 @@ export function emitRegistrationAndHydration(
157
205
  if (canGenerateStaticTemplate(_ir.root, propNamesForStaticCheck, inlinableConstants, unsafeLocalNames)) {
158
206
  const templateHtml = irToComponentTemplate(_ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName)
159
207
  if (templateHtml) {
160
- defParts.push(`template: (${PROPS_PARAM}) => \`${templateHtml}\``)
208
+ defParts.push(buildTemplateDefPart(ctx, templateHtml))
161
209
  }
162
210
  } else {
163
211
  // CSR fallback: emit for all components that can't generate static templates.
@@ -173,7 +221,7 @@ export function emitRegistrationAndHydration(
173
221
  _ir.root, csrInlinableConstants, ctx, restSpreadNames, ctx.propsObjectName, unsafeLocalNames, ctx.deferredChildSlots
174
222
  )
175
223
  if (templateHtml) {
176
- defParts.push(`template: (${PROPS_PARAM}) => \`${templateHtml}\``)
224
+ defParts.push(buildTemplateDefPart(ctx, templateHtml))
177
225
  }
178
226
  }
179
227
  // No else: top-level-only components skip template entirely (save bytes)
@@ -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.