[Flight] Add global onError handler (#21129)
* Add onError option to Flight Server The callback is called any time an error is generated in a server component. This allows it to be logged on a server if needed. It'll still be rethrown on the client so it can be logged there too but in case it never reaches the client, here's a way to make sure it doesn't get lost. * Add fatal error handling
Sebastian Markbåge committed
Mar 29, 2021 at 22:36 UTC
d1294c9d404de748b49dd6543144367f90a5e6b0
9 files changed
+106
-20
packages/react-noop-renderer/src/ReactNoopFlightServer.js
+6
-1
@@ -54,13 +54,18 @@ const ReactNoopFlightServer = ReactFlightServer({
54
},
55
});
56
57
-function render(model: ReactModel): Destination {
57
+type Options = {
58
+ onError?: (error: mixed) => void,
59
+};
60
+
61
+function render(model: ReactModel, options?: Options): Destination {
62
const destination: Destination = [];
63
const bundlerConfig = undefined;
64
const request = ReactNoopFlightServer.createRequest(
65
model,
66
destination,
67
bundlerConfig,
68
+ options ? options.onError : undefined,
69
);
70
ReactNoopFlightServer.startWork(request);
71
return destination;
packages/react-server-dom-relay/src/ReactFlightDOMRelayServer.js
+11
-1
@@ -15,12 +15,22 @@ import type {
15
16
import {createRequest, startWork} from 'react-server/src/ReactFlightServer';
17
18
+type Options = {
19
+ onError?: (error: mixed) => void,
20
+};
21
+
22
function render(
23
model: ReactModel,
24
destination: Destination,
25
config: BundlerConfig,
26
+ options?: Options,
27
): void {
23
- const request = createRequest(model, destination, config);
28
+ const request = createRequest(
29
+ model,
30
+ destination,
31
+ config,
32
+ options ? options.onError : undefined,
33
+ );
34
startWork(request);
35
}
36
packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js
+6
-1
@@ -26,6 +26,7 @@ import {resolveModelToJSON} from 'react-server/src/ReactFlightServer';
26
import {
27
emitRow,
28
resolveModuleMetaData as resolveModuleMetaDataImpl,
29
+ close,
30
} from 'ReactFlightDOMRelayServerIntegration';
31
32
export type {
@@ -146,4 +147,8 @@ export function writeChunk(destination: Destination, chunk: Chunk): boolean {
147
148
export function completeWriting(destination: Destination) {}
149
149
-export {close} from 'ReactFlightDOMRelayServerIntegration';
150
+export {close};
151
+
152
+export function closeWithError(destination: Destination, error: mixed): void {
153
+ close(destination);
154
+}
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+11
-1
@@ -16,14 +16,24 @@ import {
16
startFlowing,
17
} from 'react-server/src/ReactFlightServer';
18
19
+type Options = {
20
+ onError?: (error: mixed) => void,
21
+};
22
+
23
function renderToReadableStream(
24
model: ReactModel,
25
webpackMap: BundlerConfig,
26
+ options?: Options,
27
): ReadableStream {
28
let request;
29
return new ReadableStream({
30
start(controller) {
26
- request = createRequest(model, controller, webpackMap);
31
+ request = createRequest(
32
+ model,
33
+ controller,
34
+ webpackMap,
35
+ options ? options.onError : undefined,
36
+ );
37
startWork(request);
38
},
39
pull(controller) {
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+11
-1
@@ -21,12 +21,22 @@ function createDrainHandler(destination, request) {
21
return () => startFlowing(request);
22
}
23
24
+type Options = {
25
+ onError?: (error: mixed) => void,
26
+};
27
+
28
function pipeToNodeWritable(
29
model: ReactModel,
30
destination: Writable,
31
webpackMap: BundlerConfig,
32
+ options?: Options,
33
): void {
29
- const request = createRequest(model, destination, webpackMap);
34
+ const request = createRequest(
35
+ model,
36
+ destination,
37
+ webpackMap,
38
+ options ? options.onError : undefined,
39
+ );
40
destination.on('drain', createDrainHandler(destination, request));
41
startWork(request);
42
}
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+15
-2
@@ -256,6 +256,7 @@ describe('ReactFlightDOM', () => {
256
257
// @gate experimental
258
it('should progressively reveal server components', async () => {
259
+ let reportedErrors = [];
260
const {Suspense} = React;
261
262
// Client Components
@@ -374,7 +375,11 @@ describe('ReactFlightDOM', () => {
375
}
376
377
const {writable, readable} = getTestStream();
377
- ReactServerDOMWriter.pipeToNodeWritable(model, writable, webpackMap);
378
+ ReactServerDOMWriter.pipeToNodeWritable(model, writable, webpackMap, {
379
+ onError(x) {
380
+ reportedErrors.push(x);
381
+ },
382
+ });
383
const response = ReactServerDOMReader.createFromReadableStream(readable);
384
385
const container = document.createElement('div');
@@ -407,9 +412,12 @@ describe('ReactFlightDOM', () => {
412
'<p>(loading games)</p>',
413
);
414
415
+ expect(reportedErrors).toEqual([]);
416
+
417
+ const theError = new Error('Game over');
418
// Let's *fail* loading games.
419
await act(async () => {
412
- rejectGames(new Error('Game over'));
420
+ rejectGames(theError);
421
});
422
expect(container.innerHTML).toBe(
423
'<div>:name::avatar:</div>' +
@@ -418,6 +426,9 @@ describe('ReactFlightDOM', () => {
426
'<p>Game over</p>', // TODO: should not have message in prod.
427
);
428
429
+ expect(reportedErrors).toEqual([theError]);
430
+ reportedErrors = [];
431
+
432
// We can now show the sidebar.
433
await act(async () => {
434
resolvePhotos();
@@ -439,6 +450,8 @@ describe('ReactFlightDOM', () => {
450
'<div>:posts:</div>' +
451
'<p>Game over</p>', // TODO: should not have message in prod.
452
);
453
+
454
+ expect(reportedErrors).toEqual([]);
455
});
456
457
// @gate experimental
packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js
+6
-1
@@ -25,6 +25,7 @@ import {resolveModelToJSON} from 'react-server/src/ReactFlightServer';
25
26
import {
27
emitRow,
28
+ close,
29
resolveModuleMetaData as resolveModuleMetaDataImpl,
30
} from 'ReactFlightNativeRelayServerIntegration';
31
@@ -146,4 +147,8 @@ export function writeChunk(destination: Destination, chunk: Chunk): boolean {
147
148
export function completeWriting(destination: Destination) {}
149
149
-export {close} from 'ReactFlightNativeRelayServerIntegration';
150
+export {close};
151
+
152
+export function closeWithError(destination: Destination, error: mixed): void {
153
+ close(destination);
154
+}
packages/react-server/src/ReactFlightServer.js
+39
-12
@@ -24,6 +24,7 @@ import {
24
completeWriting,
25
flushBuffered,
26
close,
27
+ closeWithError,
28
processModelChunk,
29
processModuleChunk,
30
processSymbolChunk,
@@ -83,16 +84,20 @@ export type Request = {
84
completedErrorChunks: Array<Chunk>,
85
writtenSymbols: Map<Symbol, number>,
86
writtenModules: Map<ModuleKey, number>,
87
+ onError: (error: mixed) => void,
88
flowing: boolean,
89
toJSON: (key: string, value: ReactModel) => ReactJSONValue,
90
};
91
92
const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
93
94
+function defaultErrorHandler() {}
95
+
96
export function createRequest(
97
model: ReactModel,
98
destination: Destination,
99
bundlerConfig: BundlerConfig,
100
+ onError: (error: mixed) => void = defaultErrorHandler,
101
): Request {
102
const pingedSegments = [];
103
const request = {
@@ -107,6 +112,7 @@ export function createRequest(
112
completedErrorChunks: [],
113
writtenSymbols: new Map(),
114
writtenModules: new Map(),
115
+ onError,
116
flowing: false,
117
toJSON: function(key: string, value: ReactModel): ReactJSONValue {
118
return resolveModelToJSON(request, this, key, value);
@@ -413,6 +419,7 @@ export function resolveModelToJSON(
419
x.then(ping, ping);
420
return serializeByRefID(newSegment.id);
421
} else {
422
+ reportError(request, x);
423
// Something errored. We'll still send everything we have up until this point.
424
// We'll replace this element with a lazy reference that throws on the client
425
// once it gets rendered.
@@ -589,6 +596,15 @@ export function resolveModelToJSON(
596
);
597
}
598
599
+function reportError(request: Request, error: mixed): void {
600
+ request.onError(error);
601
+}
602
+
603
+function fatalError(request: Request, error: mixed): void {
604
+ // This is called outside error handling code such as if an error happens in React internals.
605
+ closeWithError(request.destination, error);
606
+}
607
+
608
function emitErrorChunk(request: Request, id: number, error: mixed): void {
609
// TODO: We should not leak error messages to the client in prod.
610
// Give this an error code instead and log on the server.
@@ -654,6 +670,7 @@ function retrySegment(request: Request, segment: Segment): void {
670
x.then(ping, ping);
671
return;
672
} else {
673
+ reportError(request, x);
674
// This errored, we need to serialize this error to the
675
emitErrorChunk(request, segment.id, x);
676
}
@@ -666,18 +683,23 @@ function performWork(request: Request): void {
683
ReactCurrentDispatcher.current = Dispatcher;
684
currentCache = request.cache;
685
669
- const pingedSegments = request.pingedSegments;
670
- request.pingedSegments = [];
671
- for (let i = 0; i < pingedSegments.length; i++) {
672
- const segment = pingedSegments[i];
673
- retrySegment(request, segment);
674
- }
675
- if (request.flowing) {
676
- flushCompletedChunks(request);
686
+ try {
687
+ const pingedSegments = request.pingedSegments;
688
+ request.pingedSegments = [];
689
+ for (let i = 0; i < pingedSegments.length; i++) {
690
+ const segment = pingedSegments[i];
691
+ retrySegment(request, segment);
692
+ }
693
+ if (request.flowing) {
694
+ flushCompletedChunks(request);
695
+ }
696
+ } catch (error) {
697
+ reportError(request, error);
698
+ fatalError(request, error);
699
+ } finally {
700
+ ReactCurrentDispatcher.current = prevDispatcher;
701
+ currentCache = prevCache;
702
}
678
-
679
- ReactCurrentDispatcher.current = prevDispatcher;
680
- currentCache = prevCache;
703
}
704
705
let reentrant = false;
@@ -749,7 +771,12 @@ export function startWork(request: Request): void {
771
772
export function startFlowing(request: Request): void {
773
request.flowing = true;
752
- flushCompletedChunks(request);
774
+ try {
775
+ flushCompletedChunks(request);
776
+ } catch (error) {
777
+ reportError(request, error);
778
+ fatalError(request, error);
779
+ }
780
}
781
782
function unsupportedHook(): void {
packages/react-server/src/ReactFlightServerConfigStream.js
+1
@@ -126,4 +126,5 @@ export {
126
writeChunk,
127
completeWriting,
128
close,
129
+ closeWithError,
130
} from './ReactServerStreamConfig';