@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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Catalogued Date/URL calls inside `/* @client *​/` expressions and reactive
3
+ * attribute bindings (#2640, #2641).
4
+ *
5
+ * `emitClientOnlyExpressions` and `emitReactiveAttributeUpdates`
6
+ * (`ir-to-client-js/emit-reactive.ts`) used to splice a reactive expression's
7
+ * source text VERBATIM into the emitted `createEffect` body — correct for an
8
+ * un-catalogued method (BF021 already refused anything else, and the
9
+ * `/* @client *​/` escape is genuinely just "run this raw"), but wrong for a
10
+ * CATALOGUED method (`.toISOString()`, an explicit-locale
11
+ * `.toLocaleDateString(...)`, #2292/#2324): those never fire BF021 (a
12
+ * registered lowering plugin claims them), yet the receiver still crosses
13
+ * the `bf-p` hydration boundary as a de-riched JSON value. The sibling
14
+ * non-`@client` TEXT path (`emitDynamicTextUpdates`) already routed a
15
+ * catalogued call through the `date`/`formatDate` runtime helper before
16
+ * hydrate re-evaluates it (#2292) — these two sites now share that same
17
+ * rewrite via `makeCataloguedCallLowerer`.
18
+ *
19
+ * This suite compiles through `../index` (not the bare `../compiler`) so the
20
+ * built-in lowering plugins (`datePlugin`, `toLocaleDatePlugin`) are
21
+ * registered as the real package does — a call the registry doesn't know
22
+ * about would fire BF021 and never reach the reactive-emission sites this
23
+ * suite is about.
24
+ */
25
+ import { describe, test, expect } from 'bun:test'
26
+ import { compileJSX } from '../index'
27
+ import { TestAdapter } from '../adapters/test-adapter'
28
+ import { ErrorCodes } from '../errors'
29
+
30
+ function compile(src: string) {
31
+ const result = compileJSX(src.trimStart(), 'T.tsx', { adapter: new TestAdapter() })
32
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
33
+ const bf021 = result.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
34
+ return { clientJs, bf021 }
35
+ }
36
+
37
+ describe('#2640 — /* @client */ text expressions route catalogued calls through the runtime helper', () => {
38
+ test('a catalogued zero-arg accessor lowers to date(...), not the raw call', () => {
39
+ const { clientJs, bf021 } = compile(`
40
+ export function Foo({ createdAt }: { createdAt: Date }) {
41
+ return <div>{/* @client */ createdAt.toISOString()}</div>
42
+ }
43
+ `)
44
+ expect(bf021).toHaveLength(0)
45
+ expect(clientJs).toContain('date(createdAt, "toISOString")')
46
+ expect(clientJs).not.toContain('createdAt.toISOString()')
47
+ expect(clientJs).toMatch(/import\s*\{[^}]*\bdate\b[^}]*\}\s*from\s*'@barefootjs\/client\/runtime'/)
48
+ })
49
+
50
+ test('an explicit-locale toLocaleDateString lowers to formatDate(...)', () => {
51
+ const { clientJs, bf021 } = compile(`
52
+ export function Foo({ createdAt }: { createdAt: Date }) {
53
+ return <div>{/* @client */ createdAt.toLocaleDateString('ja-JP', { timeZone: 'UTC' })}</div>
54
+ }
55
+ `)
56
+ expect(bf021).toHaveLength(0)
57
+ expect(clientJs).toContain('formatDate(createdAt, "YYYY/M/D", "UTC")')
58
+ expect(clientJs).not.toContain(".toLocaleDateString('ja-JP'")
59
+ })
60
+
61
+ test('an UNCATALOGUED method call stays verbatim (only what the matcher claims is rewritten)', () => {
62
+ const { clientJs, bf021 } = compile(`
63
+ export function Foo({ createdAt }: { createdAt: Date }) {
64
+ return <div>{/* @client */ createdAt.getDay()}</div>
65
+ }
66
+ `)
67
+ expect(bf021).toHaveLength(0)
68
+ expect(clientJs).toContain('createdAt.getDay()')
69
+ expect(clientJs).not.toContain('date(')
70
+ })
71
+
72
+ test('a zero-arg toLocaleDateString() (implicit locale) stays verbatim — the deliberate revival-escape case (#2639)', () => {
73
+ // BF021 refuses this shape outside /* @client */ and recommends the
74
+ // explicit `new Date(...)` revival wrapper as the sound escape; inside
75
+ // /* @client */ it never fires (the directive already opts out), and
76
+ // this suite pins that the compiler does NOT special-case it further —
77
+ // an implicit-locale call is genuinely the user's own responsibility.
78
+ const { clientJs, bf021 } = compile(`
79
+ export function Foo({ createdAt }: { createdAt: Date }) {
80
+ return <div>{/* @client */ createdAt.toLocaleDateString()}</div>
81
+ }
82
+ `)
83
+ expect(bf021).toHaveLength(0)
84
+ expect(clientJs).toContain('createdAt.toLocaleDateString()')
85
+ expect(clientJs).not.toContain('formatDate(')
86
+ })
87
+ })
88
+
89
+ describe('#2641 — reactive attribute bindings route catalogued calls through the runtime helper', () => {
90
+ test('/* @client */ attribute, destructured-prop style', () => {
91
+ const { clientJs, bf021 } = compile(`
92
+ export function Foo({ createdAt }: { createdAt: Date }) {
93
+ return <div data-x={/* @client */ createdAt.toISOString()} />
94
+ }
95
+ `)
96
+ expect(bf021).toHaveLength(0)
97
+ expect(clientJs).toContain('date(_p.createdAt, "toISOString")')
98
+ expect(clientJs).not.toContain('createdAt.toISOString()')
99
+ })
100
+
101
+ test('/* @client */ attribute, props-object style', () => {
102
+ const { clientJs, bf021 } = compile(`
103
+ export function Foo(props: { createdAt: Date }) {
104
+ return <div data-x={/* @client */ props.createdAt.toISOString()} />
105
+ }
106
+ `)
107
+ expect(bf021).toHaveLength(0)
108
+ expect(clientJs).toContain('date(_p.createdAt, "toISOString")')
109
+ expect(clientJs).not.toContain('props.createdAt.toISOString()')
110
+ })
111
+
112
+ test('non-@client reactive attribute, a catalogued call — the broader gap #2641 found: this site has no directive at all, and previously spliced the raw call verbatim too', () => {
113
+ const { clientJs, bf021 } = compile(`
114
+ export function Foo({ createdAt, label }: { createdAt: Date; label: string }) {
115
+ return <time data-iso={createdAt.toISOString()}>{label}</time>
116
+ }
117
+ `)
118
+ expect(bf021).toHaveLength(0)
119
+ expect(clientJs).toContain('date(_p.createdAt, "toISOString")')
120
+ expect(clientJs).not.toContain('_p.createdAt.toISOString()')
121
+ })
122
+
123
+ test('an explicit-locale toLocaleDateString in attribute position lowers to formatDate(...)', () => {
124
+ const { clientJs, bf021 } = compile(`
125
+ export function Foo({ createdAt }: { createdAt: Date }) {
126
+ return <div data-x={/* @client */ createdAt.toLocaleDateString('ja-JP', { timeZone: 'UTC' })} />
127
+ }
128
+ `)
129
+ expect(bf021).toHaveLength(0)
130
+ expect(clientJs).toContain('formatDate(_p.createdAt, "YYYY/M/D", "UTC")')
131
+ })
132
+
133
+ test('an uncatalogued attribute method call stays verbatim', () => {
134
+ const { clientJs, bf021 } = compile(`
135
+ export function Foo({ createdAt }: { createdAt: Date }) {
136
+ return <div data-x={/* @client */ createdAt.getDay()} />
137
+ }
138
+ `)
139
+ expect(bf021).toHaveLength(0)
140
+ expect(clientJs).toContain('createdAt.getDay()')
141
+ expect(clientJs).not.toContain('date(')
142
+ })
143
+
144
+ test('a non-Date reactive attribute is untouched', () => {
145
+ const { clientJs, bf021 } = compile(`
146
+ export function Foo({ label }: { label: string }) {
147
+ return <div data-x={/* @client */ label.toUpperCase()} />
148
+ }
149
+ `)
150
+ expect(bf021).toHaveLength(0)
151
+ expect(clientJs).toContain('label.toUpperCase()')
152
+ expect(clientJs).not.toContain('date(')
153
+ })
154
+ })
155
+
156
+ describe('#2645 — /* @client */ text expressions elide in the static/CSR template like SSR', () => {
157
+ // `irToComponentTemplateWithOpts`'s 'expression' case never checked
158
+ // `node.clientOnly` — a bare-destructured-prop receiver (`isSimplePropExpression`
159
+ // treats `createdAt.toISOString()` as "simple") routed to this static
160
+ // template builder, which had no clientOnly awareness and inlined the
161
+ // (possibly lowered) value directly, breaking SSR/CSR byte parity: SSR
162
+ // renders the @client region empty, this builder rendered it populated.
163
+ test('markerless: the region is fully empty, matching SSR', () => {
164
+ const { clientJs, bf021 } = compile(`
165
+ export function Foo({ createdAt }: { createdAt: Date }) {
166
+ return <div>{/* @client */ createdAt.toISOString()}</div>
167
+ }
168
+ `)
169
+ expect(bf021).toHaveLength(0)
170
+ expect(clientJs).toContain('template: (_p) => `<div bf="s1"></div>`')
171
+ })
172
+
173
+ test('marker pair (adjacent static text defeats markerless elision): empty marker pair, no inlined value', () => {
174
+ const { clientJs, bf021 } = compile(`
175
+ export function Foo({ createdAt }: { createdAt: Date }) {
176
+ return <div>ISO: {/* @client */ createdAt.toISOString()}</div>
177
+ }
178
+ `)
179
+ expect(bf021).toHaveLength(0)
180
+ expect(clientJs).toContain('template: (_p) => `<div bf="s1">ISO: <!--bf:s0--><!--/--></div>`')
181
+ })
182
+ })
@@ -311,6 +311,78 @@ describe('ClientJS generation with semantic prop refs', () => {
311
311
  })
312
312
  })
313
313
 
314
+ // Issue #2634 regression test: a prop used ONLY inside a `/* @client */`
315
+ // expression was never extracted from `_p` — the identifier reached
316
+ // `createEffect`'s body unbound, throwing `ReferenceError` at hydrate.
317
+ // `buildReferencesGraph` (build-references.ts) deliberately skips adding a
318
+ // `template-closure` edge for a clientOnly expression (it isn't template-
319
+ // reachable), but was never wired to `ctx.clientOnlyElements` either, so no
320
+ // edge of ANY kind reached `neededProps` and `emitPropsExtraction` had no
321
+ // evidence the prop was used at all.
322
+ describe('Issue #2634 regression', () => {
323
+ test('a destructured prop used only inside /* @client */ is extracted from _p', () => {
324
+ const source = `
325
+ interface Props {
326
+ label: string
327
+ }
328
+
329
+ export function LabelClientOnly({ label }: Props) {
330
+ return <div>{/* @client */ label.toUpperCase()}</div>
331
+ }
332
+ `
333
+
334
+ const result = compileJSX(source, 'LabelClientOnly.tsx', { adapter })
335
+
336
+ expect(result.errors).toHaveLength(0)
337
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
338
+ expect(clientJs).toBeDefined()
339
+ // The prop must be bound before the createEffect body reads it.
340
+ expect(clientJs?.content).toContain('const label = _p.label')
341
+ // A bare `label` reference with no preceding binding is exactly the
342
+ // unbound-identifier shape that threw ReferenceError at hydrate.
343
+ expect(clientJs?.content).toMatch(/const label = _p\.label[\s\S]*label\.toUpperCase\(\)/)
344
+ })
345
+
346
+ test('a props-object member used only inside /* @client */ needs no extraction (already _p.x)', () => {
347
+ const source = `
348
+ interface Props {
349
+ label: string
350
+ }
351
+
352
+ export function LabelClientOnly(props: Props) {
353
+ return <div>{/* @client */ props.label.toUpperCase()}</div>
354
+ }
355
+ `
356
+
357
+ const result = compileJSX(source, 'LabelClientOnly.tsx', { adapter })
358
+
359
+ expect(result.errors).toHaveLength(0)
360
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
361
+ expect(clientJs).toBeDefined()
362
+ expect(clientJs?.content).toContain('_p.label.toUpperCase()')
363
+ })
364
+
365
+ test('a prop used both in a normal reactive position AND inside /* @client */ is still extracted once', () => {
366
+ const source = `
367
+ interface Props {
368
+ label: string
369
+ }
370
+
371
+ export function LabelBoth({ label }: Props) {
372
+ return <div><span>{label}</span><span>{/* @client */ label.toUpperCase()}</span></div>
373
+ }
374
+ `
375
+
376
+ const result = compileJSX(source, 'LabelBoth.tsx', { adapter })
377
+
378
+ expect(result.errors).toHaveLength(0)
379
+ const clientJs = result.files.find((f) => f.type === 'clientJs')
380
+ expect(clientJs).toBeDefined()
381
+ const matches = clientJs?.content.match(/const label = _p\.label/g) ?? []
382
+ expect(matches).toHaveLength(1)
383
+ })
384
+ })
385
+
314
386
  // Issue #257 regression test: Double props prefix in template literals
315
387
  describe('Issue #257 regression', () => {
316
388
  test('does NOT double-wrap props.xxx when already prefixed', () => {
@@ -173,14 +173,78 @@ describe('rich-type method-call refusal — fires (BF021)', () => {
173
173
  expect(errors[0].message).toContain("'Map'")
174
174
  })
175
175
 
176
- test('diagnostic carries the @client suggestion', () => {
176
+ // #2636: bare /* @client */ crashes at hydrate for every rich-type
177
+ // receiver here (the prop arrives de-riched over the JSON bf-p boundary),
178
+ // so the suggestion must never recommend it bare. Date/URL (JSON-revivable
179
+ // via their own one-arg constructor) get an explicit-revival @client form
180
+ // instead; every other host rich type gets pre-compute only.
181
+ test('Date generic method: pre-compute lead + explicit-revival @client escape, never a bare @client', () => {
177
182
  const errors = bf021(`
178
183
  export function Foo({ createdAt }: { createdAt: Date }) {
179
184
  return <div>{createdAt.toISOString()}</div>
180
185
  }
181
186
  `)
182
187
  expect(errors[0].severity).toBe('error')
183
- expect(errors[0].suggestion?.message).toContain('@client')
188
+ const message = errors[0].suggestion?.message ?? ''
189
+ expect(message).toContain('Pre-compute')
190
+ expect(message).toContain('new Date(createdAt)')
191
+ expect(message).not.toMatch(/Add \/\* @client \*\//)
192
+ expect(errors[0].suggestion?.escape).toEqual([{ kind: 'prop-precompute' }, { kind: 'client-directive' }])
193
+ })
194
+
195
+ test('Map method: pre-compute only, explicit no-safe-@client-escape warning', () => {
196
+ const errors = bf021(`
197
+ export function Foo({ m }: { m: Map<string, string> }) {
198
+ return <div>{m.get('x')}</div>
199
+ }
200
+ `)
201
+ const message = errors[0].suggestion?.message ?? ''
202
+ expect(message).toContain('Pre-compute')
203
+ expect(message).toContain('NOT a safe escape')
204
+ expect(message).not.toContain('new Map(')
205
+ expect(errors[0].suggestion?.escape).toEqual([{ kind: 'prop-precompute' }])
206
+ })
207
+
208
+ // Zero-arg toLocaleDateString() is BF021's one case where the refusal is
209
+ // deliberately ADAPTER-UNIFORM rather than a DSL-adapter-only gap (unlike
210
+ // BF101's "JS-runtime executes verbatim" carve-out): `TestAdapter extends
211
+ // JsxAdapter` (adapter-hono's own base class) here, and it still refuses.
212
+ // See #2356's decision comment and `date-method-uncatalogued.ts`'s
213
+ // docstring — a JS-runtime adapter's hydrate leg re-evaluates a
214
+ // prop-derived expression against a JSON-de-riched receiver (a `Date`
215
+ // prop arrives as its ISO string), so "the SSR runtime can evaluate the
216
+ // call" does not make the compiled artifact sound; the compiler doesn't
217
+ // special-case this adapter class here on purpose.
218
+ test('zero-arg toLocaleDateString() refuses even on a JsxAdapter (#2356 — adapter-uniform by design)', () => {
219
+ const errors = bf021(`
220
+ export function Foo({ createdAt }: { createdAt: Date }) {
221
+ return <div>{createdAt.toLocaleDateString()}</div>
222
+ }
223
+ `)
224
+ expect(errors).toHaveLength(1)
225
+ expect(errors[0].message).toContain("'.toLocaleDateString()'")
226
+ expect(errors[0].message).toContain("'createdAt'")
227
+ // The suggestion for this specific method+type points at the
228
+ // literal-locale/timeZone escape (#2324) ahead of the generic
229
+ // pre-compute-or-revival fallback every other BF021 carries.
230
+ const message = errors[0].suggestion?.message ?? ''
231
+ expect(message).toContain('literal locale')
232
+ // #2636: even this branch's @client escape must be the explicit-revival
233
+ // form, never a bare /* @client */.
234
+ expect(message).toContain('new Date(createdAt)')
235
+ expect(errors[0].suggestion?.escape).toEqual([{ kind: 'rewrite' }, { kind: 'prop-precompute' }, { kind: 'client-directive' }])
236
+ })
237
+
238
+ test('URL generic method: revivable tier applies past Date (#2636)', () => {
239
+ const errors = bf021(`
240
+ export function Foo({ href }: { href: URL }) {
241
+ return <div>{href.toString()}</div>
242
+ }
243
+ `)
244
+ expect(errors).toHaveLength(1)
245
+ const message = errors[0].suggestion?.message ?? ''
246
+ expect(message).toContain('new URL(href)')
247
+ expect(errors[0].suggestion?.escape).toEqual([{ kind: 'prop-precompute' }, { kind: 'client-directive' }])
184
248
  })
185
249
  })
186
250
 
@@ -194,6 +258,19 @@ describe('rich-type method-call refusal — silent (no BF021)', () => {
194
258
  expect(errors).toHaveLength(0)
195
259
  })
196
260
 
261
+ // Pins the premise the revivable-tier suggestion (#2636) depends on: a
262
+ // /* @client */ block that wraps the receiver in `new Date(...)` first is
263
+ // itself a call-result receiver (resolveReceiverType → null), so it never
264
+ // fires BF021 — the same reason a bare @client call never fires above.
265
+ test('/* @client */ with explicit new Date(...) revival wrapper', () => {
266
+ const errors = bf021(`
267
+ export function Foo({ createdAt }: { createdAt: Date }) {
268
+ return <div>{/* @client */ new Date(createdAt).getUTCFullYear()}</div>
269
+ }
270
+ `)
271
+ expect(errors).toHaveLength(0)
272
+ })
273
+
197
274
  test('/* @client */-wrapped conditional branch', () => {
198
275
  const errors = bf021(`
199
276
  export function Foo({ d }: { d: Date | null }) {
@@ -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')