@samitouri / QOS-React-2 / commits / 14c2be8dac

Rename Node SSR Callbacks to onShellReady/onAllReady and Other Fixes (#24030)

* I forgot to call onFatalError I can't figure out how to write a test for this because it only happens when there is a bug in React itself which would then be fixed if we found it. We're also covered by the protection of ReadableStream which doesn't leak other errors to us. * Abort requests if the reader cancels No need to continue computing at this point. * Abort requests if node streams get destroyed This is if the downstream cancels is for example. * Rename Node APIs for Parity with allReady The "Complete" terminology is a little misleading because not everything has been written yet. It's just "Ready" to be written now. onShellReady onShellError onAllReady * 'close' should be enough

Sebastian Markbåge committed Mar 4, 2022 at 14:38 UTC 14c2be8dac2d5482fda8a0906a31d239df8551fc
12 files changed +151 -58
fixtures/ssr/server/render.js
+2 -2
@@ -22,13 +22,13 @@ export default function render(url, res) {
22 let didError = false;
23 const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
24 bootstrapScripts: [assets['main.js']],
25 - onCompleteShell() {
25 + onShellReady() {
26 // If something errored before we started streaming, we set the error code appropriately.
27 res.statusCode = didError ? 500 : 200;
28 res.setHeader('Content-type', 'text/html');
29 pipe(res);
30 },
31 - onErrorShell(x) {
31 + onShellError(x) {
32 // Something errored before we could complete the shell so we emit an alternative shell.
33 res.statusCode = 500;
34 res.send('<!doctype><p>Error</p>');
fixtures/ssr2/server/render.js
+1 -1
@@ -49,7 +49,7 @@ module.exports = function render(url, res) {
49 res.setHeader('Content-type', 'text/html');
50 pipe(res);
51 },
52 - onErrorShell(x) {
52 + onShellError(x) {
53 // Something errored before we could complete the shell so we emit an alternative shell.
54 res.statusCode = 500;
55 res.send('<!doctype><p>Error</p>');
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+3 -3
@@ -914,7 +914,7 @@ describe('ReactDOMFizzServer', () => {
914 </Suspense>,
915 {
916 identifierPrefix: 'A_',
917 - onCompleteShell() {
917 + onShellReady() {
918 writableA.write('<div id="container-A">');
919 pipe(writableA);
920 writableA.write('</div>');
@@ -933,7 +933,7 @@ describe('ReactDOMFizzServer', () => {
933 </Suspense>,
934 {
935 identifierPrefix: 'B_',
936 - onCompleteShell() {
936 + onShellReady() {
937 writableB.write('<div id="container-B">');
938 pipe(writableB);
939 writableB.write('</div>');
@@ -1168,7 +1168,7 @@ describe('ReactDOMFizzServer', () => {
1168
1169 {
1170 namespaceURI: 'http://www.w3.org/2000/svg',
1171 - onCompleteShell() {
1171 + onShellReady() {
1172 writable.write('<svg>');
1173 pipe(writable);
1174 writable.write('</svg>');
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+39
@@ -209,4 +209,43 @@ describe('ReactDOMFizzServer', () => {
209 const result = await readResult(stream);
210 expect(result).toContain('Loading');
211 });
212 +
213 + // @gate experimental
214 + it('should not continue rendering after the reader cancels', async () => {
215 + let hasLoaded = false;
216 + let resolve;
217 + let isComplete = false;
218 + let rendered = false;
219 + const promise = new Promise(r => (resolve = r));
220 + function Wait() {
221 + if (!hasLoaded) {
222 + throw promise;
223 + }
224 + rendered = true;
225 + return 'Done';
226 + }
227 + const stream = await ReactDOMFizzServer.renderToReadableStream(
228 + <div>
229 + <Suspense fallback={<div>Loading</div>}>
230 + <Wait /> />
231 + </Suspense>
232 + </div>,
233 + );
234 +
235 + stream.allReady.then(() => (isComplete = true));
236 +
237 + expect(rendered).toBe(false);
238 + expect(isComplete).toBe(false);
239 +
240 + const reader = stream.getReader();
241 + reader.cancel();
242 +
243 + hasLoaded = true;
244 + resolve();
245 +
246 + await jest.runAllTimers();
247 +
248 + expect(rendered).toBe(false);
249 + expect(isComplete).toBe(true);
250 + });
251 });
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+51 -6
@@ -138,7 +138,7 @@ describe('ReactDOMFizzServer', () => {
138 </div>,
139
140 {
141 - onCompleteAll() {
141 + onAllReady() {
142 isCompleteCalls++;
143 },
144 },
@@ -179,7 +179,7 @@ describe('ReactDOMFizzServer', () => {
179 onError(x) {
180 reportedErrors.push(x);
181 },
182 - onErrorShell(x) {
182 + onShellError(x) {
183 reportedShellErrors.push(x);
184 },
185 },
@@ -213,7 +213,7 @@ describe('ReactDOMFizzServer', () => {
213 onError(x) {
214 reportedErrors.push(x);
215 },
216 - onErrorShell(x) {
216 + onShellError(x) {
217 reportedShellErrors.push(x);
218 },
219 },
@@ -244,7 +244,7 @@ describe('ReactDOMFizzServer', () => {
244 onError(x) {
245 reportedErrors.push(x);
246 },
247 - onErrorShell(x) {
247 + onShellError(x) {
248 reportedShellErrors.push(x);
249 },
250 },
@@ -298,7 +298,7 @@ describe('ReactDOMFizzServer', () => {
298 </div>,
299
300 {
301 - onCompleteAll() {
301 + onAllReady() {
302 isCompleteCalls++;
303 },
304 },
@@ -333,7 +333,7 @@ describe('ReactDOMFizzServer', () => {
333 </div>,
334
335 {
336 - onCompleteAll() {
336 + onAllReady() {
337 isCompleteCalls++;
338 },
339 },
@@ -537,4 +537,49 @@ describe('ReactDOMFizzServer', () => {
537 expect(output.result).not.toContain('context never found');
538 expect(output.result).toContain('OK');
539 });
540 +
541 + // @gate experimental
542 + it('should not continue rendering after the writable ends unexpectedly', async () => {
543 + let hasLoaded = false;
544 + let resolve;
545 + let isComplete = false;
546 + let rendered = false;
547 + const promise = new Promise(r => (resolve = r));
548 + function Wait() {
549 + if (!hasLoaded) {
550 + throw promise;
551 + }
552 + rendered = true;
553 + return 'Done';
554 + }
555 + const {writable, completed} = getTestWritable();
556 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
557 + <div>
558 + <Suspense fallback={<div>Loading</div>}>
559 + <Wait />
560 + </Suspense>
561 + </div>,
562 + {
563 + onAllReady() {
564 + isComplete = true;
565 + },
566 + },
567 + );
568 + pipe(writable);
569 +
570 + expect(rendered).toBe(false);
571 + expect(isComplete).toBe(false);
572 +
573 + writable.end();
574 +
575 + await jest.runAllTimers();
576 +
577 + hasLoaded = true;
578 + resolve();
579 +
580 + await completed;
581 +
582 + expect(rendered).toBe(false);
583 + expect(isComplete).toBe(true);
584 + });
585 });
packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js
+1 -1
@@ -155,7 +155,7 @@ module.exports = function(initModules) {
155 new Promise((resolve, reject) => {
156 const writable = new DrainWritable();
157 const s = ReactDOMServer.renderToPipeableStream(reactElement, {
158 - onErrorShell(e) {
158 + onShellError(e) {
159 reject(e);
160 },
161 });
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+10 -8
@@ -46,25 +46,27 @@ function renderToReadableStream(
46 ): Promise<ReactDOMServerReadableStream> {
47 return new Promise((resolve, reject) => {
48 let onFatalError;
49 - let onCompleteAll;
49 + let onAllReady;
50 const allReady = new Promise((res, rej) => {
51 - onCompleteAll = res;
51 + onAllReady = res;
52 onFatalError = rej;
53 });
54
55 - function onCompleteShell() {
55 + function onShellReady() {
56 const stream: ReactDOMServerReadableStream = (new ReadableStream({
57 type: 'bytes',
58 pull(controller) {
59 startFlowing(request, controller);
60 },
61 - cancel(reason) {},
61 + cancel(reason) {
62 + abort(request);
63 + },
64 }): any);
65 // TODO: Move to sub-classing ReadableStream.
66 stream.allReady = allReady;
67 resolve(stream);
68 }
67 - function onErrorShell(error: mixed) {
69 + function onShellError(error: mixed) {
70 reject(error);
71 }
72 const request = createRequest(
@@ -79,9 +81,9 @@ function renderToReadableStream(
81 createRootFormatContext(options ? options.namespaceURI : undefined),
82 options ? options.progressiveChunkSize : undefined,
83 options ? options.onError : undefined,
82 - onCompleteAll,
83 - onCompleteShell,
84 - onErrorShell,
84 + onAllReady,
85 + onShellReady,
86 + onShellError,
87 onFatalError,
88 );
89 if (options && options.signal) {
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+11 -6
@@ -28,6 +28,10 @@ function createDrainHandler(destination, request) {
28 return () => startFlowing(request, destination);
29 }
30
31 +function createAbortHandler(request) {
32 + return () => abort(request);
33 +}
34 +
35 type Options = {|
36 identifierPrefix?: string,
37 namespaceURI?: string,
@@ -36,9 +40,9 @@ type Options = {|
40 bootstrapScripts?: Array<string>,
41 bootstrapModules?: Array<string>,
42 progressiveChunkSize?: number,
39 - onCompleteShell?: () => void,
40 - onErrorShell?: () => void,
41 - onCompleteAll?: () => void,
43 + onShellReady?: () => void,
44 + onShellError?: () => void,
45 + onAllReady?: () => void,
46 onError?: (error: mixed) => void,
47 |};
48
@@ -62,9 +66,9 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
66 createRootFormatContext(options ? options.namespaceURI : undefined),
67 options ? options.progressiveChunkSize : undefined,
68 options ? options.onError : undefined,
65 - options ? options.onCompleteAll : undefined,
66 - options ? options.onCompleteShell : undefined,
67 - options ? options.onErrorShell : undefined,
69 + options ? options.onAllReady : undefined,
70 + options ? options.onShellReady : undefined,
71 + options ? options.onShellError : undefined,
72 undefined,
73 );
74 }
@@ -86,6 +90,7 @@ function renderToPipeableStream(
90 hasStartedFlowing = true;
91 startFlowing(request, destination);
92 destination.on('drain', createDrainHandler(destination, request));
93 + destination.on('close', createAbortHandler(request));
94 return destination;
95 },
96 abort() {
packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js
+2 -2
@@ -53,7 +53,7 @@ function renderToStringImpl(
53 };
54
55 let readyToStream = false;
56 - function onCompleteShell() {
56 + function onShellReady() {
57 readyToStream = true;
58 }
59 const request = createRequest(
@@ -66,7 +66,7 @@ function renderToStringImpl(
66 Infinity,
67 onError,
68 undefined,
69 - onCompleteShell,
69 + onShellReady,
70 undefined,
71 undefined,
72 );
packages/react-dom/src/server/ReactDOMLegacyServerNode.js
+2 -2
@@ -68,7 +68,7 @@ function renderToNodeStreamImpl(
68 options: void | ServerOptions,
69 generateStaticMarkup: boolean,
70 ): Readable {
71 - function onCompleteAll() {
71 + function onAllReady() {
72 // We wait until everything has loaded before starting to write.
73 // That way we only end up with fully resolved HTML even if we suspend.
74 destination.startedFlowing = true;
@@ -81,7 +81,7 @@ function renderToNodeStreamImpl(
81 createRootFormatContext(),
82 Infinity,
83 onError,
84 - onCompleteAll,
84 + onAllReady,
85 undefined,
86 undefined,
87 );
packages/react-noop-renderer/src/ReactNoopServer.js
+4 -4
@@ -251,8 +251,8 @@ const ReactNoopServer = ReactFizzServer({
251
252 type Options = {
253 progressiveChunkSize?: number,
254 - onCompleteShell?: () => void,
255 - onCompleteAll?: () => void,
254 + onShellReady?: () => void,
255 + onAllReady?: () => void,
256 onError?: (error: mixed) => void,
257 };
258
@@ -272,8 +272,8 @@ function render(children: React$Element<any>, options?: Options): Destination {
272 null,
273 options ? options.progressiveChunkSize : undefined,
274 options ? options.onError : undefined,
275 - options ? options.onCompleteAll : undefined,
276 - options ? options.onCompleteShell : undefined,
275 + options ? options.onAllReady : undefined,
276 + options ? options.onShellReady : undefined,
277 );
278 ReactNoopServer.startWork(request);
279 ReactNoopServer.startFlowing(request, destination);
packages/react-server/src/ReactFizzServer.js
+25 -23
@@ -194,16 +194,16 @@ export opaque type Request = {
194 partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early.
195 // onError is called when an error happens anywhere in the tree. It might recover.
196 onError: (error: mixed) => void,
197 - // onCompleteAll is called when all pending task is done but it may not have flushed yet.
197 + // onAllReady is called when all pending task is done but it may not have flushed yet.
198 // This is a good time to start writing if you want only HTML and no intermediate steps.
199 - onCompleteAll: () => void,
200 - // onCompleteShell is called when there is at least a root fallback ready to show.
199 + onAllReady: () => void,
200 + // onShellReady is called when there is at least a root fallback ready to show.
201 // Typically you don't need this callback because it's best practice to always have a
202 // root fallback ready so there's no need to wait.
203 - onCompleteShell: () => void,
204 - // onErrorShell is called when the shell didn't complete. That means you probably want to
203 + onShellReady: () => void,
204 + // onShellError is called when the shell didn't complete. That means you probably want to
205 // emit a different response to the stream instead.
206 - onErrorShell: (error: mixed) => void,
206 + onShellError: (error: mixed) => void,
207 onFatalError: (error: mixed) => void,
208 };
209
@@ -236,9 +236,9 @@ export function createRequest(
236 rootFormatContext: FormatContext,
237 progressiveChunkSize: void | number,
238 onError: void | ((error: mixed) => void),
239 - onCompleteAll: void | (() => void),
240 - onCompleteShell: void | (() => void),
241 - onErrorShell: void | ((error: mixed) => void),
239 + onAllReady: void | (() => void),
240 + onShellReady: void | (() => void),
241 + onShellError: void | ((error: mixed) => void),
242 onFatalError: void | ((error: mixed) => void),
243 ): Request {
244 const pingedTasks = [];
@@ -262,9 +262,9 @@ export function createRequest(
262 completedBoundaries: [],
263 partialBoundaries: [],
264 onError: onError === undefined ? defaultErrorHandler : onError,
265 - onCompleteAll: onCompleteAll === undefined ? noop : onCompleteAll,
266 - onCompleteShell: onCompleteShell === undefined ? noop : onCompleteShell,
267 - onErrorShell: onErrorShell === undefined ? noop : onErrorShell,
265 + onAllReady: onAllReady === undefined ? noop : onAllReady,
266 + onShellReady: onShellReady === undefined ? noop : onShellReady,
267 + onShellError: onShellError === undefined ? noop : onShellError,
268 onFatalError: onFatalError === undefined ? noop : onFatalError,
269 };
270 // This segment represents the root fallback.
@@ -422,8 +422,10 @@ function fatalError(request: Request, error: mixed): void {
422 // This is called outside error handling code such as if the root errors outside
423 // a suspense boundary or if the root suspense boundary's fallback errors.
424 // It's also called if React itself or its host configs errors.
425 - const onErrorShell = request.onErrorShell;
426 - onErrorShell(error);
425 + const onShellError = request.onShellError;
426 + onShellError(error);
427 + const onFatalError = request.onFatalError;
428 + onFatalError(error);
429 if (request.destination !== null) {
430 request.status = CLOSED;
431 closeWithError(request.destination, error);
@@ -1371,8 +1373,8 @@ function erroredTask(
1373
1374 request.allPendingTasks--;
1375 if (request.allPendingTasks === 0) {
1374 - const onCompleteAll = request.onCompleteAll;
1375 - onCompleteAll();
1376 + const onAllReady = request.onAllReady;
1377 + onAllReady();
1378 }
1379 }
1380
@@ -1422,8 +1424,8 @@ function abortTask(task: Task): void {
1424
1425 request.allPendingTasks--;
1426 if (request.allPendingTasks === 0) {
1425 - const onCompleteAll = request.onCompleteAll;
1426 - onCompleteAll();
1427 + const onAllReady = request.onAllReady;
1428 + onAllReady();
1429 }
1430 }
1431 }
@@ -1446,9 +1448,9 @@ function finishedTask(
1448 request.pendingRootTasks--;
1449 if (request.pendingRootTasks === 0) {
1450 // We have completed the shell so the shell can't error anymore.
1449 - request.onErrorShell = noop;
1450 - const onCompleteShell = request.onCompleteShell;
1451 - onCompleteShell();
1451 + request.onShellError = noop;
1452 + const onShellReady = request.onShellReady;
1453 + onShellReady();
1454 }
1455 } else {
1456 boundary.pendingTasks--;
@@ -1499,8 +1501,8 @@ function finishedTask(
1501 if (request.allPendingTasks === 0) {
1502 // This needs to be called at the very end so that we can synchronously write the result
1503 // in the callback if needed.
1502 - const onCompleteAll = request.onCompleteAll;
1503 - onCompleteAll();
1504 + const onAllReady = request.onAllReady;
1505 + onAllReady();
1506 }
1507 }
1508