diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index bb65ef4b659a7..039ff1fbefe71 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -26,6 +26,8 @@ import { startFlowing, stopFlowing, abort, + suspend, + isDefaultAbortError, } from 'react-server/src/ReactFlightServer'; import { @@ -187,10 +189,20 @@ function prerenderToNodeStream( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js index ef980764942d7..13aab928e2879 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js @@ -18,6 +18,8 @@ import { startFlowing, stopFlowing, abort, + suspend, + isDefaultAbortError, } from 'react-server/src/ReactFlightServer'; import { @@ -146,10 +148,20 @@ function prerender( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js index ef980764942d7..13aab928e2879 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js @@ -18,6 +18,8 @@ import { startFlowing, stopFlowing, abort, + suspend, + isDefaultAbortError, } from 'react-server/src/ReactFlightServer'; import { @@ -146,10 +148,20 @@ function prerender( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index e484d4b7e77d5..290c3f6fcdaf3 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -26,6 +26,8 @@ import { startFlowing, stopFlowing, abort, + suspend, + isDefaultAbortError, } from 'react-server/src/ReactFlightServer'; import { @@ -189,10 +191,20 @@ function prerenderToNodeStream( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); 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 faaf8aef01b0d..eb766fc43a86a 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -2722,4 +2722,83 @@ describe('ReactFlightDOM', () => { await readInto(container, fizzReadable); expect(getMeaningfulChildren(container)).toEqual(
hello world
); }); + + // @gate experimental + it('serializes unfinished tasks with infinite promises when aborting a prerender without a reason', async () => { + let resolveGreeting; + const greetingPromise = new Promise(resolve => { + resolveGreeting = resolve; + }); + + function App() { + return ( +
+ + + +
+ ); + } + + async function Greeting() { + await greetingPromise; + return 'hello world'; + } + + const controller = new AbortController(); + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream( + , + webpackMap, + { + signal: controller.signal, + }, + ), + }; + }); + + controller.abort(); + resolveGreeting(); + const {prelude} = await pendingResult; + + const preludeWeb = Readable.toWeb(prelude); + const response = ReactServerDOMClient.createFromReadableStream(preludeWeb); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + let abortFizz; + await serverAct(async () => { + const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ); + pipe(fizzWritable); + abortFizz = abort; + }); + + await serverAct(() => { + try { + React.unstable_postpone('abort reason'); + } catch (reason) { + abortFizz(reason); + } + }); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual(
loading...
); + }); }); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js index a4e0c3bef693b..14b6d7a70bd2b 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js @@ -18,6 +18,8 @@ import { startFlowing, stopFlowing, abort, + suspend, + isDefaultAbortError, } from 'react-server/src/ReactFlightServer'; import { @@ -146,10 +148,20 @@ function prerender( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js index a4e0c3bef693b..14b6d7a70bd2b 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js @@ -18,6 +18,8 @@ import { startFlowing, stopFlowing, abort, + suspend, + isDefaultAbortError, } from 'react-server/src/ReactFlightServer'; import { @@ -146,10 +148,20 @@ function prerender( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 1506259476703..0e2a763b5ac68 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -26,6 +26,8 @@ import { startFlowing, stopFlowing, abort, + suspend, + isDefaultAbortError, } from 'react-server/src/ReactFlightServer'; import { @@ -189,10 +191,20 @@ function prerenderToNodeStream( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (isDefaultAbortError(reason)) { + suspend(request); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 5920cd0a2fbaf..723bdbb4402c1 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -748,22 +748,30 @@ function serializeReadableStream( } aborted = true; request.abortListeners.delete(error); - if ( + + let cancelWith: mixed; + if (reason === suspendedRenderSymbol) { + cancelWith = suspendedRenderReason; + } else if ( enablePostpone && typeof reason === 'object' && reason !== null && (reason: any).$$typeof === REACT_POSTPONE_TYPE ) { + cancelWith = reason; const postponeInstance: Postpone = (reason: any); logPostpone(request, postponeInstance.message, streamTask); emitPostponeChunk(request, streamTask.id, postponeInstance); + enqueueFlush(request); } else { + cancelWith = reason; const digest = logRecoverableError(request, reason, streamTask); emitErrorChunk(request, streamTask.id, digest, reason); + enqueueFlush(request); } - enqueueFlush(request); + // $FlowFixMe should be able to pass mixed - reader.cancel(reason).then(error, error); + reader.cancel(cancelWith).then(error, error); } request.abortListeners.add(error); reader.read().then(progress, error); @@ -866,24 +874,30 @@ function serializeAsyncIterable( } aborted = true; request.abortListeners.delete(error); - if ( + let throwWith: mixed; + if (reason === suspendedRenderSymbol) { + throwWith = suspendedRenderReason; + } else if ( enablePostpone && typeof reason === 'object' && reason !== null && (reason: any).$$typeof === REACT_POSTPONE_TYPE ) { + throwWith = reason; const postponeInstance: Postpone = (reason: any); logPostpone(request, postponeInstance.message, streamTask); emitPostponeChunk(request, streamTask.id, postponeInstance); + enqueueFlush(request); } else { + throwWith = reason; const digest = logRecoverableError(request, reason, streamTask); emitErrorChunk(request, streamTask.id, digest, reason); + enqueueFlush(request); } - enqueueFlush(request); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + iterator.throw(throwWith).then(error, error); } } request.abortListeners.add(error); @@ -2063,12 +2077,18 @@ function serializeBlob(request: Request, blob: Blob): string { } aborted = true; request.abortListeners.delete(error); - const digest = logRecoverableError(request, reason, newTask); - emitErrorChunk(request, newTask.id, digest, reason); - request.abortableTasks.delete(newTask); - enqueueFlush(request); + let cancelWith: mixed; + if (reason === suspendedRenderSymbol) { + cancelWith = suspendedRenderReason; + } else { + cancelWith = reason; + const digest = logRecoverableError(request, reason, newTask); + emitErrorChunk(request, newTask.id, digest, reason); + request.abortableTasks.delete(newTask); + enqueueFlush(request); + } // $FlowFixMe should be able to pass mixed - reader.cancel(reason).then(error, error); + reader.cancel(cancelWith).then(error, error); } request.abortListeners.add(error); @@ -4007,3 +4027,42 @@ export function abort(request: Request, reason: mixed): void { fatalError(request, error); } } + +const suspendedRenderSymbol = Symbol('suspended render'); +const suspendedRenderReason = 'React Server render ended before finishing'; + +// This is called to early terminate a request. It creates an error at all pending tasks. +export function suspend(request: Request, reason: 'string'): void { + try { + if (request.status === OPEN) { + request.status = ABORTING; + } + const abortableTasks = request.abortableTasks; + // We have tasks to abort. We'll emit one error row and then emit a reference + // to that row from every row that's still remaining. + if (abortableTasks.size > 0) { + request.pendingChunks++; + const refId = request.nextChunkId++; + request.fatalError = refId; + const model = stringify(serializeInfinitePromise()); + emitModelChunk(request, refId, model); + abortableTasks.forEach(task => abortTask(task, request, refId)); + abortableTasks.clear(); + } + const abortListeners = request.abortListeners; + if (abortListeners.size > 0) { + abortListeners.forEach(callback => callback(suspendedRenderSymbol)); + abortListeners.clear(); + } + if (request.destination !== null) { + flushCompletedChunks(request, request.destination); + } + } catch (error) { + logRecoverableError(request, error, null); + fatalError(request, error); + } +} + +export function isDefaultAbortError(error: mixed): boolean { + return typeof error === 'object' && error !== null && error.code === 20; +}