@samitouri / QOS-React / commits / be0de3455b

Log all Fiber errors w/ component stack (#8756)

A new module has been added (ReactFiberErrorLogger). This logs error information (call stack and component stack) to the console to make errors easier to debug. It also prompts users to use error boundaries if they are not already using them. In the future, perhaps this will be injectable, enabling users to provide their own handler for custom processing/logging. For the time being, this should help with issues like this / #2461.

Brian Vaughn committed Jan 13, 2017 at 14:45 UTC be0de3455bea6e17cf84f579d6c2418419c37d94
5 files changed +309 -10
scripts/fiber/tests-passing.txt
+4
@@ -1203,6 +1203,10 @@ src/renderers/shared/fiber/__tests__/ReactIncrementalErrorHandling-test.js
1203 * does not interrupt unmounting if detaching a ref throws
1204 * handles error thrown by host config while working on failed root
1205 * handles error thrown by top-level callback
1206 +* should log errors that occur during the begin phase
1207 +* should log errors that occur during the commit phase
1208 +* should ignore errors thrown in log method to prevent cycle
1209 +* should relay info about error boundary and retry attempts if applicable
1210
1211 src/renderers/shared/fiber/__tests__/ReactIncrementalReflection-test.js
1212 * handles isMounted even when the initial render is deferred
scripts/jest/test-framework-setup.js
+4
@@ -10,6 +10,10 @@ jest.mock('ReactDOMFeatureFlags', () => {
10 });
11 });
12
13 +// Error logging varies between Fiber and Stack;
14 +// Rather than fork dozens of tests, mock the error-logging file by default.
15 +jest.mock('ReactFiberErrorLogger');
16 +
17 var env = jasmine.getEnv();
18
19 var callCount = 0;
src/renderers/shared/fiber/ReactFiberErrorLogger.js new
+66
@@ -0,0 +1,66 @@
1 +/**
2 + * Copyright 2013-present, Facebook, Inc.
3 + * All rights reserved.
4 + *
5 + * This source code is licensed under the BSD-style license found in the
6 + * LICENSE file in the root directory of this source tree. An additional grant
7 + * of patent rights can be found in the PATENTS file in the same directory.
8 + *
9 + * @providesModule ReactFiberErrorLogger
10 + * @flow
11 + */
12 +
13 +'use strict';
14 +
15 +import type { CapturedError } from 'ReactFiberScheduler';
16 +
17 +function logCapturedError(capturedError : CapturedError) : void {
18 + if (__DEV__) {
19 + const {
20 + componentName,
21 + componentStack,
22 + error,
23 + errorBoundaryName,
24 + errorBoundaryFound,
25 + willRetry,
26 + } = capturedError;
27 +
28 + const componentNameMessage = componentName
29 + ? `React caught an error thrown by ${componentName}.`
30 + : 'React caught an error thrown by one of your components.';
31 +
32 + let errorBoundaryMessage;
33 + // errorBoundaryFound check is sufficient; errorBoundaryName check is to satisfy Flow.
34 + if (errorBoundaryFound && errorBoundaryName) {
35 + if (willRetry) {
36 + errorBoundaryMessage =
37 + `React will try to recreate this component tree from scratch ` +
38 + `using the error boundary you provided, ${errorBoundaryName}.`;
39 + } else {
40 + errorBoundaryMessage =
41 + `This error was initially handled by the error boundary ${errorBoundaryName}. ` +
42 + `Recreating the tree from scratch failed so React will unmount the tree.`;
43 + }
44 + } else {
45 + // TODO Link to unstable_handleError() documentation once it exists.
46 + errorBoundaryMessage =
47 + 'Consider adding an error boundary to your tree to customize error handling behavior.';
48 + }
49 +
50 + console.error(
51 + `${componentNameMessage} You should fix this error in your code. ${errorBoundaryMessage}\n\n` +
52 + `${error.stack}\n\n` +
53 + `The error was thrown in the following location: ${componentStack}`
54 + );
55 + }
56 +
57 + if (!__DEV__) {
58 + const { error } = capturedError;
59 + console.error(
60 + `React caught an error thrown by one of your components.\n\n${error.stack}`
61 + );
62 + }
63 +}
64 +
65 +exports.logCapturedError = logCapturedError;
66 +
src/renderers/shared/fiber/ReactFiberScheduler.js
+63 -10
@@ -17,16 +17,30 @@ import type { FiberRoot } from 'ReactFiberRoot';
17 import type { HostConfig, Deadline } from 'ReactFiberReconciler';
18 import type { PriorityLevel } from 'ReactPriorityLevel';
19
20 +export type CapturedError = {
21 + componentName : ?string,
22 + componentStack : string,
23 + error : Error,
24 + errorBoundaryFound : boolean,
25 + errorBoundaryName : ?string,
26 + willRetry : boolean,
27 +};
28 +
29 var {
30 popContextProvider,
31 } = require('ReactFiberContext');
32 const { reset } = require('ReactFiberStack');
33 +var {
34 + getStackAddendumByWorkInProgressFiber,
35 +} = require('ReactComponentTreeHook');
36 +var { logCapturedError } = require('ReactFiberErrorLogger');
37
38 var ReactFiberBeginWork = require('ReactFiberBeginWork');
39 var ReactFiberCompleteWork = require('ReactFiberCompleteWork');
40 var ReactFiberCommitWork = require('ReactFiberCommitWork');
41 var ReactFiberHostContext = require('ReactFiberHostContext');
42 var ReactCurrentOwner = require('ReactCurrentOwner');
43 +var getComponentName = require('getComponentName');
44
45 var { cloneFiber } = require('ReactFiber');
46
@@ -134,7 +148,7 @@ module.exports = function<T, P, I, TI, PI, C, CX>(config : HostConfig<T, P, I, T
148
149 // Keep track of which fibers have captured an error that need to be handled.
150 // Work is removed from this collection after unstable_handleError is called.
137 - let capturedErrors : Map<Fiber, Error> | null = null;
151 + let capturedErrors : Map<Fiber, CapturedError> | null = null;
152 // Keep track of which fibers have failed during the current batch of work.
153 // This is a different set than capturedErrors, because it is not reset until
154 // the end of the batch. This is needed to propagate errors correctly if a
@@ -797,6 +811,12 @@ module.exports = function<T, P, I, TI, PI, C, CX>(config : HostConfig<T, P, I, T
811
812 // Search for the nearest error boundary.
813 let boundary : ?Fiber = null;
814 +
815 + // Passed to logCapturedError()
816 + let errorBoundaryFound : boolean = false;
817 + let willRetry : boolean = false;
818 + let errorBoundaryName : ?string = null;
819 +
820 // Host containers are a special case. If the failed work itself is a host
821 // container, then it acts as its own boundary. In all other cases, we
822 // ignore the work itself and only search through the parents.
@@ -815,6 +835,9 @@ module.exports = function<T, P, I, TI, PI, C, CX>(config : HostConfig<T, P, I, T
835 if (node.tag === ClassComponent) {
836 const instance = node.stateNode;
837 if (typeof instance.unstable_handleError === 'function') {
838 + errorBoundaryFound = true;
839 + errorBoundaryName = getComponentName(node);
840 +
841 if (isFailedBoundary(node)) {
842 // This boundary is already in a failed state. The error should
843 // propagate to the next boundary — except in the
@@ -841,6 +864,7 @@ module.exports = function<T, P, I, TI, PI, C, CX>(config : HostConfig<T, P, I, T
864 } else {
865 // Found an error boundary!
866 boundary = node;
867 + willRetry = true;
868 }
869 }
870 } else if (node.tag === HostRoot) {
@@ -859,15 +883,29 @@ module.exports = function<T, P, I, TI, PI, C, CX>(config : HostConfig<T, P, I, T
883 }
884 failedBoundaries.add(boundary);
885
886 + // This method is unsafe outside of the begin and complete phases.
887 + // We might be in the commit phase when an error is captured.
888 + // The risk is that the return path from this Fiber may not be accurate.
889 + // That risk is acceptable given the benefit of providing users more context.
890 + const componentStack = getStackAddendumByWorkInProgressFiber(failedWork);
891 + const componentName = getComponentName(failedWork);
892 +
893 // Add to the collection of captured errors. This is stored as a global
863 - // map of errors keyed by the boundaries that capture them. We mostly
864 - // use this Map as a Set; it's a Map only to avoid adding a field to Fiber
865 - // to store the error.
894 + // map of errors and their component stack location keyed by the boundaries
895 + // that capture them. We mostly use this Map as a Set; it's a Map only to
896 + // avoid adding a field to Fiber to store the error.
897 if (!capturedErrors) {
898 capturedErrors = new Map();
899 }
900 + capturedErrors.set(boundary, {
901 + componentName,
902 + componentStack,
903 + error,
904 + errorBoundaryFound,
905 + errorBoundaryName,
906 + willRetry,
907 + });
908
870 - capturedErrors.set(boundary, error);
909 // If we're in the commit phase, defer scheduling an update on the
910 // boundary until after the commit is complete
911 if (isCommitting) {
@@ -906,23 +944,38 @@ module.exports = function<T, P, I, TI, PI, C, CX>(config : HostConfig<T, P, I, T
944 }
945
946 function commitErrorHandling(effectfulFiber : Fiber) {
909 - let error;
947 + let capturedError;
948 if (capturedErrors) {
911 - error = capturedErrors.get(effectfulFiber);
949 + capturedError = capturedErrors.get(effectfulFiber);
950 capturedErrors.delete(effectfulFiber);
913 - if (!error) {
951 + if (!capturedError) {
952 if (effectfulFiber.alternate) {
953 effectfulFiber = effectfulFiber.alternate;
916 - error = capturedErrors.get(effectfulFiber);
954 + capturedError = capturedErrors.get(effectfulFiber);
955 capturedErrors.delete(effectfulFiber);
956 }
957 }
958 }
959
922 - if (!error) {
960 + if (!capturedError) {
961 throw new Error('No error for given unit of work.');
962 }
963
964 + let error;
965 +
966 + // Conditional required to satisfy Flow
967 + if (capturedError) {
968 + error = capturedError.error;
969 +
970 + try {
971 + logCapturedError(capturedError);
972 + } catch (e) {
973 + // Prevent cycle if logCapturedError() throws.
974 + // A cycle may still occur if logCapturedError renders a component that throws.
975 + console.error(e);
976 + }
977 + }
978 +
979 switch (effectfulFiber.tag) {
980 case ClassComponent:
981 const instance = effectfulFiber.stateNode;
src/renderers/shared/fiber/__tests__/ReactIncrementalErrorHandling-test.js
+172
@@ -936,4 +936,176 @@ describe('ReactIncrementalErrorHandling', () => {
936 });
937 expect(() => ReactNoop.flush()).toThrow('Error!');
938 });
939 +
940 + describe('ReactFiberErrorLogger', () => {
941 + function initReactFiberErrorLoggerMock(mock) {
942 + jest.resetModules();
943 + if (mock) {
944 + jest.mock('ReactFiberErrorLogger');
945 + } else {
946 + jest.unmock('ReactFiberErrorLogger');
947 + }
948 + React = require('React');
949 + ReactNoop = require('ReactNoop');
950 + }
951 +
952 + function normalizeCodeLocInfo(str) {
953 + return str && str.replace(/\(at .+?:\d+\)/g, '(at **)');
954 + }
955 +
956 + it('should log errors that occur during the begin phase', () => {
957 + initReactFiberErrorLoggerMock();
958 + spyOn(console, 'error');
959 +
960 + class ErrorThrowingComponent extends React.Component {
961 + componentWillMount() {
962 + throw Error('componentWillMount error');
963 + }
964 + render() {
965 + return <div/>;
966 + }
967 + }
968 +
969 + try {
970 + ReactNoop.render(<div><span><ErrorThrowingComponent/></span></div>);
971 + ReactNoop.flushDeferredPri();
972 + } catch (error) {}
973 +
974 + expect(console.error.calls.count()).toBe(1);
975 + const errorMessage = console.error.calls.argsFor(0)[0];
976 + expect(errorMessage).toContain(
977 + 'React caught an error thrown by ErrorThrowingComponent. ' +
978 + 'You should fix this error in your code. ' +
979 + 'Consider adding an error boundary to your tree to customize error handling behavior.'
980 + );
981 + expect(errorMessage).toContain('Error: componentWillMount error');
982 + expect(normalizeCodeLocInfo(errorMessage)).toContain(
983 + 'The error was thrown in the following location: \n' +
984 + ' in ErrorThrowingComponent (at **)\n' +
985 + ' in span (at **)\n' +
986 + ' in div (at **)'
987 + );
988 + });
989 +
990 + it('should log errors that occur during the commit phase', () => {
991 + initReactFiberErrorLoggerMock();
992 + spyOn(console, 'error');
993 +
994 + class ErrorThrowingComponent extends React.Component {
995 + componentDidMount() {
996 + throw Error('componentDidMount error');
997 + }
998 + render() {
999 + return <div/>;
1000 + }
1001 + }
1002 +
1003 + try {
1004 + ReactNoop.render(<div><span><ErrorThrowingComponent/></span></div>);
1005 + ReactNoop.flushDeferredPri();
1006 + } catch (error) {}
1007 +
1008 + expect(console.error.calls.count()).toBe(1);
1009 + const errorMessage = console.error.calls.argsFor(0)[0];
1010 + expect(errorMessage).toContain(
1011 + 'React caught an error thrown by ErrorThrowingComponent. ' +
1012 + 'You should fix this error in your code. ' +
1013 + 'Consider adding an error boundary to your tree to customize error handling behavior.'
1014 + );
1015 + expect(errorMessage).toContain('Error: componentDidMount error');
1016 + expect(normalizeCodeLocInfo(errorMessage)).toContain(
1017 + 'The error was thrown in the following location: \n' +
1018 + ' in ErrorThrowingComponent (at **)\n' +
1019 + ' in span (at **)\n' +
1020 + ' in div (at **)'
1021 + );
1022 + });
1023 +
1024 + it('should ignore errors thrown in log method to prevent cycle', () => {
1025 + initReactFiberErrorLoggerMock(true);
1026 + spyOn(console, 'error');
1027 +
1028 + class ErrorThrowingComponent extends React.Component {
1029 + render() {
1030 + throw Error('render error');
1031 + }
1032 + }
1033 +
1034 + const logCapturedErrorCalls = [];
1035 +
1036 + const ReactFiberErrorLogger = require('ReactFiberErrorLogger');
1037 + ReactFiberErrorLogger.logCapturedError.mockImplementation(
1038 + (capturedError) => {
1039 + logCapturedErrorCalls.push(capturedError);
1040 + throw Error('logCapturedError error');
1041 + }
1042 + );
1043 +
1044 + try {
1045 + ReactNoop.render(<div><span><ErrorThrowingComponent/></span></div>);
1046 + ReactNoop.flushDeferredPri();
1047 + } catch (error) {}
1048 +
1049 + expect(logCapturedErrorCalls.length).toBe(1);
1050 +
1051 + // The error thrown in logCapturedError should also be logged
1052 + expect(console.error.calls.count()).toBe(1);
1053 + expect(console.error.calls.argsFor(0)[0].message).toContain('logCapturedError error');
1054 + });
1055 +
1056 + it('should relay info about error boundary and retry attempts if applicable', () => {
1057 + initReactFiberErrorLoggerMock();
1058 + spyOn(console, 'error');
1059 +
1060 + class ParentComponent extends React.Component {
1061 + render() {
1062 + return <ErrorBoundaryComponent/>;
1063 + }
1064 + }
1065 +
1066 + let handleErrorCalls = [];
1067 + let renderAttempts = 0;
1068 +
1069 + class ErrorBoundaryComponent extends React.Component {
1070 + unstable_handleError(error) {
1071 + handleErrorCalls.push(error);
1072 + this.setState({}); // Render again
1073 + }
1074 + render() {
1075 + return <ErrorThrowingComponent/>;
1076 + }
1077 + }
1078 +
1079 + class ErrorThrowingComponent extends React.Component {
1080 + componentDidMount() {
1081 + throw Error('componentDidMount error');
1082 + }
1083 + render() {
1084 + renderAttempts++;
1085 + return <div/>;
1086 + }
1087 + }
1088 +
1089 + try {
1090 + ReactNoop.render(<ParentComponent/>);
1091 + ReactNoop.flush();
1092 + } catch (error) {}
1093 +
1094 + expect(renderAttempts).toBe(2);
1095 + expect(handleErrorCalls.length).toBe(1);
1096 + expect(console.error.calls.count()).toBe(2);
1097 + expect(console.error.calls.argsFor(0)[0]).toContain(
1098 + 'React caught an error thrown by ErrorThrowingComponent. ' +
1099 + 'You should fix this error in your code. ' +
1100 + 'React will try to recreate this component tree from scratch ' +
1101 + 'using the error boundary you provided, ErrorBoundaryComponent.'
1102 + );
1103 + expect(console.error.calls.argsFor(1)[0]).toContain(
1104 + 'React caught an error thrown by ErrorThrowingComponent. ' +
1105 + 'You should fix this error in your code. ' +
1106 + 'This error was initially handled by the error boundary ErrorBoundaryComponent. ' +
1107 + 'Recreating the tree from scratch failed so React will unmount the tree.'
1108 + );
1109 + });
1110 + });
1111 });