@samitouri / QOS-React / commits / d1294c9d40

[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';