Skip to content

Commit

Permalink
Encode Errors during serialization as Lazy at the Nearest Element
Browse files Browse the repository at this point in the history
We feel comfortable turning any Element into Lazy since it serializes as
Node. So if any error happens inside of the deserialization such as if
a direct reference errored or a client reference failed to load we can
scope it to that element. That way if any Error boundaries were on the
stack inside the same model, they can handle the error.

This also gives us better debug info for things like serialization errors
because they now can get a stack trace pointing to the exact JSX.
  • Loading branch information
sebmarkbage committed Jun 10, 2024
1 parent c0fe18d commit a8eb44b
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 13 deletions.
74 changes: 71 additions & 3 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -732,8 +732,32 @@ function createElement(
// This is effectively the complete phase.
initializingHandler = handler.parent;
if (handler.errored) {
// TODO: Encode the error as Lazy.
throw handler.value;
// Something errored inside this Element's props. We can turn this Element
// into a Lazy so that we can still render up until that Lazy is rendered.
const erroredChunk: ErroredChunk<React$Element<any>> = createErrorChunk(
response,
handler.value,
);
if (__DEV__) {
// Conceptually the error happened inside this Element but right before
// it was rendered. We don't have a client side component to render but
// we can add some DebugInfo to explain that this was conceptually a
// Server side error that errored inside this element. That way any stack
// traces will point to the nearest JSX that errored - e.g. during
// serialization.
const erroredComponent: ReactComponentInfo = {
name: getComponentNameFromType(element.type) || '',
owner: element._owner,
};
if (enableOwnerStacks) {
// $FlowFixMe[cannot-write]
erroredComponent.stack = element._debugStack;
// $FlowFixMe[cannot-write]
erroredComponent.task = element._debugTask;
}
erroredChunk._debugInfo = [erroredComponent];
}
return createLazyChunkWrapper(erroredChunk);
}
if (handler.deps > 0) {
// We have blocked references inside this Element but we can turn this into
Expand Down Expand Up @@ -861,12 +885,41 @@ function waitForReference<T>(
// Promise.all.
return;
}
const blockedValue = handler.value;
handler.errored = true;
handler.value = error;
const chunk = handler.chunk;
if (chunk === null || chunk.status !== BLOCKED) {
return;
}

if (__DEV__) {
if (
typeof blockedValue === 'object' &&
blockedValue !== null &&
blockedValue.$$typeof === REACT_ELEMENT_TYPE
) {
const element = blockedValue;
// Conceptually the error happened inside this Element but right before
// it was rendered. We don't have a client side component to render but
// we can add some DebugInfo to explain that this was conceptually a
// Server side error that errored inside this element. That way any stack
// traces will point to the nearest JSX that errored - e.g. during
// serialization.
const erroredComponent: ReactComponentInfo = {
name: getComponentNameFromType(element.type) || '',
owner: element._owner,
};
if (enableOwnerStacks) {
// $FlowFixMe[cannot-write]
erroredComponent.stack = element._debugStack;
// $FlowFixMe[cannot-write]
erroredComponent.task = element._debugTask;
}
chunk._debugInfo = [erroredComponent];
}
}

triggerErrorOnChunk(chunk, error);
}

Expand Down Expand Up @@ -961,7 +1014,22 @@ function getOutlinedModel<T>(
case BLOCKED:
return waitForReference(chunk, parentObject, key, response, map, path);
default:
throw chunk.reason;
// This is an error. Instead of erroring directly, we're going to encode this on
// an initialization handler so that we can catch it at the nearest Element.
if (initializingHandler) {
initializingHandler.errored = true;
initializingHandler.value = chunk.reason;
} else {
initializingHandler = {
parent: null,
chunk: null,
value: chunk.reason,
deps: 0,
errored: true,
};
}
// Placeholder
return (null: any);
}
}

Expand Down
40 changes: 40 additions & 0 deletions packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,46 @@ describe('ReactFlight', () => {
});
});

it('should handle serialization errors in element inside error boundary', async () => {
const ClientErrorBoundary = clientReference(ErrorBoundary);

const expectedStack = __DEV__
? '\n in div' + '\n in ErrorBoundary (at **)' + '\n in App'
: '\n in ErrorBoundary (at **)';

function App() {
return (
<ClientErrorBoundary
expectedMessage="Event handlers cannot be passed to Client Component props."
expectedStack={expectedStack}>
<div onClick={function () {}} />
</ClientErrorBoundary>
);
}

const transport = ReactNoopFlightServer.render(<App />, {
onError(x) {
if (__DEV__) {
return 'a dev digest';
}
if (x instanceof Error) {
return `digest("${x.message}")`;
} else if (Array.isArray(x)) {
return `digest([])`;
} else if (typeof x === 'object' && x !== null) {
return `digest({})`;
}
return `digest(${String(x)})`;
},
});

await act(() => {
startTransition(() => {
ReactNoop.render(ReactNoopFlightClient.read(transport));
});
});
});

it('should include server components in warning stacks', async () => {
function Component() {
// Trigger key warning
Expand Down
21 changes: 11 additions & 10 deletions packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1039,7 +1039,9 @@ function renderFunctionComponent<Props>(
owner: owner,
};
if (enableOwnerStacks) {
(componentDebugInfo: any).stack = stack;
// $FlowFixMe[prop-missing]
// $FlowFixMe[cannot-write]
componentDebugInfo.stack = stack;
}
// We outline this model eagerly so that we can refer to by reference as an owner.
// If we had a smarter way to dedupe we might not have to do this if there ends up
Expand Down Expand Up @@ -2055,20 +2057,19 @@ function renderModel(
task.keyPath = prevKeyPath;
task.implicitSlot = prevImplicitSlot;

// Something errored. We'll still send everything we have up until this point.
request.pendingChunks++;
const errorId = request.nextChunkId++;
const digest = logRecoverableError(request, x);
emitErrorChunk(request, errorId, digest, x);
if (wasReactNode) {
// Something errored. We'll still send everything we have up until this point.
// We'll replace this element with a lazy reference that throws on the client
// once it gets rendered.
request.pendingChunks++;
const errorId = request.nextChunkId++;
const digest = logRecoverableError(request, x);
emitErrorChunk(request, errorId, digest, x);
return serializeLazyID(errorId);
}
// Something errored but it was not in a React Node. There's no need to serialize
// it by value because it'll just error the whole parent row anyway so we can
// just stop any siblings and error the whole parent row.
throw x;
// If we don't know if it was a React Node we render a direct reference and let
// the client deal with it.
return serializeByValueID(errorId);
}
}

Expand Down

0 comments on commit a8eb44b

Please sign in to comment.