Skip to content

Commit 326832a

Browse files
authored
[Flight] Serialize Error Values (#31104)
The idea is that the RSC protocol is a superset of Structured Clone. #25687 One exception that we left out was serializing Error objects as values. We serialize "throws" or "rejections" as Error (regardless of their type) but not Error values. This fixes that by serializing `Error` objects. We don't include digest in this case since we don't call `onError` and it's not really expected that you'd log it on the server with some way to look it up. In general this is not super useful outside throws. Especially since we hide their values in prod. However, there is one case where it is quite useful. When you replay console logs in DEV you might often log an Error object within the scope of a Server Component. E.g. the default RSC error handling just console.error and error object. Before this would just be an empty object due to our lax console log serialization: <img width="1355" alt="Screenshot 2024-09-30 at 2 24 03 PM" src="https://github.com/user-attachments/assets/694b3fd3-f95f-4863-9321-bcea3f5c5db4"> After: <img width="1348" alt="Screenshot 2024-09-30 at 2 36 48 PM" src="https://github.com/user-attachments/assets/834b129d-220d-43a2-a2f4-2eb06921747d"> TODO for a follow up: Flight Reply direction. This direction doesn't actually serialize thrown errors because they always reject the serialization.
1 parent c67e241 commit 326832a

File tree

3 files changed

+112
-38
lines changed

3 files changed

+112
-38
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 36 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,6 +1287,21 @@ function parseModelString(
12871287
createFormData,
12881288
);
12891289
}
1290+
case 'Z': {
1291+
// Error
1292+
if (__DEV__) {
1293+
const ref = value.slice(2);
1294+
return getOutlinedModel(
1295+
response,
1296+
ref,
1297+
parentObject,
1298+
key,
1299+
resolveErrorDev,
1300+
);
1301+
} else {
1302+
return resolveErrorProd(response);
1303+
}
1304+
}
12901305
case 'i': {
12911306
// Iterator
12921307
const ref = value.slice(2);
@@ -1881,11 +1896,7 @@ function formatV8Stack(
18811896
}
18821897

18831898
type ErrorWithDigest = Error & {digest?: string};
1884-
function resolveErrorProd(
1885-
response: Response,
1886-
id: number,
1887-
digest: string,
1888-
): void {
1899+
function resolveErrorProd(response: Response): Error {
18891900
if (__DEV__) {
18901901
// These errors should never make it into a build so we don't need to encode them in codes.json
18911902
// eslint-disable-next-line react-internal/prod-error-codes
@@ -1899,25 +1910,17 @@ function resolveErrorProd(
18991910
' may provide additional details about the nature of the error.',
19001911
);
19011912
error.stack = 'Error: ' + error.message;
1902-
(error: any).digest = digest;
1903-
const errorWithDigest: ErrorWithDigest = (error: any);
1904-
const chunks = response._chunks;
1905-
const chunk = chunks.get(id);
1906-
if (!chunk) {
1907-
chunks.set(id, createErrorChunk(response, errorWithDigest));
1908-
} else {
1909-
triggerErrorOnChunk(chunk, errorWithDigest);
1910-
}
1913+
return error;
19111914
}
19121915

19131916
function resolveErrorDev(
19141917
response: Response,
1915-
id: number,
1916-
digest: string,
1917-
message: string,
1918-
stack: ReactStackTrace,
1919-
env: string,
1920-
): void {
1918+
errorInfo: {message: string, stack: ReactStackTrace, env: string, ...},
1919+
): Error {
1920+
const message: string = errorInfo.message;
1921+
const stack: ReactStackTrace = errorInfo.stack;
1922+
const env: string = errorInfo.env;
1923+
19211924
if (!__DEV__) {
19221925
// These errors should never make it into a build so we don't need to encode them in codes.json
19231926
// eslint-disable-next-line react-internal/prod-error-codes
@@ -1957,16 +1960,8 @@ function resolveErrorDev(
19571960
}
19581961
}
19591962

1960-
(error: any).digest = digest;
19611963
(error: any).environmentName = env;
1962-
const errorWithDigest: ErrorWithDigest = (error: any);
1963-
const chunks = response._chunks;
1964-
const chunk = chunks.get(id);
1965-
if (!chunk) {
1966-
chunks.set(id, createErrorChunk(response, errorWithDigest));
1967-
} else {
1968-
triggerErrorOnChunk(chunk, errorWithDigest);
1969-
}
1964+
return error;
19701965
}
19711966

19721967
function resolvePostponeProd(response: Response, id: number): void {
@@ -2622,17 +2617,20 @@ function processFullStringRow(
26222617
}
26232618
case 69 /* "E" */: {
26242619
const errorInfo = JSON.parse(row);
2620+
let error;
26252621
if (__DEV__) {
2626-
resolveErrorDev(
2627-
response,
2628-
id,
2629-
errorInfo.digest,
2630-
errorInfo.message,
2631-
errorInfo.stack,
2632-
errorInfo.env,
2633-
);
2622+
error = resolveErrorDev(response, errorInfo);
2623+
} else {
2624+
error = resolveErrorProd(response);
2625+
}
2626+
(error: any).digest = errorInfo.digest;
2627+
const errorWithDigest: ErrorWithDigest = (error: any);
2628+
const chunks = response._chunks;
2629+
const chunk = chunks.get(id);
2630+
if (!chunk) {
2631+
chunks.set(id, createErrorChunk(response, errorWithDigest));
26342632
} else {
2635-
resolveErrorProd(response, id, errorInfo.digest);
2633+
triggerErrorOnChunk(chunk, errorWithDigest);
26362634
}
26372635
return;
26382636
}

packages/react-client/src/__tests__/ReactFlight-test.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,46 @@ describe('ReactFlight', () => {
653653
`);
654654
});
655655

656+
it('can transport Error objects as values', async () => {
657+
function ComponentClient({prop}) {
658+
return `
659+
is error: ${prop instanceof Error}
660+
message: ${prop.message}
661+
stack: ${normalizeCodeLocInfo(prop.stack).split('\n').slice(0, 2).join('\n')}
662+
environmentName: ${prop.environmentName}
663+
`;
664+
}
665+
const Component = clientReference(ComponentClient);
666+
667+
function ServerComponent() {
668+
const error = new Error('hello');
669+
return <Component prop={error} />;
670+
}
671+
672+
const transport = ReactNoopFlightServer.render(<ServerComponent />);
673+
674+
await act(async () => {
675+
ReactNoop.render(await ReactNoopFlightClient.read(transport));
676+
});
677+
678+
if (__DEV__) {
679+
expect(ReactNoop).toMatchRenderedOutput(`
680+
is error: true
681+
message: hello
682+
stack: Error: hello
683+
in ServerComponent (at **)
684+
environmentName: Server
685+
`);
686+
} else {
687+
expect(ReactNoop).toMatchRenderedOutput(`
688+
is error: true
689+
message: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.
690+
stack: Error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.
691+
environmentName: undefined
692+
`);
693+
}
694+
});
695+
656696
it('can transport cyclic objects', async () => {
657697
function ComponentClient({prop}) {
658698
expect(prop.obj.obj.obj).toBe(prop.obj.obj);

packages/react-server/src/ReactFlightServer.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2688,6 +2688,9 @@ function renderModelDestructive(
26882688
if (typeof FormData === 'function' && value instanceof FormData) {
26892689
return serializeFormData(request, value);
26902690
}
2691+
if (value instanceof Error) {
2692+
return serializeErrorValue(request, value);
2693+
}
26912694

26922695
if (enableBinaryFlight) {
26932696
if (value instanceof ArrayBuffer) {
@@ -3114,6 +3117,36 @@ function emitPostponeChunk(
31143117
request.completedErrorChunks.push(processedChunk);
31153118
}
31163119

3120+
function serializeErrorValue(request: Request, error: Error): string {
3121+
if (__DEV__) {
3122+
let message;
3123+
let stack: ReactStackTrace;
3124+
let env = (0, request.environmentName)();
3125+
try {
3126+
// eslint-disable-next-line react-internal/safe-string-coercion
3127+
message = String(error.message);
3128+
stack = filterStackTrace(request, error, 0);
3129+
const errorEnv = (error: any).environmentName;
3130+
if (typeof errorEnv === 'string') {
3131+
// This probably came from another FlightClient as a pass through.
3132+
// Keep the environment name.
3133+
env = errorEnv;
3134+
}
3135+
} catch (x) {
3136+
message = 'An error occurred but serializing the error message failed.';
3137+
stack = [];
3138+
}
3139+
const errorInfo = {message, stack, env};
3140+
const id = outlineModel(request, errorInfo);
3141+
return '$Z' + id.toString(16);
3142+
} else {
3143+
// In prod we don't emit any information about this Error object to avoid
3144+
// unintentional leaks. Since this doesn't actually throw on the server
3145+
// we don't go through onError and so don't register any digest neither.
3146+
return '$Z';
3147+
}
3148+
}
3149+
31173150
function emitErrorChunk(
31183151
request: Request,
31193152
id: number,
@@ -3403,6 +3436,9 @@ function renderConsoleValue(
34033436
if (typeof FormData === 'function' && value instanceof FormData) {
34043437
return serializeFormData(request, value);
34053438
}
3439+
if (value instanceof Error) {
3440+
return serializeErrorValue(request, value);
3441+
}
34063442

34073443
if (enableBinaryFlight) {
34083444
if (value instanceof ArrayBuffer) {

0 commit comments

Comments
 (0)