diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 564f4e859871f9..a05fa0820a4471 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -45,6 +45,7 @@ import { enableRefAsProp, enableFlightReadableStream, enableOwnerStacks, + enableHalt, } from 'shared/ReactFeatureFlags'; import { @@ -860,6 +861,25 @@ function getChunk(response: Response, id: number): SomeChunk { return chunk; } +/** + * Fork of waitForReference that doesn't ever resolve + */ +function waitForever() { + if (initializingHandler) { + initializingHandler.deps++; + } else { + initializingHandler = { + parent: null, + chunk: null, + value: null, + deps: 1, + errored: false, + }; + } + + return null; +} + function waitForReference( referencedChunk: SomeChunk, parentObject: Object, @@ -1184,6 +1204,10 @@ function parseModelString( } case 'L': { // Lazy node + if (enableHalt && value.length === 2) { + // Lazy Infinitely Blocked Reference. + return createLazyChunkWrapper(createBlockedChunk(response)); + } const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); // We create a React.lazy wrapper around any lazy values. @@ -1227,6 +1251,13 @@ function parseModelString( } return readTemporaryReference(temporaryReferences, reference); } + case '#': { + // Infinitely Blocked Reference + if (enableHalt) { + return waitForever(); + } + // fallthrough + } case 'Q': { // Map const ref = value.slice(2); 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 6a0ce0152b704f..97fce8a8ea11dc 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -2856,4 +2856,105 @@ describe('ReactFlightDOM', () => { jest.advanceTimersByTime('100'); expect(await race).toBe('timeout'); }); + + // @gate enableHalt + it('will halt unfinished chunks inside Suspense when aborting a prerender', async () => { + const controller = new AbortController(); + function ComponentThatAborts() { + controller.abort(); + return null; + } + + async function Greeting() { + await 1; + return 'hello world'; + } + + async function Farewell() { + return 'goodbye world'; + } + + async function Wrapper() { + return ( + + + + ); + } + + function App() { + return ( +
+ + + + + + + +
+ ); + } + + const errors = []; + const {pendingResult} = await serverAct(() => { + return { + pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream( + , + {}, + { + onError(x) { + errors.push(x); + }, + signal: controller.signal, + }, + ), + }; + }); + + controller.abort(); + + const {prelude} = await pendingResult; + expect(errors).toEqual([]); + + const response = ReactServerDOMClient.createFromReadableStream( + Readable.toWeb(prelude), + ); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + let abortFizz; + await serverAct(async () => { + const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onError(error, errorInfo) { + errors.push(error); + }, + }, + ); + pipe(fizzWritable); + abortFizz = abort; + }); + + await serverAct(() => { + abortFizz('boom'); + }); + + // one error per boundary + expect(errors).toEqual(['boom', 'boom', 'boom']); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+ {'loading...'} + {'loading too...'} + {'loading three...'} +
, + ); + }); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index db0ee7b3cebadc..8f43caeb7f3d7e 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -615,7 +615,7 @@ function serializeThenable( request.abortableTasks.delete(newTask); newTask.status = ABORTED; if (enableHalt && request.fatalError === haltSymbol) { - emitModelChunk(request, newTask.id, reusableInfinitePromiseModel); + emitModelChunk(request, newTask.id, reusableBlockedReferenceModel); } else { const errorId: number = (request.fatalError: any); const model = stringify(serializeByValueID(errorId)); @@ -1815,10 +1815,13 @@ function serializeLazyID(id: number): string { return '$L' + id.toString(16); } +function serializeLazyBlockedReference(): string { + return '$L'; +} + function serializeInfinitePromise(): string { return '$@'; } -const reusableInfinitePromiseModel = stringify(serializeInfinitePromise()); function serializePromiseID(id: number): string { return '$@' + id.toString(16); @@ -1836,6 +1839,11 @@ function serializeLimitedObject(): string { return '$Y'; } +function serializeBlockedReference(): string { + return '$#'; +} +const reusableBlockedReferenceModel = '"$#"'; + function serializeNumber(number: number): string | number { if (Number.isFinite(number)) { if (number === 0 && 1 / number === -Infinity) { @@ -2177,7 +2185,10 @@ function renderModel( if (request.status === ABORTING) { task.status = ABORTED; if (enableHalt && request.fatalError === haltSymbol) { - return serializeInfinitePromise(); + if (wasReactNode) { + return serializeLazyBlockedReference(); + } + return serializeBlockedReference(); } const errorId: number = (request.fatalError: any); if (wasReactNode) { @@ -2233,7 +2244,10 @@ function renderModel( if (request.status === ABORTING) { task.status = ABORTED; if (enableHalt && request.fatalError === haltSymbol) { - return serializeInfinitePromise(); + if (wasReactNode) { + return serializeLazyBlockedReference(); + } + return serializeBlockedReference(); } const errorId: number = (request.fatalError: any); if (wasReactNode) { @@ -3725,7 +3739,7 @@ function retryTask(request: Request, task: Task): void { request.abortableTasks.delete(task); task.status = ABORTED; if (enableHalt && request.fatalError === haltSymbol) { - emitModelChunk(request, task.id, reusableInfinitePromiseModel); + emitModelChunk(request, task.id, reusableBlockedReferenceModel); } else { const errorId: number = (request.fatalError: any); const model = stringify(serializeByValueID(errorId)); @@ -3753,7 +3767,7 @@ function retryTask(request: Request, task: Task): void { request.abortableTasks.delete(task); task.status = ABORTED; if (enableHalt && request.fatalError === haltSymbol) { - emitModelChunk(request, task.id, reusableInfinitePromiseModel); + emitModelChunk(request, task.id, reusableBlockedReferenceModel); } else { const errorId: number = (request.fatalError: any); const model = stringify(serializeByValueID(errorId)); @@ -3798,6 +3812,7 @@ function performWork(request: Request): void { currentRequest = request; prepareToUseHooksForRequest(request); + const hadAbortableTasks = request.abortableTasks.size > 0; try { const pingedTasks = request.pingedTasks; request.pingedTasks = []; @@ -3808,10 +3823,11 @@ function performWork(request: Request): void { if (request.destination !== null) { flushCompletedChunks(request, request.destination); } - if (request.abortableTasks.size === 0) { - // we're done rendering - const onAllReady = request.onAllReady; - onAllReady(); + if (hadAbortableTasks && request.abortableTasks.size === 0) { + // We can ping after completing but if this happens there already + // wouldn't be any abortable tasks. So we only call allReady after + // the work which actually completed the last pending task + allReady(request); } } catch (error) { logRecoverableError(request, error, null); @@ -3842,7 +3858,7 @@ function haltTask(task: Task, request: Request): void { return; } task.status = ABORTED; - emitModelChunk(request, task.id, reusableInfinitePromiseModel); + emitModelChunk(request, task.id, reusableBlockedReferenceModel); } function flushCompletedChunks( @@ -3986,7 +4002,7 @@ 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 { try { - if (request.status === OPEN) { + if (request.status === PENDING) { request.status = ABORTING; } const abortableTasks = request.abortableTasks; @@ -4023,6 +4039,7 @@ export function abort(request: Request, reason: mixed): void { } abortableTasks.forEach(task => abortTask(task, request, errorId)); abortableTasks.clear(); + allReady(request); } const abortListeners = request.abortListeners; if (abortListeners.size > 0) { @@ -4069,7 +4086,7 @@ const haltSymbol = Symbol('halt'); // that never resolve. export function halt(request: Request, reason: mixed): void { try { - if (request.status === OPEN) { + if (request.status === PENDING) { request.status = ABORTING; } request.fatalError = haltSymbol; @@ -4077,9 +4094,9 @@ export function halt(request: Request, reason: mixed): void { // 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++; abortableTasks.forEach(task => haltTask(task, request)); abortableTasks.clear(); + allReady(request); } const abortListeners = request.abortListeners; if (abortListeners.size > 0) { @@ -4094,3 +4111,8 @@ export function halt(request: Request, reason: mixed): void { fatalError(request, error); } } + +function allReady(request: Request) { + const onAllReady = request.onAllReady; + onAllReady(); +}