-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathcreateCompositeComponent.ts
More file actions
342 lines (309 loc) 路 9.24 KB
/
Copy pathcreateCompositeComponent.ts
File metadata and controls
342 lines (309 loc) 路 9.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
import { createElement } from 'react'
import { renderToReadableStream } from 'virtual:tanstack-rsc-runtime'
import { getRequest } from '@tanstack/start-server-core'
import { getStartContext } from '@tanstack/start-storage-context'
import { sanitizeSlotArgs } from './slotUsageSanitizer'
import { ReplayableStream } from './ReplayableStream'
import { ClientSlot } from './ClientSlot'
import {
RSC_SLOT_USAGES_STREAM,
SERVER_COMPONENT_STREAM,
} from './ServerComponentTypes'
import { createRscCssEnvelope } from './rscCssEnvelope'
import type {
AnyCompositeComponent,
CompositeComponentResult,
RscSlotUsageEvent,
ServerComponentStream,
ValidateCompositeComponent,
} from './ServerComponentTypes'
import type { RscCssEnvelopeOptions } from './rscCssEnvelope'
import './rscSsrHandler' // Import for global declaration side effect
/**
* Creates a composite server component with slot support.
*
* Supports returning:
* - A ReactNode directly
* - An object structure with ReactNodes: accessed as `src.Foo`
* - Nested structures: accessed as `src.x.Bar`
*
* Props that are functions become slots - they render as ClientSlot placeholders
* in the RSC output, filled in by the consumer with actual implementations.
*
* The returned value is NOT directly renderable. Use `<CompositeComponent src={...} />`.
*
* @example
* ```tsx
* const src = await createCompositeComponent((props) => (
* <div>
* <header>{props.header('Dashboard')}</header>
* <main>{props.children}</main>
* </div>
* ))
*
* // In route component
* return (
* <CompositeComponent src={src} header={(title) => <h1>{title}</h1>}>
* <p>Main content</p>
* </CompositeComponent>
* )
* ```
*/
export async function createCompositeComponent<TComp>(
component: ValidateCompositeComponent<TComp>,
options?: RscCssEnvelopeOptions,
): Promise<CompositeComponentResult<TComp>> {
const isDev = process.env.NODE_ENV === 'development'
// Dev-only: stream slot usage events (slot + raw args)
const slotUsagesEmitter = isDev
? createReadableStreamEmitter<RscSlotUsageEvent>()
: null
// Create a wrapper component that will be rendered inside React's Flight context.
// This ensures React.cache works properly since the component is called during
// renderToReadableStream's render phase, not before it.
const { proxy: proxyProps } = createSlotProxy<{}>({
onSlotCall: slotUsagesEmitter
? (name, args) => {
const sanitizedArgs = sanitizeSlotArgs(args)
slotUsagesEmitter.emit({
slot: name,
args: sanitizedArgs.length ? sanitizedArgs : undefined,
})
}
: undefined,
})
// Wrapper that renders the user's component inside Flight render context
async function ServerComponentWrapper() {
return createRscCssEnvelope(
await (component as React.FC)(proxyProps),
options,
)
}
// Render using createElement so React calls our component during Flight rendering
// This is critical for React.cache to work - the component must be invoked
// during renderToReadableStream's execution, not before
const flightStream = renderToReadableStream(
createElement(ServerComponentWrapper as any),
)
// Check if this is an SSR request (router) or a direct server function call
const ctx = getStartContext({ throwIfNotFound: false })
const isRouterRequest = ctx?.handlerType === 'router'
const ssrHandler = globalThis.__RSC_SSR__
// SSR path: buffer stream for replay, pre-decode for synchronous rendering
if (isRouterRequest && ssrHandler) {
const signal = getRequest().signal
const stream = new ReplayableStream(flightStream, { signal })
// Pre-decode during loader phase for synchronous SSR rendering
const decoded = await ssrHandler.decode(stream)
// For SSR we know decode fully consumed the Flight stream.
slotUsagesEmitter?.close()
return ssrHandler.createCompositeProxy(
stream,
decoded,
slotUsagesEmitter?.stream,
) as CompositeComponentResult<TComp>
}
// Server function call path:
// The serialization adapter will stream to the client.
const monitoredFlightStream =
isDev && slotUsagesEmitter
? wrapReadableStream(flightStream, {
onDone: () => {
slotUsagesEmitter.close()
},
onCancel: () => {
slotUsagesEmitter.close()
},
onError: () => {
slotUsagesEmitter.close()
},
})
: flightStream
return createCompositeHandle(monitoredFlightStream, {
slotUsagesStream: slotUsagesEmitter?.stream,
}) as CompositeComponentResult<TComp>
}
/**
* Creates a composite handle for server function responses.
* No proxy needed - the client will decode and create its own proxy.
*/
function createCompositeHandle(
flightStream: ReadableStream<Uint8Array>,
options?: {
slotUsagesStream?: ReadableStream<RscSlotUsageEvent>
},
): AnyCompositeComponent {
// Simple single-use stream wrapper. For server function calls, the stream
// is consumed exactly once by the serialization adapter for transport.
const streamWrapper: ServerComponentStream = {
createReplayStream: () => flightStream,
}
// Create a stub function with the stream attached for serialization.
// This will never be rendered directly - it goes through serialization
// which extracts the stream and sends it to the client.
const stub = function CompositeComponentStub(): never {
throw new Error(
'CompositeComponent from server function cannot be rendered on server. ' +
'It should be serialized and sent to the client.',
)
}
;(stub as any)[SERVER_COMPONENT_STREAM] = streamWrapper
// Note: RENDERABLE_RSC is not set (or implicitly false), indicating this is a composite component
if (options?.slotUsagesStream) {
;(stub as any)[RSC_SLOT_USAGES_STREAM] = options.slotUsagesStream
}
return stub as unknown as AnyCompositeComponent
}
/**
* Base slot props type - functions that become ClientSlot placeholders
*/
interface SlotPropsBase {
[key: string]:
| ((...args: Array<any>) => React.ReactNode)
| React.ReactNode
| undefined
children?: React.ReactNode
}
interface SlotProxyResult<TSlotProps extends object> {
proxy: TSlotProps & SlotPropsBase
}
/**
* Proxy that turns property access into ClientSlot renders.
* Also tracks accessed slot names for devtools.
*/
function createSlotProxy<TSlotProps extends object>(options?: {
onSlotCall?: (name: string, args: Array<any>) => void
}): SlotProxyResult<TSlotProps> {
const cache = new Map<string, (...args: Array<any>) => React.ReactNode>()
const proxy = new Proxy({} as TSlotProps & SlotPropsBase, {
get(_target, prop) {
if (prop === 'then' || typeof prop !== 'string') return undefined
if (prop === 'children') {
options?.onSlotCall?.('children', [])
return createElement(ClientSlot, { slot: 'children', args: [] })
}
let fn = cache.get(prop)
if (!fn) {
fn = (...args: Array<any>) => {
options?.onSlotCall?.(prop, args)
return createElement(ClientSlot, { slot: prop, args })
}
cache.set(prop, fn)
}
return fn
},
})
return {
proxy,
}
}
function createReadableStreamEmitter<T>(): {
stream: ReadableStream<T>
emit: (value: T) => void
close: () => void
} {
let closed = false
const queue: Array<T> = []
let controller: ReadableStreamDefaultController<T> | null = null
const stream = new ReadableStream<T>({
start(ctrl) {
controller = ctrl
for (const value of queue) {
try {
ctrl.enqueue(value)
} catch {
// Ignore
}
}
queue.length = 0
if (closed) {
try {
ctrl.close()
} catch {
// Ignore
}
}
},
cancel() {
closed = true
controller = null
queue.length = 0
},
})
const emit = (value: T) => {
if (closed) return
if (!controller) {
queue.push(value)
return
}
try {
controller.enqueue(value)
} catch {
// Ignore
}
}
const close = () => {
if (closed) return
closed = true
if (controller) {
try {
controller.close()
} catch {
// Ignore
}
controller = null
}
}
return { stream, emit, close }
}
function wrapReadableStream<T>(
source: ReadableStream<T>,
handlers: {
onDone?: () => void
onCancel?: () => void
onError?: () => void
},
): ReadableStream<T> {
const reader = source.getReader()
let finished = false
const finish = () => {
if (finished) return
finished = true
handlers.onDone?.()
try {
reader.releaseLock()
} catch {
// Ignore
}
}
return new ReadableStream<T>({
async pull(controller) {
try {
const { value, done } = await reader.read()
if (done) {
controller.close()
finish()
return
}
controller.enqueue(value)
} catch (err) {
try {
controller.error(err)
} catch {
// Ignore
}
handlers.onError?.()
finish()
}
},
async cancel(reason) {
handlers.onCancel?.()
try {
await reader.cancel(reason)
} catch {
// Ignore
}
finish()
},
})
}