[Fizz] Reset error component stack and fix error messages (#27456)

The way we collect component stacks right now are pretty fragile.

We expect that we'll call captureBoundaryErrorDetailsDev whenever an
error happens. That resets lastBoundaryErrorComponentStackDev to null
but if we don't, it just lingers and we don't set it to anything new
then which leaks the previous component stack into the next time we have
an error. So we need to reset it in a bunch of places.

This is still broken with erroredReplay because it has the inverse
problem that abortRemainingReplayNodes can call
captureBoundaryErrorDetailsDev more than one time. So the second
boundary won't get a stack.

We probably should try to figure out an alternative way to carry along
the stack. Perhaps WeakMap keyed by the error object.

This also fixes an issue where we weren't invoking the onShellReady
event if we error a replay. That event is a bit weird for resuming
because we're probably really supposed to just invoke it immediately if
we have already flushed the shell in the prerender which is always atm.
Right now, it gets invoked later than necessary because you could have a
resumed hole ready before a sibling in the shell is ready and that's
blocked.
This commit is contained in:
Sebastian Markbåge
2023-10-04 16:48:12 -04:00
committed by GitHub
parent 6f13243957
commit 0fba3ecf73
3 changed files with 188 additions and 19 deletions

View File

@@ -981,4 +981,149 @@ describe('ReactDOMFizzStaticBrowser', () => {
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
});
// @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: <span />};
});
function App() {
const children = (
<Suspense fallback="Loading...">
<Postpone />
</Suspense>
);
return (
<>
<div>{prerendering ? <Wrapper>{children}</Wrapper> : children}</div>
<div>
{prerendering ? (
<Suspense fallback="Loading...">
<div>
<Postpone />
</div>
</Suspense>
) : (
lazySpan
)}
</div>
</>
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual([
<div>Loading...</div>,
<div>Loading...</div>,
]);
prerendering = false;
const errors = [];
const resumed = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{
onError(x) {
errors.push(x.message);
},
},
);
expect(errors).toEqual([
'Expected the resume to render <Wrapper> in this slot but instead it rendered <Suspense>. ' +
"The tree doesn't match so React will fallback to client rendering.",
'Expected the resume to render <Suspense> in this slot but instead it rendered <span>. ' +
"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([
<div>Loading...</div>,
<div>Loading...</div>,
]);
});
// @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 (
<div>
<Suspense fallback="Loading...">
<Postpone />
</Suspense>
</div>
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
prerendering = false;
const controller = new AbortController();
const errors = [];
const resumedPromise = ReactDOMFizzServer.resume(
<App />,
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(<div>Loading...</div>);
});
});

View File

@@ -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;

View File

@@ -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.",