@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.
- package/dist/compiler.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.js +105 -11
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/rich-type-evidence.d.ts +86 -0
- package/dist/rich-type-evidence.d.ts.map +1 -1
- package/dist/rich-type-refusal.d.ts +59 -11
- package/dist/rich-type-refusal.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/client-only-date-lowering.test.ts +182 -0
- package/src/__tests__/env-signal-template-prelude.test.ts +87 -0
- package/src/__tests__/prop-references.test.ts +72 -0
- package/src/__tests__/rich-type-method-refusal.test.ts +79 -2
- package/src/__tests__/rich-type-prop-serialization.test.ts +238 -0
- package/src/compiler.ts +3 -1
- package/src/errors.ts +13 -0
- package/src/ir-to-client-js/build-references.ts +17 -0
- package/src/ir-to-client-js/csr-substitute.ts +3 -2
- package/src/ir-to-client-js/emit-reactive.ts +29 -8
- package/src/ir-to-client-js/emit-registration.ts +51 -3
- package/src/ir-to-client-js/html-template.ts +22 -4
- package/src/rich-type-evidence.ts +104 -0
- package/src/rich-type-refusal.ts +177 -23
|
@@ -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
|
|
456
|
-
//
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
2408
|
-
//
|
|
2409
|
-
// `
|
|
2410
|
-
//
|
|
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.
|