From f4b7800f92b134373317d1a9b19f49faf83016e2 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 4 Jun 2024 08:11:08 -0700 Subject: [PATCH] [Flight] Allow aborting during render Previously if you aborted during a render the currently rendering task would itself be aborted which will cause the entire model to be replaced by the aborted error rather than just the slot currently being rendered. This change updates the abort logic to mark currently rendering tasks as aborted but allowing the current render to emit a partially serialized model with an error reference in place of the current model. The intent is to support aborting from rendering synchronously, in microtasks (after an await or in a .then) and in lazy initializers. We don't specifically support aborting from things like proxies that might be triggered during serialization of props --- .../src/__tests__/ReactFlightDOM-test.js | 361 +++++++++++++++++- .../react-server/src/ReactFlightServer.js | 49 ++- 2 files changed, 389 insertions(+), 21 deletions(-) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 1ead6efe4b25a..be672958682ea 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -36,6 +36,7 @@ let ErrorBoundary; let JSDOM; let ReactServerScheduler; let reactServerAct; +let assertConsoleErrorDev; describe('ReactFlightDOM', () => { beforeEach(() => { @@ -70,6 +71,8 @@ describe('ReactFlightDOM', () => { __unmockReact(); jest.resetModules(); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; Stream = require('stream'); React = require('react'); use = React.use; @@ -107,6 +110,38 @@ describe('ReactFlightDOM', () => { return maybePromise; } + async function readInto( + container: Document | HTMLElement, + stream: ReadableStream, + ) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let content = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + content += decoder.decode(); + break; + } + content += decoder.decode(value, {stream: true}); + } + if (container.nodeType === 9 /* DOCUMENT */) { + const doc = new JSDOM(content).window.document; + container.documentElement.innerHTML = doc.documentElement.innerHTML; + while (container.documentElement.attributes.length > 0) { + container.documentElement.removeAttribute( + container.documentElement.attributes[0].name, + ); + } + const attrs = doc.documentElement.attributes; + for (let i = 0; i < attrs.length; i++) { + container.documentElement.setAttribute(attrs[i].name, attrs[i].value); + } + } else { + container.innerHTML = content; + } + } + function getTestStream() { const writable = new Stream.PassThrough(); const readable = new ReadableStream({ @@ -1633,20 +1668,8 @@ describe('ReactFlightDOM', () => { ReactDOMFizzServer.renderToPipeableStream().pipe(fizzWritable); }); - const decoder = new TextDecoder(); - const reader = fizzReadable.getReader(); - let content = ''; - while (true) { - const {done, value} = await reader.read(); - if (done) { - content += decoder.decode(); - break; - } - content += decoder.decode(value, {stream: true}); - } - - const doc = new JSDOM(content).window.document; - expect(getMeaningfulChildren(doc)).toEqual( + await readInto(document, fizzReadable); + expect(getMeaningfulChildren(document)).toEqual( @@ -1912,4 +1935,314 @@ describe('ReactFlightDOM', () => { }); expect(container.innerHTML).toBe('Hello World'); }); + + fit('can abort synchronously during render', async () => { + let siblingDidRender = false; + function Sibling() { + siblingDidRender = true; + return

sibling

; + } + + function App() { + return ( + loading...

}> + + +
+ +
+
+ ); + } + + const abortRef = {current: null}; + function ComponentThatAborts() { + abortRef.current(); + return

hello world

; + } + + let {writable: flightWritable, readable: flightReadable} = getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const [s1, s2] = flightReadable.tee(); + flightReadable = s1; + let reader2 = s2.getReader(); + let decoder = new TextDecoder(); + while (true) { + const {done, value} = await reader2.read(); + if (done) { + break; + } + console.log(decoder.decode(value, {stream: true})); + } + + expect(siblingDidRender).toBe(false); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + onError(x) { + console.log('erroring', x); + }, + }, + ).pipe(fizzWritable); + }); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual(

loading...

); + }); + + it('can abort during render in an async tick', async () => { + let siblingDidRender = false; + function DidRender({children}) { + siblingDidRender = true; + } + + async function Sibling() { + return ( + +

sibling

+
+ ); + } + + function App() { + return ( + loading...

}> + + +
+ ); + } + + const abortRef = {current: null}; + async function ComponentThatAborts() { + await 1; + abortRef.current(); + return

hello world

; + } + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + expect(siblingDidRender).toBe(false); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual(

loading...

); + }); + + it('can abort during render in a lazy initializer for a component', async () => { + let siblingDidRender = false; + + function Sibling() { + siblingDidRender = true; + return

sibling

; + } + + function App() { + return ( + loading...

}> + + +
+ ); + } + + const abortRef = {current: null}; + const LazyAbort = React.lazy(() => { + abortRef.current(); + return Promise.resolve({ + default: function LazyComponent() { + return

hello world

; + }, + }); + }); + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + expect(siblingDidRender).toBe(false); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual(

loading...

); + }); + + it('can abort during render in a lazy initializer for an element', async () => { + let siblingDidRender = false; + + function Sibling() { + siblingDidRender = true; + return

sibling

; + } + + function App() { + return ( + loading...

}> + {lazyAbort} + +
+ ); + } + + const abortRef = {current: null}; + const lazyAbort = React.lazy(() => { + abortRef.current(); + return Promise.resolve({ + default:

hello world

, + }); + }); + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + expect(siblingDidRender).toBe(false); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual(

loading...

); + }); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 33199490bd620..42e1b2b615a1c 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -350,10 +350,11 @@ const PENDING = 0; const COMPLETED = 1; const ABORTED = 3; const ERRORED = 4; +const RENDERING = 5; type Task = { id: number, - status: 0 | 1 | 3 | 4, + status: 0 | 1 | 3 | 4 | 5, model: ReactClientValue, ping: () => void, toJSON: (key: string, value: ReactClientValue) => ReactJSONValue, @@ -1015,6 +1016,16 @@ function renderFunctionComponent( const secondArg = undefined; result = Component(props, secondArg); } + + if (task.status === ABORTED) { + // If we aborted during rendering we should interrupt the render but + // we don't need to provide an error because the renderer will encode + // the abort error as the reason. + + // eslint-disable-next-line no-throw-literal + throw null; + } + if ( typeof result === 'object' && result !== null && @@ -1880,11 +1891,19 @@ function renderModel( key: string, value: ReactClientValue, ): ReactJSONValue { + if (task.status === ABORTED) { + const errorId: number = (task.model: any); + return serializeLazyID(errorId); + } const prevKeyPath = task.keyPath; const prevImplicitSlot = task.implicitSlot; try { return renderModelDestructive(request, task, parent, key, value); } catch (thrownValue) { + if (task.status === ABORTED) { + const errorId: number = (task.model: any); + return serializeLazyID(errorId); + } const x = thrownValue === SuspenseException ? // This is a special type of exception used for Suspense. For historical @@ -3207,6 +3226,7 @@ function retryTask(request: Request, task: Task): void { } const prevDebugID = debugID; + task.status = RENDERING; try { // Track the root so we know that we have to emit this object even though it @@ -3261,6 +3281,12 @@ function retryTask(request: Request, task: Task): void { request.abortableTasks.delete(task); task.status = COMPLETED; } catch (thrownValue) { + if (task.status === ABORTED) { + const errorId: number = (task.model: any); + const model = stringify(serializeLazyID(errorId)); + emitModelChunk(request, task.id, model); + return; + } const x = thrownValue === SuspenseException ? // This is a special type of exception used for Suspense. For historical @@ -3277,6 +3303,7 @@ function retryTask(request: Request, task: Task): void { const ping = task.ping; x.then(ping, ping); task.thenableState = getThenableStateAfterSuspending(); + task.status = PENDING; return; } else if (enablePostpone && x.$$typeof === REACT_POSTPONE_TYPE) { request.abortableTasks.delete(task); @@ -3344,12 +3371,20 @@ function performWork(request: Request): void { } function abortTask(task: Task, request: Request, errorId: number): void { - task.status = ABORTED; - // Instead of emitting an error per task.id, we emit a model that only - // has a single value referencing the error. - const ref = serializeByValueID(errorId); - const processedChunk = encodeReferenceChunk(request, task.id, ref); - request.completedErrorChunks.push(processedChunk); + if (task.status === RENDERING) { + task.status = ABORTED; + task.model = errorId; + // We don't direclty abort rendering tasks here but we mutate the model to + // encode the errorId and mark the task aborted. The abort will be handled + // by the task itself when control is returned. + } else { + task.status = ABORTED; + // Instead of emitting an error per task.id, we emit a model that only + // has a single value referencing the error. + const ref = serializeByValueID(errorId); + const processedChunk = encodeReferenceChunk(request, task.id, ref); + request.completedErrorChunks.push(processedChunk); + } } function flushCompletedChunks(