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..dbf9a5e756313 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -107,6 +107,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 +1665,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 +1932,279 @@ describe('ReactFlightDOM', () => { }); expect(container.innerHTML).toBe('Hello World'); }); + + it('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

; + } + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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..a4b659807ce9e 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 = task.model; + 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 = task.model; + 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 @@ -3277,6 +3297,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 +3365,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(