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 9211b2796543f..108dc9e25bdba 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -2724,7 +2724,7 @@ describe('ReactFlightDOM', () => { }); // @gate enableHalt - it('serializes unfinished tasks with infinite promises when aborting a prerender', async () => { + it('serializes a forever blocked reference when aborting a prerender', async () => { let resolveGreeting; const greetingPromise = new Promise(resolve => { resolveGreeting = resolve; @@ -2746,6 +2746,7 @@ describe('ReactFlightDOM', () => { } const controller = new AbortController(); + const errors = []; const {pendingResult} = await serverAct(async () => { // destructure trick to avoid the act scope from awaiting the returned value return { @@ -2754,15 +2755,20 @@ describe('ReactFlightDOM', () => { webpackMap, { signal: controller.signal, + onError(err) { + errors.push(err); + }, }, ), }; }); - controller.abort(); + controller.abort('boom'); resolveGreeting(); const {prelude} = await pendingResult; + expect(errors).toEqual(['boom']); + const preludeWeb = Readable.toWeb(prelude); const response = ReactServerDOMClient.createFromReadableStream(preludeWeb); @@ -2772,7 +2778,7 @@ describe('ReactFlightDOM', () => { return use(response); } - const errors = []; + errors.length = 0; let abortFizz; await serverAct(async () => { const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream( @@ -2788,10 +2794,10 @@ describe('ReactFlightDOM', () => { }); await serverAct(() => { - abortFizz('boom'); + abortFizz('bam'); }); - expect(errors).toEqual(['boom']); + expect(errors).toEqual(['bam']); const container = document.createElement('div'); await readInto(container, fizzReadable); @@ -2861,7 +2867,7 @@ describe('ReactFlightDOM', () => { it('will halt unfinished chunks inside Suspense when aborting a prerender', async () => { const controller = new AbortController(); function ComponentThatAborts() { - controller.abort(); + controller.abort('boom'); return null; } @@ -2901,10 +2907,8 @@ describe('ReactFlightDOM', () => { }; }); - controller.abort(); - const {prelude} = await pendingResult; - expect(errors).toEqual([]); + expect(errors).toEqual(['boom']); const response = ReactServerDOMClient.createFromReadableStream( Readable.toWeb(prelude), ); @@ -2914,6 +2918,7 @@ describe('ReactFlightDOM', () => { function ClientApp() { return use(response); } + errors.length = 0; let abortFizz; await serverAct(async () => { const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream( diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 7bbfea1484bed..154f7a1aef5c8 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -2390,7 +2390,7 @@ describe('ReactFlightDOMBrowser', () => { }); // @gate enableHalt - it('serializes unfinished tasks with infinite promises when aborting a prerender', async () => { + it('serializes a forever blocked reference when aborting a prerender', async () => { let resolveGreeting; const greetingPromise = new Promise(resolve => { resolveGreeting = resolve; @@ -2412,6 +2412,7 @@ describe('ReactFlightDOMBrowser', () => { } const controller = new AbortController(); + const errors = []; const {pendingResult} = await serverAct(async () => { // destructure trick to avoid the act scope from awaiting the returned value return { @@ -2420,14 +2421,18 @@ describe('ReactFlightDOMBrowser', () => { webpackMap, { signal: controller.signal, + onError(err) { + errors.push(err); + }, }, ), }; }); - controller.abort(); + controller.abort('boom'); resolveGreeting(); const {prelude} = await pendingResult; + expect(errors).toEqual(['boom']); function ClientRoot({response}) { return use(response); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index b38c33dc7761b..f9b50e0bb0e0b 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -1103,7 +1103,7 @@ describe('ReactFlightDOMEdge', () => { }); // @gate enableHalt - it('serializes unfinished tasks with infinite promises when aborting a prerender', async () => { + it('serializes a forever blocked reference when aborting a prerender', async () => { let resolveGreeting; const greetingPromise = new Promise(resolve => { resolveGreeting = resolve; @@ -1125,6 +1125,7 @@ describe('ReactFlightDOMEdge', () => { } const controller = new AbortController(); + const errors = []; const {pendingResult} = await serverAct(async () => { // destructure trick to avoid the act scope from awaiting the returned value return { @@ -1133,15 +1134,20 @@ describe('ReactFlightDOMEdge', () => { webpackMap, { signal: controller.signal, + onError(err) { + errors.push(err); + }, }, ), }; }); - controller.abort(); + controller.abort('boom'); resolveGreeting(); const {prelude} = await pendingResult; + expect(errors).toEqual(['boom']); + function ClientRoot({response}) { return use(response); } @@ -1153,7 +1159,7 @@ describe('ReactFlightDOMEdge', () => { }, }); const fizzController = new AbortController(); - const errors = []; + errors.length = 0; const ssrStream = await serverAct(() => ReactDOMServer.renderToReadableStream( React.createElement(ClientRoot, {response}), @@ -1165,8 +1171,8 @@ describe('ReactFlightDOMEdge', () => { }, ), ); - fizzController.abort('boom'); - expect(errors).toEqual(['boom']); + fizzController.abort('bam'); + expect(errors).toEqual(['bam']); // Should still match the result when parsed const result = await readResult(ssrStream); const div = document.createElement('div'); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index fbe32d2f1f697..4b1ada9fb128a 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -443,7 +443,7 @@ describe('ReactFlightDOMNode', () => { }); // @gate enableHalt - it('serializes unfinished tasks with infinite promises when aborting a prerender', async () => { + it('serializes a forever blocked reference when aborting a prerender', async () => { let resolveGreeting; const greetingPromise = new Promise(resolve => { resolveGreeting = resolve; @@ -465,6 +465,7 @@ describe('ReactFlightDOMNode', () => { } const controller = new AbortController(); + const errors = []; const {pendingResult} = await serverAct(async () => { // destructure trick to avoid the act scope from awaiting the returned value return { @@ -473,14 +474,18 @@ describe('ReactFlightDOMNode', () => { webpackMap, { signal: controller.signal, + onError(err) { + errors.push(err); + }, }, ), }; }); - controller.abort(); + controller.abort('boom'); resolveGreeting(); const {prelude} = await pendingResult; + expect(errors).toEqual(['boom']); function ClientRoot({response}) { return use(response); @@ -492,7 +497,7 @@ describe('ReactFlightDOMNode', () => { moduleLoading: null, }, }); - const errors = []; + errors.length = 0; const ssrStream = await serverAct(() => ReactDOMServer.renderToPipeableStream( React.createElement(ClientRoot, {response}), @@ -503,8 +508,8 @@ describe('ReactFlightDOMNode', () => { }, ), ); - ssrStream.abort('boom'); - expect(errors).toEqual(['boom']); + ssrStream.abort('bam'); + expect(errors).toEqual(['bam']); // Should still match the result when parsed const result = await readResult(ssrStream); const div = document.createElement('div'); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index b48dd2303a3d2..3ba09d33506ff 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -753,30 +753,32 @@ function serializeReadableStream( } aborted = true; request.abortListeners.delete(error); - - let cancelWith: mixed; - if (enableHalt && request.fatalError === haltSymbol) { - cancelWith = reason; - } else if ( + 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); + if (enableHalt && request.fatalError === haltSymbol) { + request.pendingChunks--; + } else { + emitPostponeChunk(request, streamTask.id, postponeInstance); + enqueueFlush(request); + } } else { - cancelWith = reason; const digest = logRecoverableError(request, reason, streamTask); - emitErrorChunk(request, streamTask.id, digest, reason); - enqueueFlush(request); + if (enableHalt && request.fatalError === haltSymbol) { + request.pendingChunks--; + } else { + emitErrorChunk(request, streamTask.id, digest, reason); + enqueueFlush(request); + } } // $FlowFixMe should be able to pass mixed - reader.cancel(cancelWith).then(error, error); + reader.cancel(reason).then(error, error); } request.abortListeners.add(error); @@ -880,30 +882,33 @@ function serializeAsyncIterable( } aborted = true; request.abortListeners.delete(error); - let throwWith: mixed; - if (enableHalt && request.fatalError === haltSymbol) { - throwWith = reason; - } else if ( + 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); + if (enableHalt && request.fatalError === haltSymbol) { + request.pendingChunks--; + } else { + emitPostponeChunk(request, streamTask.id, postponeInstance); + enqueueFlush(request); + } } else { - throwWith = reason; const digest = logRecoverableError(request, reason, streamTask); - emitErrorChunk(request, streamTask.id, digest, reason); - enqueueFlush(request); + if (enableHalt && request.fatalError === haltSymbol) { + request.pendingChunks--; + } else { + emitErrorChunk(request, streamTask.id, digest, reason); + 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(throwWith).then(error, error); + iterator.throw(reason).then(error, error); } } request.abortListeners.add(error); @@ -2095,18 +2100,31 @@ function serializeBlob(request: Request, blob: Blob): string { } aborted = true; request.abortListeners.delete(error); - let cancelWith: mixed; - if (enableHalt && request.fatalError === haltSymbol) { - cancelWith = reason; + if ( + enablePostpone && + typeof reason === 'object' && + reason !== null && + (reason: any).$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (reason: any); + logPostpone(request, postponeInstance.message, newTask); + if (enableHalt && request.fatalError === haltSymbol) { + request.pendingChunks--; + } else { + emitPostponeChunk(request, newTask.id, postponeInstance); + enqueueFlush(request); + } } else { - cancelWith = reason; const digest = logRecoverableError(request, reason, newTask); - emitErrorChunk(request, newTask.id, digest, reason); - request.abortableTasks.delete(newTask); - enqueueFlush(request); + if (enableHalt && request.fatalError === haltSymbol) { + request.pendingChunks--; + } else { + emitErrorChunk(request, newTask.id, digest, reason); + enqueueFlush(request); + } } // $FlowFixMe should be able to pass mixed - reader.cancel(cancelWith).then(error, error); + reader.cancel(reason).then(error, error); } request.abortListeners.add(error); @@ -3998,14 +4016,15 @@ export function stopFlowing(request: Request): void { // This is called to early terminate a request. It creates an error at all pending tasks. export function abort(request: Request, reason: mixed): void { + if (request.status === OPEN) { + request.status = ABORTING; + } 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.status = ABORTING; request.pendingChunks++; const errorId = request.nextChunkId++; request.fatalError = errorId; @@ -4019,54 +4038,14 @@ export function abort(request: Request, reason: mixed): void { logPostpone(request, postponeInstance.message, null); emitPostponeChunk(request, errorId, postponeInstance); } else { - const error = - reason === undefined - ? new Error( - 'The render was aborted by the server without a reason.', - ) - : typeof reason === 'object' && - reason !== null && - typeof reason.then === 'function' - ? new Error( - 'The render was aborted by the server with a promise.', - ) - : reason; + const error = resolveAbortError(reason); const digest = logRecoverableError(request, error, null); emitErrorChunk(request, errorId, digest, error); } abortableTasks.forEach(task => abortTask(task, request, errorId)); abortableTasks.clear(); } - const abortListeners = request.abortListeners; - if (abortListeners.size > 0) { - let error; - if ( - enablePostpone && - typeof reason === 'object' && - reason !== null && - (reason: any).$$typeof === REACT_POSTPONE_TYPE - ) { - // We aborted with a Postpone but since we're passing this to an - // external handler, passing this object would leak it outside React. - // We create an alternative reason for it instead. - error = new Error('The render was aborted due to being postponed.'); - } else { - error = - reason === undefined - ? new Error( - 'The render was aborted by the server without a reason.', - ) - : typeof reason === 'object' && - reason !== null && - typeof reason.then === 'function' - ? new Error( - 'The render was aborted by the server with a promise.', - ) - : reason; - } - abortListeners.forEach(callback => callback(error)); - abortListeners.clear(); - } + abortAnyListeners(reason, request.abortListeners); if (request.destination !== null) { flushCompletedChunks(request, request.destination); } @@ -4082,23 +4061,32 @@ const haltSymbol = Symbol('halt'); // This is called to stop rendering without erroring. All unfinished work is represented Promises // that never resolve. export function halt(request: Request, reason: mixed): void { + if (request.status === OPEN) { + request.status = ABORTING; + } + request.fatalError = haltSymbol; try { - if (request.status === OPEN) { - request.status = ABORTING; - } - request.fatalError = haltSymbol; 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) { + // We have tasks to halt. We will log the error or postpone but we don't + // emit an error or postpone chunk. Instead we will emit a reference that + // never resolves on the client. + if ( + enablePostpone && + typeof reason === 'object' && + reason !== null && + (reason: any).$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (reason: any); + logPostpone(request, postponeInstance.message, null); + } else { + const error = resolveAbortError(reason); + logRecoverableError(request, error, null); + } abortableTasks.forEach(task => haltTask(task, request)); abortableTasks.clear(); } - const abortListeners = request.abortListeners; - if (abortListeners.size > 0) { - abortListeners.forEach(callback => callback(reason)); - abortListeners.clear(); - } + abortAnyListeners(reason, request.abortListeners); if (request.destination !== null) { flushCompletedChunks(request, request.destination); } @@ -4109,6 +4097,47 @@ export function halt(request: Request, reason: mixed): void { } } +function resolveAbortError(reason: mixed): mixed { + return reason === undefined + ? new Error('The render was aborted by the server without a reason.') + : typeof reason === 'object' && + reason !== null && + typeof reason.then === 'function' + ? new Error('The render was aborted by the server with a promise.') + : reason; +} + +function abortAnyListeners( + reason: mixed, + listeners: Set<(reason: mixed) => void>, +) { + if (listeners.size > 0) { + let error; + if ( + enablePostpone && + typeof reason === 'object' && + reason !== null && + (reason: any).$$typeof === REACT_POSTPONE_TYPE + ) { + // We aborted with a Postpone but since we're passing this to an + // external handler, passing this object would leak it outside React. + // We create an alternative reason for it instead. + error = new Error('The render was aborted due to being postponed.'); + } else { + error = + reason === undefined + ? new Error('The render was aborted by the server without a reason.') + : typeof reason === 'object' && + reason !== null && + typeof reason.then === 'function' + ? new Error('The render was aborted by the server with a promise.') + : reason; + } + listeners.forEach(callback => callback(error)); + listeners.clear(); + } +} + function allReady(request: Request) { const onAllReady = request.onAllReady; onAllReady();