diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 9ec0f2c97c9fb..9f8bb2fc07fc5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -981,4 +981,149 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(getVisibleChildren(container)).toEqual(
Hello
); }); + + // @gate enablePostpone + it('errors if the replay does not line up', async () => { + let prerendering = true; + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + function Wrapper({children}) { + return children; + } + + const lazySpan = React.lazy(async () => { + await 0; + return {default: }; + }); + + function App() { + const children = ( + + + + ); + return ( + <> +
{prerendering ? {children} : children}
+
+ {prerendering ? ( + +
+ +
+
+ ) : ( + lazySpan + )} +
+ + ); + } + + const prerendered = await ReactDOMFizzStatic.prerender(); + expect(prerendered.postponed).not.toBe(null); + + await readIntoContainer(prerendered.prelude); + + expect(getVisibleChildren(container)).toEqual([ +
Loading...
, +
Loading...
, + ]); + + prerendering = false; + + const errors = []; + const resumed = await ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + { + onError(x) { + errors.push(x.message); + }, + }, + ); + + expect(errors).toEqual([ + 'Expected the resume to render in this slot but instead it rendered . ' + + "The tree doesn't match so React will fallback to client rendering.", + 'Expected the resume to render in this slot but instead it rendered . ' + + "The tree doesn't match so React will fallback to client rendering.", + ]); + + // TODO: Test the component stack but we don't expose it to the server yet. + + await readIntoContainer(resumed); + + // Client rendered + expect(getVisibleChildren(container)).toEqual([ +
Loading...
, +
Loading...
, + ]); + }); + + // @gate enablePostpone + it('can abort the resume', async () => { + let prerendering = true; + const infinitePromise = new Promise(() => {}); + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + function App() { + if (!prerendering) { + React.use(infinitePromise); + } + return ( +
+ + + +
+ ); + } + + const prerendered = await ReactDOMFizzStatic.prerender(); + expect(prerendered.postponed).not.toBe(null); + + await readIntoContainer(prerendered.prelude); + + expect(getVisibleChildren(container)).toEqual(
Loading...
); + + prerendering = false; + + const controller = new AbortController(); + + const errors = []; + + const resumedPromise = ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + { + signal: controller.signal, + onError(x) { + errors.push(x); + }, + }, + ); + + controller.abort('abort'); + + const resumed = await resumedPromise; + await resumed.allReady; + + expect(errors).toEqual(['abort']); + + await readIntoContainer(resumed); + + // Client rendered + expect(getVisibleChildren(container)).toEqual(
Loading...
); + }); }); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 951b3e07930a2..00b1c29843599 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1110,7 +1110,6 @@ function replaySuspenseBoundary( // on preparing fallbacks if we don't have any more main content to task on. request.pingedTasks.push(suspendedFallbackTask); - // TODO: Should this be in the finally? popComponentStackInDEV(task); } @@ -1953,17 +1952,19 @@ function replayElement( if (keyOrIndex !== node[1]) { continue; } - // Let's double check that the component name matches as a precaution. - if (name !== null && name !== node[0]) { - throw new Error( - 'Expected to see a component of type "' + - name + - '" in this slot. ' + - "The tree doesn't match so React will fallback to client rendering.", - ); - } if (node.length === 4) { // Matched a replayable path. + // Let's double check that the component name matches as a precaution. + if (name !== null && name !== node[0]) { + throw new Error( + 'Expected the resume to render <' + + (node[0]: any) + + '> in this slot but instead it rendered <' + + name + + '>. ' + + "The tree doesn't match so React will fallback to client rendering.", + ); + } const childNodes = node[2]; const childSlots = node[3]; task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1}; @@ -2009,8 +2010,13 @@ function replayElement( } else { // Let's double check that the component type matches. if (type !== REACT_SUSPENSE_TYPE) { + const expectedType = 'Suspense'; throw new Error( - 'Expected to see a Suspense boundary in this slot. ' + + 'Expected the resume to render <' + + expectedType + + '> in this slot but instead it rendered <' + + (getComponentNameFromType(type) || 'Unknown') + + '>. ' + "The tree doesn't match so React will fallback to client rendering.", ); } @@ -2378,6 +2384,7 @@ function replayFragment( // 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. + // This is an error, stash the component stack if it is null. erroredReplay(request, task.blockedBoundary, x, childNodes, childSlots); } finally { task.replay.pendingTasks--; @@ -2849,6 +2856,7 @@ function renderNode( if (__DEV__) { task.componentStack = previousComponentStack; } + lastBoundaryErrorComponentStackDev = null; return; } } @@ -2930,6 +2938,7 @@ function erroredTask( errorDigest = logRecoverableError(request, error); } if (boundary === null) { + lastBoundaryErrorComponentStackDev = null; fatalError(request, error); } else { boundary.pendingTasks--; @@ -2949,6 +2958,8 @@ function erroredTask( // We reuse the same queue for errors. request.clientRenderedBoundaries.push(boundary); } + } else { + lastBoundaryErrorComponentStackDev = null; } } @@ -3077,7 +3088,6 @@ function abortTask(task: Task, request: Request, error: mixed): void { } if (boundary === null) { - request.allPendingTasks--; if (request.status !== CLOSING && request.status !== CLOSED) { const replay: null | ReplaySet = task.replay; if (replay === null) { @@ -3085,6 +3095,7 @@ function abortTask(task: Task, request: Request, error: mixed): void { // the request; logRecoverableError(request, error); fatalError(request, error); + return; } 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 @@ -3101,6 +3112,12 @@ function abortTask(task: Task, request: Request, error: mixed): void { errorDigest, ); } + request.pendingRootTasks--; + if (request.pendingRootTasks === 0) { + request.onShellError = noop; + const onShellReady = request.onShellReady; + onShellReady(); + } } } } else { @@ -3137,12 +3154,12 @@ function abortTask(task: Task, request: Request, error: mixed): void { abortTask(fallbackTask, request, error), ); boundary.fallbackAbortableTasks.clear(); + } - request.allPendingTasks--; - if (request.allPendingTasks === 0) { - const onAllReady = request.onAllReady; - onAllReady(); - } + request.allPendingTasks--; + if (request.allPendingTasks === 0) { + const onAllReady = request.onAllReady; + onAllReady(); } } @@ -3365,6 +3382,7 @@ function retryRenderTask( logPostpone(request, postponeInstance.message); trackPostpone(request, trackedPostpones, task, segment); finishedTask(request, task.blockedBoundary, segment); + lastBoundaryErrorComponentStackDev = null; return; } } @@ -3452,6 +3470,12 @@ function retryReplayTask(request: Request, task: ReplayTask): void { task.replay.nodes, task.replay.slots, ); + request.pendingRootTasks--; + if (request.pendingRootTasks === 0) { + request.onShellError = noop; + const onShellReady = request.onShellReady; + onShellReady(); + } request.allPendingTasks--; if (request.allPendingTasks === 0) { const onAllReady = request.onAllReady; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index be62358481bc0..851c058cf1b2d 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -474,8 +474,8 @@ "486": "It should not be possible to postpone at the root. This is a bug in React.", "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.", + "489": "Expected the resume to render <%s> in this slot but instead it rendered <%s>. The tree doesn't match so React will fallback to client rendering.", + "490": "Expected the resume to render <%s> in this slot but instead it rendered <%s>. The tree doesn't match so React will fallback to client rendering.", "491": "It should not be possible to postpone both at the root of an element as well as a slot below. This is a bug in React.", "492": "The \"react\" package in this environment is not configured correctly. The \"react-server\" condition must be enabled in any environment that runs React Server Components.", "493": "To taint a value, a lifetime must be defined by passing an object that holds the value.",