diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 076c442f5283..b89724cadd4f 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -6355,16 +6355,479 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual(
Loading...
); - const b = new Stream.PassThrough(); - b.setEncoding('utf8'); - b.on('data', chunk => { + await act(() => { + resumed.pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual(
Hello
); + }); + + // @gate enablePostpone + it('client renders a component if it errors during resuming', async () => { + let prerendering = true; + let ssr = true; + function PostponeAndError() { + if (prerendering) { + React.unstable_postpone(); + } + if (ssr) { + throw new Error('server error'); + } + return 'Hello'; + } + + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + const lazyPostponeAndError = React.lazy(async () => { + return {default: }; + }); + + function ReplayError() { + if (prerendering) { + return ; + } + if (ssr) { + throw new Error('replay error'); + } + return 'Hello'; + } + + function App() { + return ( +
+ + + + + + {lazyPostponeAndError} + + + + +
+ ); + } + + const prerenderErrors = []; + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream( + , + { + onError(x) { + prerenderErrors.push(x.message); + }, + }, + ); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + const ssrErrors = []; + + const resumed = ReactDOMFizzServer.resumeToPipeableStream( + , + prerendered.postponed, + { + onError(x) { + ssrErrors.push(x.message); + }, + }, + ); + + // Create a separate stream so it doesn't close the writable. I.e. simple concat. + const preludeWritable = new Stream.PassThrough(); + preludeWritable.setEncoding('utf8'); + preludeWritable.on('data', chunk => { writable.write(chunk); }); + await act(() => { + prerendered.prelude.pipe(preludeWritable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+ {'Loading1'} + {'Loading2'} + {'Loading4'} +
, + ); + await act(() => { resumed.pipe(writable); }); - expect(getVisibleChildren(container)).toEqual(
Hello
); + expect(prerenderErrors).toEqual([]); + + expect(ssrErrors).toEqual(['server error', 'server error', 'replay error']); + + // Still loading... + expect(getVisibleChildren(container)).toEqual( +
+ {'Loading1'} + {'Hello'} + {'Loading3'} + {'Loading4'} +
, + ); + + const recoverableErrors = []; + + ssr = false; + + await clientAct(() => { + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(x) { + recoverableErrors.push(x.message); + }, + }); + }); + + expect(recoverableErrors).toEqual( + __DEV__ + ? ['server error', 'replay error', 'server error'] + : [ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + ], + ); + expect(getVisibleChildren(container)).toEqual( +
+ {'Hello'} + {'Hello'} + {'Hello'} + {'Hello'} +
, + ); + }); + + // @gate enablePostpone + it('client renders a component if we abort before resuming', async () => { + let prerendering = true; + let ssr = true; + const promise = new Promise(() => {}); + function PostponeAndSuspend() { + if (prerendering) { + React.unstable_postpone(); + } + if (ssr) { + React.use(promise); + } + return 'Hello'; + } + + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + function DelayedBoundary() { + if (!prerendering && ssr) { + // We delay discovery of the boundary so we can abort before finding it. + React.use(promise); + } + return ( + + + + ); + } + + function App() { + return ( +
+ + + + + + + + + +
+ ); + } + + const prerenderErrors = []; + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream( + , + { + onError(x) { + prerenderErrors.push(x.message); + }, + }, + ); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + const ssrErrors = []; + + const resumed = ReactDOMFizzServer.resumeToPipeableStream( + , + prerendered.postponed, + { + onError(x) { + ssrErrors.push(x.message); + }, + }, + ); + + // Create a separate stream so it doesn't close the writable. I.e. simple concat. + const preludeWritable = new Stream.PassThrough(); + preludeWritable.setEncoding('utf8'); + preludeWritable.on('data', chunk => { + writable.write(chunk); + }); + + await act(() => { + prerendered.prelude.pipe(preludeWritable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+ {'Loading1'} + {'Loading2'} + {'Loading3'} +
, + ); + + await act(() => { + resumed.pipe(writable); + }); + + const recoverableErrors = []; + + ssr = false; + + await clientAct(() => { + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(x) { + recoverableErrors.push(x.message); + }, + }); + }); + + expect(recoverableErrors).toEqual([]); + expect(prerenderErrors).toEqual([]); + expect(ssrErrors).toEqual([]); + + // Still loading... + expect(getVisibleChildren(container)).toEqual( +
+ {'Loading1'} + {'Hello'} + {'Loading3'} +
, + ); + + await clientAct(async () => { + await act(() => { + resumed.abort(new Error('aborted')); + }); + }); + + expect(getVisibleChildren(container)).toEqual( +
+ {'Hello'} + {'Hello'} + {'Hello'} +
, + ); + + expect(prerenderErrors).toEqual([]); + expect(ssrErrors).toEqual(['aborted', 'aborted']); + expect(recoverableErrors).toEqual( + __DEV__ + ? [ + 'The server did not finish this Suspense boundary: aborted', + 'The server did not finish this Suspense boundary: aborted', + ] + : [ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + ], + ); + }); + + // @gate enablePostpone + it('client renders remaining boundaries below the error in shell', async () => { + let prerendering = true; + let ssr = true; + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + function ReplayError({children}) { + if (!prerendering && ssr) { + throw new Error('replay error'); + } + return children; + } + + function App() { + return ( +
+
+ + + + + + + + + + + +
+ +
+ + + +
+
+ + + + + + + + +
+ ); + } + + const prerenderErrors = []; + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream( + , + { + onError(x) { + prerenderErrors.push(x.message); + }, + }, + ); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + const ssrErrors = []; + + const resumed = ReactDOMFizzServer.resumeToPipeableStream( + , + prerendered.postponed, + { + onError(x) { + ssrErrors.push(x.message); + }, + }, + ); + + // Create a separate stream so it doesn't close the writable. I.e. simple concat. + const preludeWritable = new Stream.PassThrough(); + preludeWritable.setEncoding('utf8'); + preludeWritable.on('data', chunk => { + writable.write(chunk); + }); + + await act(() => { + prerendered.prelude.pipe(preludeWritable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+
+ {'Loading1'} + {'Loading2'} + {'Loading3'} +
+
{'Loading4'}
+ {'Loading5'} +
, + ); + + await act(() => { + resumed.pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+
+ {'Hello' /* This was matched and completed before the error */} + { + 'Loading2' /* This will be client rendered because its parent errored during replay */ + } + { + 'Hello' /* This should be renderable since we matched which previous sibling errored */ + } +
+
+ { + 'Hello' /* This should be able to resume because it's in a different parent. */ + } +
+ {'Hello'} + {'Loading6' /* The parent could resolve even if the child didn't */} +
, + ); + + const recoverableErrors = []; + + ssr = false; + + await clientAct(() => { + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(x) { + recoverableErrors.push(x.message); + }, + }); + }); + + expect(getVisibleChildren(container)).toEqual( +
+
+ {'Hello'} + {'Hello'} + {'Hello'} +
+
{'Hello'}
+ {'Hello'} + {'Hello'} +
, + ); + + // We should've logged once for each boundary that this affected. + expect(prerenderErrors).toEqual([]); + expect(ssrErrors).toEqual([ + // This error triggered in two replay components. + 'replay error', + 'replay error', + ]); + expect(recoverableErrors).toEqual( + // It surfaced in two different suspense boundaries. + __DEV__ + ? [ + 'The server did not finish this Suspense boundary: replay error', + 'The server did not finish this Suspense boundary: replay error', + ] + : [ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + ], + ); }); }); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index cb75725c6b77..59e5636c110c 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -816,10 +816,18 @@ function renderSuspenseBoundary( props: Object, ): void { if (someTask.replay !== null) { - throw new Error( - 'Did not expect to see a Suspense boundary in this slot. ' + - "The tree doesn't match so React will fallback to client rendering.", - ); + // If we're replaying through this pass, it means we're replaying through + // an already completed Suspense boundary. It's too late to do anything about it + // so we can just render through it. + const prevKeyPath = someTask.keyPath; + someTask.keyPath = keyPath; + const content: ReactNodeList = props.children; + try { + renderNode(request, someTask, content, -1); + } finally { + someTask.keyPath = prevKeyPath; + } + return; } // $FlowFixMe: Refined. const task: RenderTask = someTask; @@ -975,11 +983,13 @@ function renderSuspenseBoundary( function replaySuspenseBoundary( request: Request, task: ReplayTask, + keyPath: Root | KeyNode, props: Object, replayNode: ReplaySuspenseBoundary, ): void { pushBuiltInComponentStackInDEV(task, 'Suspense'); + const prevKeyPath = task.keyPath; const previousReplaySet: ReplaySet = task.replay; const parentBoundary = task.blockedBoundary; @@ -1003,7 +1013,6 @@ function replaySuspenseBoundary( task.blockedBoundary = resumedBoundary; task.replay = {nodes: replayNode[3], pendingTasks: 1}; if (enableFloat) { - // Does this even matter for replaying? setCurrentlyRenderingBoundaryResourcesTarget( request.renderState, resumedBoundary.resources, @@ -1049,6 +1058,9 @@ function replaySuspenseBoundary( task.replay.pendingTasks--; + // The parent already flushed in the prerender so we need to schedule this to be emitted. + request.clientRenderedBoundaries.push(resumedBoundary); + // We don't need to decrement any task numbers because we didn't spawn any new task. // We don't need to schedule any task because we know the parent has written yet. // We do need to fallthrough to create the fallback though. @@ -1061,6 +1073,7 @@ function replaySuspenseBoundary( } task.blockedBoundary = parentBoundary; task.replay = previousReplaySet; + task.keyPath = prevKeyPath; } // TODO: Should this be in the finally? popComponentStackInDEV(task); @@ -1069,11 +1082,13 @@ function replaySuspenseBoundary( function resumeSuspenseBoundary( request: Request, task: ReplayTask, + keyPath: Root | KeyNode, props: Object, replayNode: ResumeSuspenseBoundary, ): void { pushBuiltInComponentStackInDEV(task, 'Suspense'); + const prevKeyPath = task.keyPath; const previousReplaySet: ReplaySet = task.replay; const parentBoundary = task.blockedBoundary; @@ -1113,6 +1128,7 @@ function resumeSuspenseBoundary( resumedBoundary.resources, ); } + task.keyPath = keyPath; try { // Convert the current ReplayTask to a RenderTask. const renderTask: RenderTask = (task: any); @@ -1150,6 +1166,9 @@ function resumeSuspenseBoundary( captureBoundaryErrorDetailsDev(resumedBoundary, error); } + // The parent already flushed in the prerender so we need to schedule this to be emitted. + request.clientRenderedBoundaries.push(resumedBoundary); + // We don't need to decrement any task numbers because we didn't spawn any new task. // We don't need to schedule any task because we know the parent has written yet. // We do need to fallthrough to create the fallback though. @@ -1164,6 +1183,7 @@ function resumeSuspenseBoundary( // Restore to a ReplayTask task.blockedSegment = null; task.replay = previousReplaySet; + task.keyPath = prevKeyPath; } // TODO: Should this be in the finally? popComponentStackInDEV(task); @@ -2063,7 +2083,8 @@ function replayElement( ); } // Matched a replayable path. - task.replay = {nodes: node[3], pendingTasks: 1}; + const childNodes = node[3]; + task.replay = {nodes: childNodes, pendingTasks: 1}; try { renderElement( request, @@ -2074,13 +2095,8 @@ function replayElement( props, ref, ); - // We finished rendering this node, so now we can consume this - // slot. This must happen after in case we rerender this task. - replayNodes.splice(i, 1); - } finally { - task.replay.pendingTasks--; if ( - task.replay.pendingTasks === 0 && + task.replay.pendingTasks === 1 && task.replay.nodes.length > 0 ) { throw new Error( @@ -2088,8 +2104,28 @@ function replayElement( "The tree doesn't match so React will fallback to client rendering.", ); } + } catch (x) { + if ( + typeof x === 'object' && + x !== null && + (x === SuspenseException || typeof x.then === 'function') + ) { + // Suspend + throw x; + } + // Unlike regular render, we don't terminate the siblings if we error + // during a replay. That's because this component didn't actually error + // in the original prerender. What's unable to complete is the child + // replay nodes which might be Suspense boundaries which are able to + // absorb the error and we can still continue with siblings. + erroredReplay(request, task.blockedBoundary, x, childNodes); + } finally { + task.replay.pendingTasks--; task.replay = replay; } + // We finished rendering this node, so now we can consume this + // slot. This must happen after in case we rerender this task. + replayNodes.splice(i, 1); } continue; } @@ -2104,7 +2140,7 @@ function replayElement( ); } // Matched a replayable path. - replaySuspenseBoundary(request, task, props, node); + replaySuspenseBoundary(request, task, keyPath, props, node); // We finished rendering this node, so now we can consume this // slot. This must happen after in case we rerender this task. replayNodes.splice(i, 1); @@ -2155,7 +2191,7 @@ function replayElement( ); } // Matched a resumable suspense boundary. - resumeSuspenseBoundary(request, task, props, node); + resumeSuspenseBoundary(request, task, keyPath, props, node); // We finished rendering this node, so now we can consume this // slot. This must happen after in case we rerender this task. @@ -2466,6 +2502,62 @@ function renderNodeDestructiveImpl( } } +function replayFragment( + request: Request, + task: ReplayTask, + children: Array, + childIndex: number, +): void { + // If we're supposed follow this array, we'd expect to see a ReplayNode matching + // this fragment. + const replay = task.replay; + const replayNodes = replay.nodes; + for (let j = 0; j < replayNodes.length; j++) { + const replayNode = replayNodes[j]; + if (replayNode[0] !== REPLAY_NODE) { + continue; + } + const node: ReplayNode = (replayNode: any); + if (node[2] !== childIndex) { + continue; + } + // Matched a replayable path. + const childNodes = node[3]; + task.replay = {nodes: childNodes, pendingTasks: 1}; + try { + renderChildrenArray(request, task, children, -1); + if (task.replay.pendingTasks === 1 && task.replay.nodes.length > 0) { + throw new Error( + "Couldn't find all resumable slots by key/index during replaying. " + + "The tree doesn't match so React will fallback to client rendering.", + ); + } + } catch (x) { + if ( + typeof x === 'object' && + x !== null && + (x === SuspenseException || typeof x.then === 'function') + ) { + // Suspend + throw x; + } + // Unlike regular render, we don't terminate the siblings if we error + // during a replay. That's because this component didn't actually error + // in the original prerender. What's unable to complete is the child + // replay nodes which might be Suspense boundaries which are able to + // absorb the error and we can still continue with siblings. + erroredReplay(request, task.blockedBoundary, x, childNodes); + } finally { + task.replay.pendingTasks--; + task.replay = replay; + } + // We finished rendering this node, so now we can consume this + // slot. This must happen after in case we rerender this task. + replayNodes.splice(j, 1); + break; + } +} + function renderChildrenArray( request: Request, task: Task, @@ -2476,42 +2568,13 @@ function renderChildrenArray( if (childIndex !== -1) { task.keyPath = [task.keyPath, 'Fragment', childIndex]; if (task.replay !== null) { - // If we're supposed follow this array, we'd expect to see a ReplayNode matching - // this fragment. - const replayTask: ReplayTask = task; - const replay = task.replay; - const replayNodes = replay.nodes; - for (let j = 0; j < replayNodes.length; j++) { - const replayNode = replayNodes[j]; - if (replayNode[0] !== REPLAY_NODE) { - continue; - } - const node: ReplayNode = (replayNode: any); - if (node[2] !== childIndex) { - continue; - } - // Matched a replayable path. - replayTask.replay = {nodes: node[3], pendingTasks: 1}; - try { - renderChildrenArray(request, task, children, -1); - } finally { - replayTask.replay.pendingTasks--; - if ( - replayTask.replay.pendingTasks === 0 && - replayTask.replay.nodes.length > 0 - ) { - throw new Error( - "Couldn't find all resumable slots by key/index during replaying. " + - "The tree doesn't match so React will fallback to client rendering.", - ); - } - replayTask.replay = replay; - } - // We finished rendering this node, so now we can consume this - // slot. This must happen after in case we rerender this task. - replayNodes.splice(j, 1); - break; - } + replayFragment( + request, + // $FlowFixMe: Refined. + task, + children, + childIndex, + ); task.keyPath = prevKeyPath; return; } @@ -2934,6 +2997,42 @@ function renderNode( throw x; } +function erroredReplay( + request: Request, + boundary: Root | SuspenseBoundary, + error: mixed, + replayNodes: ResumableNode[], +): void { + // Erroring during a replay doesn't actually cause an error by itself because + // that component has already rendered. What causes the error is the resumable + // points that we did not yet finish which will be below the point of the reset. + // For example, if we're replaying a path to a Suspense boundary that is not done + // that doesn't error the parent Suspense boundary. + // This might be a bit strange that the error in a parent gets thrown at a child. + // We log it only once and reuse the digest. + let errorDigest; + if ( + enablePostpone && + typeof error === 'object' && + error !== null && + error.$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (error: any); + logPostpone(request, postponeInstance.message); + // TODO: Figure out a better signal than a magic digest value. + errorDigest = 'POSTPONE'; + } else { + errorDigest = logRecoverableError(request, error); + } + abortRemainingResumableNodes( + request, + boundary, + replayNodes, + error, + errorDigest, + ); +} + function erroredTask( request: Request, boundary: Root | SuspenseBoundary, @@ -2955,9 +3054,6 @@ function erroredTask( errorDigest = logRecoverableError(request, error); } if (boundary === null) { - // TODO: If the shell errors during a replay, that's not a fatal error. Instead - // we should be able to recover by client rendering all the root boundaries in - // the ReplaySet and any already matched. fatalError(request, error); } else { boundary.pendingTasks--; @@ -3000,11 +3096,118 @@ function abortTaskSoft(this: Request, task: Task): void { } } +function abortRemainingSuspenseBoundary( + request: Request, + id: SuspenseBoundaryID, + rootSegmentID: number, + error: mixed, + errorDigest: ?string, +): void { + const resumedBoundary = createSuspenseBoundary( + request, + new Set(), + null, // The keyPath doesn't matter at this point so we don't bother rebuilding it. + ); + resumedBoundary.parentFlushed = true; + // We restore the same id of this boundary as was used during prerender. + resumedBoundary.id = id; + resumedBoundary.rootSegmentID = rootSegmentID; + + resumedBoundary.status = CLIENT_RENDERED; + resumedBoundary.errorDigest = errorDigest; + if (__DEV__) { + const errorPrefix = 'The server did not finish this Suspense boundary: '; + let errorMessage; + if (error && typeof error.message === 'string') { + errorMessage = errorPrefix + error.message; + } else { + // eslint-disable-next-line react-internal/safe-string-coercion + errorMessage = errorPrefix + String(error); + } + const previousTaskInDev = currentTaskInDEV; + currentTaskInDEV = null; + try { + captureBoundaryErrorDetailsDev(resumedBoundary, errorMessage); + } finally { + currentTaskInDEV = previousTaskInDev; + } + } + if (resumedBoundary.parentFlushed) { + request.clientRenderedBoundaries.push(resumedBoundary); + } +} + function abortRemainingResumableNodes( + request: Request, + boundary: Root | SuspenseBoundary, nodes: Array, error: mixed, + errorDigest: ?string, ): void { - // TODO: Abort any undiscovered Suspense boundaries in the ReplaySet. + for (let i = 0; i < nodes.length; i++) { + const node: any = nodes[i]; + switch (node[0]) { + case REPLAY_NODE: { + abortRemainingResumableNodes( + request, + boundary, + node[3], + error, + errorDigest, + ); + continue; + } + case REPLAY_SUSPENSE_BOUNDARY: { + const boundaryNode: ReplaySuspenseBoundary = node; + const id = boundaryNode[4]; + const rootSegmentID = boundaryNode[5]; + abortRemainingSuspenseBoundary( + request, + id, + rootSegmentID, + error, + errorDigest, + ); + continue; + } + case RESUME_SUSPENSE_BOUNDARY: { + const boundaryNode: ResumeSuspenseBoundary = node; + const id = boundaryNode[3]; + const rootSegmentID = boundaryNode[4]; + abortRemainingSuspenseBoundary( + request, + id, + rootSegmentID, + error, + errorDigest, + ); + continue; + } + case RESUME_ELEMENT: + case RESUME_SLOT: { + // We had something still to resume in the parent boundary. We must trigger + // the error on the parent boundary since it's not able to complete. + if (boundary === null) { + throw new Error( + 'We should not have any resumable nodes in the shell. ' + + 'This is a bug in React.', + ); + } else if (boundary.status !== CLIENT_RENDERED) { + boundary.status = CLIENT_RENDERED; + boundary.errorDigest = errorDigest; + if (__DEV__) { + captureBoundaryErrorDetailsDev(boundary, error); + } + if (boundary.parentFlushed) { + request.clientRenderedBoundaries.push(boundary); + } + } + continue; + } + } + } + // Empty the set, since we've cleared it now. + nodes.length = 0; } function abortTask(task: Task, request: Request, error: mixed): void { @@ -3012,30 +3215,41 @@ function abortTask(task: Task, request: Request, error: mixed): void { // client rendered mode. const boundary = task.blockedBoundary; const segment = task.blockedSegment; - if (segment === null) { - // $FlowFixMe: Refined. - const replay: ReplaySet = task.replay; - replay.pendingTasks--; - if (replay.pendingTasks === 0) { - abortRemainingResumableNodes(replay.nodes, error); - } - } else { + if (segment !== null) { segment.status = ABORTED; } if (boundary === null) { request.allPendingTasks--; - // We didn't complete the root so we have nothing to show. We can close - // the request; if (request.status !== CLOSING && request.status !== CLOSED) { - logRecoverableError(request, error); - fatalError(request, error); + const replay: null | ReplaySet = task.replay; + if (replay === null) { + // We didn't complete the root so we have nothing to show. We can close + // the request; + logRecoverableError(request, error); + fatalError(request, error); + } else { + // If the shell aborts during a replay, that's not a fatal error. Instead + // we should be able to recover by client rendering all the root boundaries in + // the ReplaySet. + replay.pendingTasks--; + if (replay.pendingTasks === 0 && replay.nodes.length > 0) { + const errorDigest = logRecoverableError(request, error); + abortRemainingResumableNodes( + request, + null, + replay.nodes, + error, + errorDigest, + ); + } + } } } else { boundary.pendingTasks--; if (boundary.status !== CLIENT_RENDERED) { boundary.status = CLIENT_RENDERED; - boundary.errorDigest = request.onError(error); + boundary.errorDigest = logRecoverableError(request, error); if (__DEV__) { const errorPrefix = 'The server did not finish this Suspense boundary: '; @@ -3145,8 +3359,11 @@ function finishedTask( // We can now cancel any pending task on the fallback since we won't need to show it anymore. // This needs to happen after we read the parentFlushed flags because aborting can finish // work which can trigger user code, which can start flushing, which can change those flags. - boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request); - boundary.fallbackAbortableTasks.clear(); + // If the boundary was POSTPONED, we still need to finish the fallback first. + if (boundary.status === COMPLETED) { + boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request); + boundary.fallbackAbortableTasks.clear(); + } } else { if (segment !== null && segment.parentFlushed) { // Our parent already flushed, so we need to schedule this segment to be emitted. @@ -3370,7 +3587,12 @@ function retryReplayTask(request: Request, task: ReplayTask): void { } task.replay.pendingTasks--; task.abortSet.delete(task); - erroredTask(request, task.blockedBoundary, x); + erroredReplay(request, task.blockedBoundary, x, task.replay.nodes); + request.allPendingTasks--; + if (request.allPendingTasks === 0) { + const onAllReady = request.onAllReady; + onAllReady(); + } return; } finally { if (enableFloat) { @@ -3778,9 +4000,6 @@ function flushCompletedQueues( // We haven't flushed the root yet so we don't need to check any other branches further down return; } - } else if (request.pendingRootTasks > 0) { - // We have not yet flushed the root segment so we early return - return; } if (enableFloat) { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 3f7bc8d47a87..6de63d819bdd 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -472,7 +472,7 @@ "484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details.", "485": "Cannot update form state while rendering.", "486": "It should not be possible to postpone at the root. This is a bug in React.", - "487": "Did not expect to see a Suspense boundary in this slot. The tree doesn't match so React will fallback to client rendering.", + "487": "We should not have any resumable nodes in the shell. This is a bug in React.", "488": "Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering.", "489": "Expected to see a component of type \"%s\" in this slot. The tree doesn't match so React will fallback to client rendering.", "490": "Expected to see a Suspense boundary in this slot. The tree doesn't match so React will fallback to client rendering."