From f4541c307faae036593c88bae7e949bc4173a8f2 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 3 Mar 2022 18:09:45 -0500 Subject: [PATCH 1/5] 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. --- packages/react-server/src/ReactFizzServer.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 306926503d7..f302da1e570 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -424,6 +424,8 @@ function fatalError(request: Request, error: mixed): void { // It's also called if React itself or its host configs errors. const onErrorShell = request.onErrorShell; onErrorShell(error); + const onFatalError = request.onFatalError; + onFatalError(error); if (request.destination !== null) { request.status = CLOSED; closeWithError(request.destination, error); From d3efbe6dc0c77be6cdadbbfb2f16dfd3a9340677 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 3 Mar 2022 18:25:00 -0500 Subject: [PATCH 2/5] Abort requests if the reader cancels No need to continue computing at this point. --- .../ReactDOMFizzServerBrowser-test.js | 39 +++++++++++++++++++ .../src/server/ReactDOMFizzServerBrowser.js | 4 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js index 93e30aa2305..ad6176e3fa5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js @@ -209,4 +209,43 @@ describe('ReactDOMFizzServer', () => { const result = await readResult(stream); expect(result).toContain('Loading'); }); + + // @gate experimental + it('should not continue rendering after the reader cancels', async () => { + let hasLoaded = false; + let resolve; + let isComplete = false; + let rendered = false; + const promise = new Promise(r => (resolve = r)); + function Wait() { + if (!hasLoaded) { + throw promise; + } + rendered = true; + return 'Done'; + } + const stream = await ReactDOMFizzServer.renderToReadableStream( +
+ Loading
}> + /> + + , + ); + + stream.allReady.then(() => (isComplete = true)); + + expect(rendered).toBe(false); + expect(isComplete).toBe(false); + + const reader = stream.getReader(); + reader.cancel(); + + hasLoaded = true; + resolve(); + + await jest.runAllTimers(); + + expect(rendered).toBe(false); + expect(isComplete).toBe(true); + }); }); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index bf90682d3c6..0bca5a2451a 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -58,7 +58,9 @@ function renderToReadableStream( pull(controller) { startFlowing(request, controller); }, - cancel(reason) {}, + cancel(reason) { + abort(request); + }, }): any); // TODO: Move to sub-classing ReadableStream. stream.allReady = allReady; From 93caabe5a72671157d7a69ec9061f5b62749e1ab Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 3 Mar 2022 19:10:46 -0500 Subject: [PATCH 3/5] Abort requests if node streams get destroyed This is if the downstream cancels is for example. --- .../__tests__/ReactDOMFizzServerNode-test.js | 45 +++++++++++++++++++ .../src/server/ReactDOMFizzServerNode.js | 7 +++ 2 files changed, 52 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js index a35a250282e..05aef9dab62 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js @@ -537,4 +537,49 @@ describe('ReactDOMFizzServer', () => { expect(output.result).not.toContain('context never found'); expect(output.result).toContain('OK'); }); + + // @gate experimental + it('should not continue rendering after the writable ends unexpectedly', async () => { + let hasLoaded = false; + let resolve; + let isComplete = false; + let rendered = false; + const promise = new Promise(r => (resolve = r)); + function Wait() { + if (!hasLoaded) { + throw promise; + } + rendered = true; + return 'Done'; + } + const {writable, completed} = getTestWritable(); + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( +
+ Loading
}> + + + , + { + onCompleteAll() { + isComplete = true; + }, + }, + ); + pipe(writable); + + expect(rendered).toBe(false); + expect(isComplete).toBe(false); + + writable.end(); + + await jest.runAllTimers(); + + hasLoaded = true; + resolve(); + + await completed; + + expect(rendered).toBe(false); + expect(isComplete).toBe(true); + }); }); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index 39126ed8d42..c269fd39de8 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -28,6 +28,10 @@ function createDrainHandler(destination, request) { return () => startFlowing(request, destination); } +function createAbortHandler(request) { + return () => abort(request); +} + type Options = {| identifierPrefix?: string, namespaceURI?: string, @@ -86,6 +90,9 @@ function renderToPipeableStream( hasStartedFlowing = true; startFlowing(request, destination); destination.on('drain', createDrainHandler(destination, request)); + const abortHandler = createAbortHandler(request); + destination.on('end', abortHandler); + destination.on('error', abortHandler); return destination; }, abort() { From 692734545f0767f734ddbbc4269f3a2637ea2900 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 3 Mar 2022 19:17:53 -0500 Subject: [PATCH 4/5] 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 --- fixtures/ssr/server/render.js | 4 +- fixtures/ssr2/server/render.js | 2 +- .../src/__tests__/ReactDOMFizzServer-test.js | 6 +-- .../__tests__/ReactDOMFizzServerNode-test.js | 14 +++--- .../ReactDOMServerIntegrationTestUtils.js | 2 +- .../src/server/ReactDOMFizzServerBrowser.js | 14 +++--- .../src/server/ReactDOMFizzServerNode.js | 12 ++--- .../src/server/ReactDOMLegacyServerBrowser.js | 4 +- .../src/server/ReactDOMLegacyServerNode.js | 4 +- .../src/ReactNoopServer.js | 8 ++-- packages/react-server/src/ReactFizzServer.js | 46 +++++++++---------- 11 files changed, 58 insertions(+), 58 deletions(-) diff --git a/fixtures/ssr/server/render.js b/fixtures/ssr/server/render.js index ae30e622174..a4fe698858a 100644 --- a/fixtures/ssr/server/render.js +++ b/fixtures/ssr/server/render.js @@ -22,13 +22,13 @@ export default function render(url, res) { let didError = false; const {pipe, abort} = renderToPipeableStream(, { bootstrapScripts: [assets['main.js']], - onCompleteShell() { + onShellReady() { // If something errored before we started streaming, we set the error code appropriately. res.statusCode = didError ? 500 : 200; res.setHeader('Content-type', 'text/html'); pipe(res); }, - onErrorShell(x) { + onShellError(x) { // Something errored before we could complete the shell so we emit an alternative shell. res.statusCode = 500; res.send('

Error

'); diff --git a/fixtures/ssr2/server/render.js b/fixtures/ssr2/server/render.js index ccd12af0d36..ea2b6e4696a 100644 --- a/fixtures/ssr2/server/render.js +++ b/fixtures/ssr2/server/render.js @@ -49,7 +49,7 @@ module.exports = function render(url, res) { res.setHeader('Content-type', 'text/html'); pipe(res); }, - onErrorShell(x) { + onShellError(x) { // Something errored before we could complete the shell so we emit an alternative shell. res.statusCode = 500; res.send('

Error

'); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 90b339bb281..0aacf5a7552 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -914,7 +914,7 @@ describe('ReactDOMFizzServer', () => { , { identifierPrefix: 'A_', - onCompleteShell() { + onShellReady() { writableA.write('
'); pipe(writableA); writableA.write('
'); @@ -933,7 +933,7 @@ describe('ReactDOMFizzServer', () => { , { identifierPrefix: 'B_', - onCompleteShell() { + onShellReady() { writableB.write('
'); pipe(writableB); writableB.write('
'); @@ -1168,7 +1168,7 @@ describe('ReactDOMFizzServer', () => { { namespaceURI: 'http://www.w3.org/2000/svg', - onCompleteShell() { + onShellReady() { writable.write(''); pipe(writable); writable.write(''); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js index 05aef9dab62..c1844d7ef78 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js @@ -138,7 +138,7 @@ describe('ReactDOMFizzServer', () => { , { - onCompleteAll() { + onAllReady() { isCompleteCalls++; }, }, @@ -179,7 +179,7 @@ describe('ReactDOMFizzServer', () => { onError(x) { reportedErrors.push(x); }, - onErrorShell(x) { + onShellError(x) { reportedShellErrors.push(x); }, }, @@ -213,7 +213,7 @@ describe('ReactDOMFizzServer', () => { onError(x) { reportedErrors.push(x); }, - onErrorShell(x) { + onShellError(x) { reportedShellErrors.push(x); }, }, @@ -244,7 +244,7 @@ describe('ReactDOMFizzServer', () => { onError(x) { reportedErrors.push(x); }, - onErrorShell(x) { + onShellError(x) { reportedShellErrors.push(x); }, }, @@ -298,7 +298,7 @@ describe('ReactDOMFizzServer', () => { , { - onCompleteAll() { + onAllReady() { isCompleteCalls++; }, }, @@ -333,7 +333,7 @@ describe('ReactDOMFizzServer', () => { , { - onCompleteAll() { + onAllReady() { isCompleteCalls++; }, }, @@ -560,7 +560,7 @@ describe('ReactDOMFizzServer', () => { , { - onCompleteAll() { + onAllReady() { isComplete = true; }, }, diff --git a/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js b/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js index 9f23d7aa96a..f03825c16d4 100644 --- a/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js +++ b/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js @@ -155,7 +155,7 @@ module.exports = function(initModules) { new Promise((resolve, reject) => { const writable = new DrainWritable(); const s = ReactDOMServer.renderToPipeableStream(reactElement, { - onErrorShell(e) { + onShellError(e) { reject(e); }, }); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index 0bca5a2451a..219c4a1bf7b 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -46,13 +46,13 @@ function renderToReadableStream( ): Promise { return new Promise((resolve, reject) => { let onFatalError; - let onCompleteAll; + let onAllReady; const allReady = new Promise((res, rej) => { - onCompleteAll = res; + onAllReady = res; onFatalError = rej; }); - function onCompleteShell() { + function onShellReady() { const stream: ReactDOMServerReadableStream = (new ReadableStream({ type: 'bytes', pull(controller) { @@ -66,7 +66,7 @@ function renderToReadableStream( stream.allReady = allReady; resolve(stream); } - function onErrorShell(error: mixed) { + function onShellError(error: mixed) { reject(error); } const request = createRequest( @@ -81,9 +81,9 @@ function renderToReadableStream( createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, - onCompleteAll, - onCompleteShell, - onErrorShell, + onAllReady, + onShellReady, + onShellError, onFatalError, ); if (options && options.signal) { diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index c269fd39de8..b885fe95abc 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -40,9 +40,9 @@ type Options = {| bootstrapScripts?: Array, bootstrapModules?: Array, progressiveChunkSize?: number, - onCompleteShell?: () => void, - onErrorShell?: () => void, - onCompleteAll?: () => void, + onShellReady?: () => void, + onShellError?: () => void, + onAllReady?: () => void, onError?: (error: mixed) => void, |}; @@ -66,9 +66,9 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) { createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, - options ? options.onCompleteAll : undefined, - options ? options.onCompleteShell : undefined, - options ? options.onErrorShell : undefined, + options ? options.onAllReady : undefined, + options ? options.onShellReady : undefined, + options ? options.onShellError : undefined, undefined, ); } diff --git a/packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js b/packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js index 778806c9703..168f38fc59d 100644 --- a/packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js @@ -53,7 +53,7 @@ function renderToStringImpl( }; let readyToStream = false; - function onCompleteShell() { + function onShellReady() { readyToStream = true; } const request = createRequest( @@ -66,7 +66,7 @@ function renderToStringImpl( Infinity, onError, undefined, - onCompleteShell, + onShellReady, undefined, undefined, ); diff --git a/packages/react-dom/src/server/ReactDOMLegacyServerNode.js b/packages/react-dom/src/server/ReactDOMLegacyServerNode.js index 7840708c5ad..f5c6aa1f460 100644 --- a/packages/react-dom/src/server/ReactDOMLegacyServerNode.js +++ b/packages/react-dom/src/server/ReactDOMLegacyServerNode.js @@ -68,7 +68,7 @@ function renderToNodeStreamImpl( options: void | ServerOptions, generateStaticMarkup: boolean, ): Readable { - function onCompleteAll() { + function onAllReady() { // We wait until everything has loaded before starting to write. // That way we only end up with fully resolved HTML even if we suspend. destination.startedFlowing = true; @@ -81,7 +81,7 @@ function renderToNodeStreamImpl( createRootFormatContext(), Infinity, onError, - onCompleteAll, + onAllReady, undefined, undefined, ); diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 94815d57f9a..5bd7e79cf72 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -251,8 +251,8 @@ const ReactNoopServer = ReactFizzServer({ type Options = { progressiveChunkSize?: number, - onCompleteShell?: () => void, - onCompleteAll?: () => void, + onShellReady?: () => void, + onAllReady?: () => void, onError?: (error: mixed) => void, }; @@ -272,8 +272,8 @@ function render(children: React$Element, options?: Options): Destination { null, options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, - options ? options.onCompleteAll : undefined, - options ? options.onCompleteShell : undefined, + options ? options.onAllReady : undefined, + options ? options.onShellReady : undefined, ); ReactNoopServer.startWork(request); ReactNoopServer.startFlowing(request, destination); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index f302da1e570..7a238391493 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -194,16 +194,16 @@ export opaque type Request = { partialBoundaries: Array, // Partially completed boundaries that can flush its segments early. // onError is called when an error happens anywhere in the tree. It might recover. onError: (error: mixed) => void, - // onCompleteAll is called when all pending task is done but it may not have flushed yet. + // onAllReady is called when all pending task is done but it may not have flushed yet. // This is a good time to start writing if you want only HTML and no intermediate steps. - onCompleteAll: () => void, - // onCompleteShell is called when there is at least a root fallback ready to show. + onAllReady: () => void, + // onShellReady is called when there is at least a root fallback ready to show. // Typically you don't need this callback because it's best practice to always have a // root fallback ready so there's no need to wait. - onCompleteShell: () => void, - // onErrorShell is called when the shell didn't complete. That means you probably want to + onShellReady: () => void, + // onShellError is called when the shell didn't complete. That means you probably want to // emit a different response to the stream instead. - onErrorShell: (error: mixed) => void, + onShellError: (error: mixed) => void, onFatalError: (error: mixed) => void, }; @@ -236,9 +236,9 @@ export function createRequest( rootFormatContext: FormatContext, progressiveChunkSize: void | number, onError: void | ((error: mixed) => void), - onCompleteAll: void | (() => void), - onCompleteShell: void | (() => void), - onErrorShell: void | ((error: mixed) => void), + onAllReady: void | (() => void), + onShellReady: void | (() => void), + onShellError: void | ((error: mixed) => void), onFatalError: void | ((error: mixed) => void), ): Request { const pingedTasks = []; @@ -262,9 +262,9 @@ export function createRequest( completedBoundaries: [], partialBoundaries: [], onError: onError === undefined ? defaultErrorHandler : onError, - onCompleteAll: onCompleteAll === undefined ? noop : onCompleteAll, - onCompleteShell: onCompleteShell === undefined ? noop : onCompleteShell, - onErrorShell: onErrorShell === undefined ? noop : onErrorShell, + onAllReady: onAllReady === undefined ? noop : onAllReady, + onShellReady: onShellReady === undefined ? noop : onShellReady, + onShellError: onShellError === undefined ? noop : onShellError, onFatalError: onFatalError === undefined ? noop : onFatalError, }; // This segment represents the root fallback. @@ -422,8 +422,8 @@ function fatalError(request: Request, error: mixed): void { // This is called outside error handling code such as if the root errors outside // a suspense boundary or if the root suspense boundary's fallback errors. // It's also called if React itself or its host configs errors. - const onErrorShell = request.onErrorShell; - onErrorShell(error); + const onShellError = request.onShellError; + onShellError(error); const onFatalError = request.onFatalError; onFatalError(error); if (request.destination !== null) { @@ -1373,8 +1373,8 @@ function erroredTask( request.allPendingTasks--; if (request.allPendingTasks === 0) { - const onCompleteAll = request.onCompleteAll; - onCompleteAll(); + const onAllReady = request.onAllReady; + onAllReady(); } } @@ -1424,8 +1424,8 @@ function abortTask(task: Task): void { request.allPendingTasks--; if (request.allPendingTasks === 0) { - const onCompleteAll = request.onCompleteAll; - onCompleteAll(); + const onAllReady = request.onAllReady; + onAllReady(); } } } @@ -1448,9 +1448,9 @@ function finishedTask( request.pendingRootTasks--; if (request.pendingRootTasks === 0) { // We have completed the shell so the shell can't error anymore. - request.onErrorShell = noop; - const onCompleteShell = request.onCompleteShell; - onCompleteShell(); + request.onShellError = noop; + const onShellReady = request.onShellReady; + onShellReady(); } } else { boundary.pendingTasks--; @@ -1501,8 +1501,8 @@ function finishedTask( if (request.allPendingTasks === 0) { // This needs to be called at the very end so that we can synchronously write the result // in the callback if needed. - const onCompleteAll = request.onCompleteAll; - onCompleteAll(); + const onAllReady = request.onAllReady; + onAllReady(); } } From 15bd2716e88ac38a8414155fb9258b0c67b0c81d Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 4 Mar 2022 10:39:33 -0800 Subject: [PATCH 5/5] 'close' should be enough --- packages/react-dom/src/server/ReactDOMFizzServerNode.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index b885fe95abc..306724c448b 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -90,9 +90,7 @@ function renderToPipeableStream( hasStartedFlowing = true; startFlowing(request, destination); destination.on('drain', createDrainHandler(destination, request)); - const abortHandler = createAbortHandler(request); - destination.on('end', abortHandler); - destination.on('error', abortHandler); + destination.on('close', createAbortHandler(request)); return destination; }, abort() {