Skip to content

Commit 65ec57d

Browse files
authored
[Fizz] Add Web Streams to Fizz Node entry point (#33475)
New take on #33441. This uses a wrapper instead of a separate bundle.
1 parent b3d5e90 commit 65ec57d

12 files changed

+503
-9
lines changed

packages/react-dom/npm/server.node.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ exports.version = l.version;
1313
exports.renderToString = l.renderToString;
1414
exports.renderToStaticMarkup = l.renderToStaticMarkup;
1515
exports.renderToPipeableStream = s.renderToPipeableStream;
16+
exports.renderToReadableStream = s.renderToReadableStream;
1617
if (s.resumeToPipeableStream) {
1718
exports.resumeToPipeableStream = s.resumeToPipeableStream;
1819
}
20+
if (s.resume) {
21+
exports.resume = s.resume;
22+
}

packages/react-dom/npm/static.node.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ if (process.env.NODE_ENV === 'production') {
99

1010
exports.version = s.version;
1111
exports.prerenderToNodeStream = s.prerenderToNodeStream;
12+
exports.prerender = s.prerender;
1213
exports.resumeAndPrerenderToNodeStream = s.resumeAndPrerenderToNodeStream;
14+
exports.resumeAndPrerender = s.resumeAndPrerender;

packages/react-dom/server.node.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,17 @@ 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+
}
47+
48+
export function resume() {
49+
return require('./src/server/react-dom-server.node').resume.apply(
50+
this,
51+
arguments,
52+
);
53+
}

packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ describe('ReactDOMFizzServerNode', () => {
5656
throw theInfinitePromise;
5757
}
5858

59+
async function readContentWeb(stream) {
60+
const reader = stream.getReader();
61+
let content = '';
62+
while (true) {
63+
const {done, value} = await reader.read();
64+
if (done) {
65+
return content;
66+
}
67+
content += Buffer.from(value).toString('utf8');
68+
}
69+
}
70+
5971
it('should call renderToPipeableStream', async () => {
6072
const {writable, output} = getTestWritable();
6173
await act(() => {
@@ -67,6 +79,14 @@ describe('ReactDOMFizzServerNode', () => {
6779
expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
6880
});
6981

82+
it('should support web streams', async () => {
83+
const stream = await act(() =>
84+
ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>),
85+
);
86+
const result = await readContentWeb(stream);
87+
expect(result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
88+
});
89+
7090
it('flush fully if piping in on onShellReady', async () => {
7191
const {writable, output} = getTestWritable();
7292
await act(() => {

packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ describe('ReactDOMFizzStaticNode', () => {
4646
});
4747
}
4848

49+
async function readContentWeb(stream) {
50+
const reader = stream.getReader();
51+
let content = '';
52+
while (true) {
53+
const {done, value} = await reader.read();
54+
if (done) {
55+
return content;
56+
}
57+
content += Buffer.from(value).toString('utf8');
58+
}
59+
}
60+
4961
// @gate experimental
5062
it('should call prerenderToNodeStream', async () => {
5163
const result = await ReactDOMFizzStatic.prerenderToNodeStream(
@@ -55,6 +67,13 @@ describe('ReactDOMFizzStaticNode', () => {
5567
expect(prelude).toMatchInlineSnapshot(`"<div>hello world</div>"`);
5668
});
5769

70+
// @gate experimental
71+
it('should suppport web streams', async () => {
72+
const result = await ReactDOMFizzStatic.prerender(<div>hello world</div>);
73+
const prelude = await readContentWeb(result.prelude);
74+
expect(prelude).toMatchInlineSnapshot(`"<div>hello world</div>"`);
75+
});
76+
5877
// @gate experimental
5978
it('should emit DOCTYPE at the root of the document', async () => {
6079
const result = await ReactDOMFizzStatic.prerenderToNodeStream(

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

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import {
4141
createRootFormatContext,
4242
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
4343

44+
import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode';
45+
4446
import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion';
4547
ensureCorrectIsomorphicReactVersion();
4648

@@ -167,6 +169,141 @@ function renderToPipeableStream(
167169
};
168170
}
169171

172+
function createFakeWritableFromReadableStreamController(
173+
controller: ReadableStreamController,
174+
): Writable {
175+
// The current host config expects a Writable so we create
176+
// a fake writable for now to push into the Readable.
177+
return ({
178+
write(chunk: string | Uint8Array) {
179+
if (typeof chunk === 'string') {
180+
chunk = textEncoder.encode(chunk);
181+
}
182+
controller.enqueue(chunk);
183+
// in web streams there is no backpressure so we can alwas write more
184+
return true;
185+
},
186+
end() {
187+
controller.close();
188+
},
189+
destroy(error) {
190+
// $FlowFixMe[method-unbinding]
191+
if (typeof controller.error === 'function') {
192+
// $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types.
193+
controller.error(error);
194+
} else {
195+
controller.close();
196+
}
197+
},
198+
}: any);
199+
}
200+
201+
// TODO: Move to sub-classing ReadableStream.
202+
type ReactDOMServerReadableStream = ReadableStream & {
203+
allReady: Promise<void>,
204+
};
205+
206+
type WebStreamsOptions = Omit<
207+
Options,
208+
'onShellReady' | 'onShellError' | 'onAllReady' | 'onHeaders',
209+
> & {signal: AbortSignal, onHeaders?: (headers: Headers) => void};
210+
211+
function renderToReadableStream(
212+
children: ReactNodeList,
213+
options?: WebStreamsOptions,
214+
): Promise<ReactDOMServerReadableStream> {
215+
return new Promise((resolve, reject) => {
216+
let onFatalError;
217+
let onAllReady;
218+
const allReady = new Promise<void>((res, rej) => {
219+
onAllReady = res;
220+
onFatalError = rej;
221+
});
222+
223+
function onShellReady() {
224+
let writable: Writable;
225+
const stream: ReactDOMServerReadableStream = (new ReadableStream(
226+
{
227+
type: 'bytes',
228+
start: (controller): ?Promise<void> => {
229+
writable =
230+
createFakeWritableFromReadableStreamController(controller);
231+
},
232+
pull: (controller): ?Promise<void> => {
233+
startFlowing(request, writable);
234+
},
235+
cancel: (reason): ?Promise<void> => {
236+
stopFlowing(request);
237+
abort(request, reason);
238+
},
239+
},
240+
// $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
241+
{highWaterMark: 0},
242+
): any);
243+
// TODO: Move to sub-classing ReadableStream.
244+
stream.allReady = allReady;
245+
resolve(stream);
246+
}
247+
function onShellError(error: mixed) {
248+
// If the shell errors the caller of `renderToReadableStream` won't have access to `allReady`.
249+
// However, `allReady` will be rejected by `onFatalError` as well.
250+
// So we need to catch the duplicate, uncatchable fatal error in `allReady` to prevent a `UnhandledPromiseRejection`.
251+
allReady.catch(() => {});
252+
reject(error);
253+
}
254+
255+
const onHeaders = options ? options.onHeaders : undefined;
256+
let onHeadersImpl;
257+
if (onHeaders) {
258+
onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
259+
onHeaders(new Headers(headersDescriptor));
260+
};
261+
}
262+
263+
const resumableState = createResumableState(
264+
options ? options.identifierPrefix : undefined,
265+
options ? options.unstable_externalRuntimeSrc : undefined,
266+
options ? options.bootstrapScriptContent : undefined,
267+
options ? options.bootstrapScripts : undefined,
268+
options ? options.bootstrapModules : undefined,
269+
);
270+
const request = createRequest(
271+
children,
272+
resumableState,
273+
createRenderState(
274+
resumableState,
275+
options ? options.nonce : undefined,
276+
options ? options.unstable_externalRuntimeSrc : undefined,
277+
options ? options.importMap : undefined,
278+
onHeadersImpl,
279+
options ? options.maxHeadersLength : undefined,
280+
),
281+
createRootFormatContext(options ? options.namespaceURI : undefined),
282+
options ? options.progressiveChunkSize : undefined,
283+
options ? options.onError : undefined,
284+
onAllReady,
285+
onShellReady,
286+
onShellError,
287+
onFatalError,
288+
options ? options.onPostpone : undefined,
289+
options ? options.formState : undefined,
290+
);
291+
if (options && options.signal) {
292+
const signal = options.signal;
293+
if (signal.aborted) {
294+
abort(request, (signal: any).reason);
295+
} else {
296+
const listener = () => {
297+
abort(request, (signal: any).reason);
298+
signal.removeEventListener('abort', listener);
299+
};
300+
signal.addEventListener('abort', listener);
301+
}
302+
}
303+
startWork(request);
304+
});
305+
}
306+
170307
function resumeRequestImpl(
171308
children: ReactNodeList,
172309
postponedState: PostponedState,
@@ -225,8 +362,89 @@ function resumeToPipeableStream(
225362
};
226363
}
227364

365+
type WebStreamsResumeOptions = Omit<
366+
Options,
367+
'onShellReady' | 'onShellError' | 'onAllReady',
368+
> & {signal: AbortSignal};
369+
370+
function resume(
371+
children: ReactNodeList,
372+
postponedState: PostponedState,
373+
options?: WebStreamsResumeOptions,
374+
): Promise<ReactDOMServerReadableStream> {
375+
return new Promise((resolve, reject) => {
376+
let onFatalError;
377+
let onAllReady;
378+
const allReady = new Promise<void>((res, rej) => {
379+
onAllReady = res;
380+
onFatalError = rej;
381+
});
382+
383+
function onShellReady() {
384+
let writable: Writable;
385+
const stream: ReactDOMServerReadableStream = (new ReadableStream(
386+
{
387+
type: 'bytes',
388+
start: (controller): ?Promise<void> => {
389+
writable =
390+
createFakeWritableFromReadableStreamController(controller);
391+
},
392+
pull: (controller): ?Promise<void> => {
393+
startFlowing(request, writable);
394+
},
395+
cancel: (reason): ?Promise<void> => {
396+
stopFlowing(request);
397+
abort(request, reason);
398+
},
399+
},
400+
// $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
401+
{highWaterMark: 0},
402+
): any);
403+
// TODO: Move to sub-classing ReadableStream.
404+
stream.allReady = allReady;
405+
resolve(stream);
406+
}
407+
function onShellError(error: mixed) {
408+
// If the shell errors the caller of `renderToReadableStream` won't have access to `allReady`.
409+
// However, `allReady` will be rejected by `onFatalError` as well.
410+
// So we need to catch the duplicate, uncatchable fatal error in `allReady` to prevent a `UnhandledPromiseRejection`.
411+
allReady.catch(() => {});
412+
reject(error);
413+
}
414+
const request = resumeRequest(
415+
children,
416+
postponedState,
417+
resumeRenderState(
418+
postponedState.resumableState,
419+
options ? options.nonce : undefined,
420+
),
421+
options ? options.onError : undefined,
422+
onAllReady,
423+
onShellReady,
424+
onShellError,
425+
onFatalError,
426+
options ? options.onPostpone : undefined,
427+
);
428+
if (options && options.signal) {
429+
const signal = options.signal;
430+
if (signal.aborted) {
431+
abort(request, (signal: any).reason);
432+
} else {
433+
const listener = () => {
434+
abort(request, (signal: any).reason);
435+
signal.removeEventListener('abort', listener);
436+
};
437+
signal.addEventListener('abort', listener);
438+
}
439+
}
440+
startWork(request);
441+
});
442+
}
443+
228444
export {
229445
renderToPipeableStream,
446+
renderToReadableStream,
230447
resumeToPipeableStream,
448+
resume,
231449
ReactVersion as version,
232450
};

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,8 @@ function prerender(
159159
type ResumeOptions = {
160160
nonce?: NonceOption,
161161
signal?: AbortSignal,
162-
onError?: (error: mixed) => ?string,
163-
onPostpone?: (reason: string) => void,
164-
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
162+
onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
163+
onPostpone?: (reason: string, postponeInfo: PostponeInfo) => void,
165164
};
166165

167166
function resumeAndPrerender(

0 commit comments

Comments
 (0)