Skip to content

Commit a4f3794

Browse files
committed
Expose renderToReadableStream in Node
In Node `renderToReadableStream` is currently not exposed, even though Node has supported `ReadableStream` since 18.0.0. Users such as Remix use `renderToPipeableStream` only to immediately turn the `PipeableStream` into a `ReadableStream`. This is unfortunate and causes slowdowns. This commit exposes `renderToReadableStream` from the Node build of `react-dom/server`. Additionally this commit changes the `deno` export condition to use the Node build, because there is no reason that Deno users should not be able to use `renderToPipeableStream`, because Deno supports Node's `WriteableStream`.
1 parent 64f8951 commit a4f3794

4 files changed

Lines changed: 133 additions & 5 deletions

File tree

packages/react-dom/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@
6161
"react-server": "./server.react-server.js",
6262
"workerd": "./server.edge.js",
6363
"bun": "./server.bun.js",
64-
"deno": "./server.browser.js",
6564
"worker": "./server.browser.js",
6665
"node": "./server.node.js",
6766
"edge-light": "./server.edge.js",

packages/react-dom/server.node.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,10 @@ export function resumeToPipeableStream() {
3737
arguments,
3838
);
3939
}
40+
41+
export function renderToReadableStream() {
42+
return require('./src/server/react-dom-server.node').renderToReadableStream.apply(
43+
this,
44+
arguments,
45+
);
46+
}

packages/react-dom/src/server/ReactDOMFizzServerNode.js

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ function createCancelHandler(request: Request, reason: string) {
5656
};
5757
}
5858

59-
type Options = {
59+
type RenderToPipeableStreamOptions = {
6060
identifierPrefix?: string,
6161
namespaceURI?: string,
6262
nonce?: string,
@@ -92,7 +92,10 @@ type PipeableStream = {
9292
pipe<T: Writable>(destination: T): T,
9393
};
9494

95-
function createRequestImpl(children: ReactNodeList, options: void | Options) {
95+
function createRequestImpl(
96+
children: ReactNodeList,
97+
options: void | RenderToPipeableStreamOptions,
98+
) {
9699
const resumableState = createResumableState(
97100
options ? options.identifierPrefix : undefined,
98101
options ? options.unstable_externalRuntimeSrc : undefined,
@@ -125,7 +128,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
125128

126129
function renderToPipeableStream(
127130
children: ReactNodeList,
128-
options?: Options,
131+
options?: RenderToPipeableStreamOptions,
129132
): PipeableStream {
130133
const request = createRequestImpl(children, options);
131134
let hasStartedFlowing = false;
@@ -160,6 +163,120 @@ function renderToPipeableStream(
160163
};
161164
}
162165

166+
type RenderToReadableStreamOptions = {
167+
identifierPrefix?: string,
168+
namespaceURI?: string,
169+
nonce?: string,
170+
bootstrapScriptContent?: string,
171+
bootstrapScripts?: Array<string | BootstrapScriptDescriptor>,
172+
bootstrapModules?: Array<string | BootstrapScriptDescriptor>,
173+
progressiveChunkSize?: number,
174+
signal?: AbortSignal,
175+
onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
176+
onPostpone?: (reason: string, postponeInfo: PostponeInfo) => void,
177+
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
178+
importMap?: ImportMap,
179+
formState?: ReactFormState<any, any> | null,
180+
onHeaders?: (headers: Headers) => void,
181+
maxHeadersLength?: number,
182+
};
183+
184+
// TODO: Move to sub-classing ReadableStream.
185+
type ReactDOMServerReadableStream = ReadableStream & {
186+
allReady: Promise<void>,
187+
};
188+
189+
function renderToReadableStream(
190+
children: ReactNodeList,
191+
options?: RenderToReadableStreamOptions,
192+
): Promise<ReactDOMServerReadableStream> {
193+
return new Promise((resolve, reject) => {
194+
let onFatalError;
195+
let onAllReady;
196+
const allReady = new Promise<void>((res, rej) => {
197+
onAllReady = res;
198+
onFatalError = rej;
199+
});
200+
201+
function onShellReady() {
202+
const stream: ReactDOMServerReadableStream = (new ReadableStream(
203+
{
204+
type: 'bytes',
205+
pull: (controller): ?Promise<void> => {
206+
startFlowing(request, controller);
207+
},
208+
cancel: (reason): ?Promise<void> => {
209+
stopFlowing(request);
210+
abort(request, reason);
211+
},
212+
},
213+
// $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
214+
{highWaterMark: 0},
215+
): any);
216+
// TODO: Move to sub-classing ReadableStream.
217+
stream.allReady = allReady;
218+
resolve(stream);
219+
}
220+
function onShellError(error: mixed) {
221+
// If the shell errors the caller of `renderToReadableStream` won't have access to `allReady`.
222+
// However, `allReady` will be rejected by `onFatalError` as well.
223+
// So we need to catch the duplicate, uncatchable fatal error in `allReady` to prevent a `UnhandledPromiseRejection`.
224+
allReady.catch(() => {});
225+
reject(error);
226+
}
227+
228+
const onHeaders = options ? options.onHeaders : undefined;
229+
let onHeadersImpl;
230+
if (onHeaders) {
231+
onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
232+
onHeaders(new Headers(headersDescriptor));
233+
};
234+
}
235+
236+
const resumableState = createResumableState(
237+
options ? options.identifierPrefix : undefined,
238+
options ? options.unstable_externalRuntimeSrc : undefined,
239+
options ? options.bootstrapScriptContent : undefined,
240+
options ? options.bootstrapScripts : undefined,
241+
options ? options.bootstrapModules : undefined,
242+
);
243+
const request = createRequest(
244+
children,
245+
resumableState,
246+
createRenderState(
247+
resumableState,
248+
options ? options.nonce : undefined,
249+
options ? options.unstable_externalRuntimeSrc : undefined,
250+
options ? options.importMap : undefined,
251+
onHeadersImpl,
252+
options ? options.maxHeadersLength : undefined,
253+
),
254+
createRootFormatContext(options ? options.namespaceURI : undefined),
255+
options ? options.progressiveChunkSize : undefined,
256+
options ? options.onError : undefined,
257+
onAllReady,
258+
onShellReady,
259+
onShellError,
260+
onFatalError,
261+
options ? options.onPostpone : undefined,
262+
options ? options.formState : undefined,
263+
);
264+
if (options && options.signal) {
265+
const signal = options.signal;
266+
if (signal.aborted) {
267+
abort(request, (signal: any).reason);
268+
} else {
269+
const listener = () => {
270+
abort(request, (signal: any).reason);
271+
signal.removeEventListener('abort', listener);
272+
};
273+
signal.addEventListener('abort', listener);
274+
}
275+
}
276+
startWork(request);
277+
});
278+
}
279+
163280
function resumeRequestImpl(
164281
children: ReactNodeList,
165282
postponedState: PostponedState,
@@ -220,6 +337,7 @@ function resumeToPipeableStream(
220337

221338
export {
222339
renderToPipeableStream,
340+
renderToReadableStream,
223341
resumeToPipeableStream,
224342
ReactVersion as version,
225343
};

packages/react-dom/src/server/react-dom-server.node.stable.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,9 @@
77
* @flow
88
*/
99

10-
export {renderToPipeableStream, version} from './ReactDOMFizzServerNode.js';
10+
export {
11+
renderToPipeableStream,
12+
renderToReadableStream,
13+
version,
14+
} from './ReactDOMFizzServerNode.js';
1115
export {prerenderToNodeStream} from './ReactDOMFizzStaticNode.js';

0 commit comments

Comments
 (0)