[Fresh] Make all errors recoverable (#17438)
* [Fresh] Detect root updates more reliably * [Fresh] Use WeakMap for root elements * [Fresh] Make initial failures recoverable too * Fix DevTools check * Fix wrong flow type
Dan Abramov committed
Nov 25, 2019 at 17:25 UTC
6470e0f169b4cf0416132aa66221bf20e264c618
5 files changed
+195
-80
packages/react-reconciler/src/ReactFiberDevToolsHook.js
+27
@@ -14,12 +14,14 @@ import {inferPriorityFromExpirationTime} from './ReactFiberExpirationTime';
14
import type {Fiber} from './ReactFiber';
15
import type {FiberRoot} from './ReactFiberRoot';
16
import type {ExpirationTime} from './ReactFiberExpirationTime';
17
+import type {ReactNodeList} from 'shared/ReactTypes';
18
19
import {DidCapture} from 'shared/ReactSideEffectTags';
20
import warningWithoutStack from 'shared/warningWithoutStack';
21
22
declare var __REACT_DEVTOOLS_GLOBAL_HOOK__: Object | void;
23
24
+let onScheduleFiberRoot = null;
25
let onCommitFiberRoot = null;
26
let onCommitFiberUnmount = null;
27
let hasLoggedError = false;
@@ -54,6 +56,25 @@ export function injectInternals(internals: Object): boolean {
56
try {
57
const rendererID = hook.inject(internals);
58
// We have successfully injected, so now it is safe to set up hooks.
59
+ if (__DEV__) {
60
+ // Only used by Fast Refresh
61
+ if (typeof hook.onScheduleFiberRoot === 'function') {
62
+ onScheduleFiberRoot = (root, children) => {
63
+ try {
64
+ hook.onScheduleFiberRoot(rendererID, root, children);
65
+ } catch (err) {
66
+ if (__DEV__ && !hasLoggedError) {
67
+ hasLoggedError = true;
68
+ warningWithoutStack(
69
+ false,
70
+ 'React DevTools encountered an error: %s',
71
+ err,
72
+ );
73
+ }
74
+ }
75
+ };
76
+ }
77
+ }
78
onCommitFiberRoot = (root, expirationTime) => {
79
try {
80
const didError = (root.current.effectTag & DidCapture) === DidCapture;
@@ -106,6 +127,12 @@ export function injectInternals(internals: Object): boolean {
127
return true;
128
}
129
130
+export function onScheduleRoot(root: FiberRoot, children: ReactNodeList) {
131
+ if (typeof onScheduleFiberRoot === 'function') {
132
+ onScheduleFiberRoot(root, children);
133
+ }
134
+}
135
+
136
export function onCommitRoot(root: FiberRoot, expirationTime: ExpirationTime) {
137
if (typeof onCommitFiberRoot === 'function') {
138
onCommitFiberRoot(root, expirationTime);
packages/react-reconciler/src/ReactFiberInstrumentation.js
deleted
-18
@@ -1,18 +0,0 @@
1
-/**
2
- * Copyright (c) Facebook, Inc. and its affiliates.
3
- *
4
- * This source code is licensed under the MIT license found in the
5
- * LICENSE file in the root directory of this source tree.
6
- *
7
- * @flow
8
- */
9
-
10
-// This lets us hook into Fiber to debug what it's doing.
11
-// See https://github.com/facebook/react/pull/8033.
12
-// This is not part of the public API, not even for React DevTools.
13
-// You may only inject a debugTool if you work on React Fiber itself.
14
-const ReactFiberInstrumentation = {
15
- debugTool: null,
16
-};
17
-
18
-module.exports = ReactFiberInstrumentation;
packages/react-reconciler/src/ReactFiberReconciler.js
+4
-14
@@ -48,7 +48,7 @@ import {
48
isContextProvider as isLegacyContextProvider,
49
} from './ReactFiberContext';
50
import {createFiberRoot} from './ReactFiberRoot';
51
-import {injectInternals} from './ReactFiberDevToolsHook';
51
+import {injectInternals, onScheduleRoot} from './ReactFiberDevToolsHook';
52
import {
53
requestCurrentTimeForUpdate,
54
computeExpirationForFiber,
@@ -69,7 +69,6 @@ import {
69
IsThisRendererActing,
70
} from './ReactFiberWorkLoop';
71
import {createUpdate, enqueueUpdate} from './ReactUpdateQueue';
72
-import ReactFiberInstrumentation from './ReactFiberInstrumentation';
72
import {
73
getStackByFiberInDevAndProd,
74
phase as ReactCurrentFiberPhase,
@@ -230,6 +229,9 @@ export function updateContainer(
229
parentComponent: ?React$Component<any, any>,
230
callback: ?Function,
231
): ExpirationTime {
232
+ if (__DEV__) {
233
+ onScheduleRoot(container, element);
234
+ }
235
const current = container.current;
236
const currentTime = requestCurrentTimeForUpdate();
237
if (__DEV__) {
@@ -246,18 +248,6 @@ export function updateContainer(
248
suspenseConfig,
249
);
250
249
- if (__DEV__) {
250
- if (ReactFiberInstrumentation.debugTool) {
251
- if (current.alternate === null) {
252
- ReactFiberInstrumentation.debugTool.onMountContainer(container);
253
- } else if (element === null) {
254
- ReactFiberInstrumentation.debugTool.onUnmountContainer(container);
255
- } else {
256
- ReactFiberInstrumentation.debugTool.onUpdateContainer(container);
257
- }
258
- }
259
- }
260
-
251
const context = getContextForSubtree(parentComponent);
252
if (container.context === null) {
253
container.context = context;
packages/react-refresh/src/ReactFreshRuntime.js
+68
-31
@@ -68,9 +68,17 @@ let helpersByRoot: Map<FiberRoot, RendererHelpers> = new Map();
68
69
// We keep track of mounted roots so we can schedule updates.
70
let mountedRoots: Set<FiberRoot> = new Set();
71
-// If a root captures an error, we add its element to this Map so we can retry on edit.
72
-let failedRoots: Map<FiberRoot, ReactNodeList> = new Map();
73
-let didSomeRootFailOnMount = false;
71
+// If a root captures an error, we remember it so we can retry on edit.
72
+let failedRoots: Set<FiberRoot> = new Set();
73
+
74
+// In environments that support WeakMap, we also remember the last element for every root.
75
+// It needs to be weak because we do this even for roots that failed to mount.
76
+// If there is no WeakMap, we won't attempt to do retrying.
77
+// $FlowIssue
78
+let rootElements: WeakMap<any, ReactNodeList> | null = // $FlowIssue
79
+ typeof WeakMap === 'function' ? new WeakMap() : null;
80
+
81
+let isPerformingRefresh = false;
82
83
function computeFullKey(signature: Signature): string {
84
if (signature.fullKey !== null) {
@@ -171,11 +179,20 @@ function cloneSet<T>(set: Set<T>): Set<T> {
179
}
180
181
export function performReactRefresh(): RefreshUpdate | null {
174
- if (__DEV__) {
175
- if (pendingUpdates.length === 0) {
176
- return null;
177
- }
182
+ if (!__DEV__) {
183
+ throw new Error(
184
+ 'Unexpected call to React Refresh in a production environment.',
185
+ );
186
+ }
187
+ if (pendingUpdates.length === 0) {
188
+ return null;
189
+ }
190
+ if (isPerformingRefresh) {
191
+ return null;
192
+ }
193
194
+ isPerformingRefresh = true;
195
+ try {
196
const staleFamilies = new Set();
197
const updatedFamilies = new Set();
198
@@ -216,17 +233,27 @@ export function performReactRefresh(): RefreshUpdate | null {
233
// If we don't do this, there is a risk they will be mutated while
234
// we iterate over them. For example, trying to recover a failed root
235
// may cause another root to be added to the failed list -- an infinite loop.
219
- let failedRootsSnapshot = cloneMap(failedRoots);
236
+ let failedRootsSnapshot = cloneSet(failedRoots);
237
let mountedRootsSnapshot = cloneSet(mountedRoots);
238
let helpersByRootSnapshot = cloneMap(helpersByRoot);
239
223
- failedRootsSnapshot.forEach((element, root) => {
240
+ failedRootsSnapshot.forEach(root => {
241
const helpers = helpersByRootSnapshot.get(root);
242
if (helpers === undefined) {
243
throw new Error(
244
'Could not find helpers for a root. This is a bug in React Refresh.',
245
);
246
}
247
+ if (!failedRoots.has(root)) {
248
+ // No longer failed.
249
+ }
250
+ if (rootElements === null) {
251
+ return;
252
+ }
253
+ if (!rootElements.has(root)) {
254
+ return;
255
+ }
256
+ const element = rootElements.get(root);
257
try {
258
helpers.scheduleRoot(root, element);
259
} catch (err) {
@@ -244,6 +271,9 @@ export function performReactRefresh(): RefreshUpdate | null {
271
'Could not find helpers for a root. This is a bug in React Refresh.',
272
);
273
}
274
+ if (!mountedRoots.has(root)) {
275
+ // No longer mounted.
276
+ }
277
try {
278
helpers.scheduleRefresh(root, update);
279
} catch (err) {
@@ -258,10 +288,8 @@ export function performReactRefresh(): RefreshUpdate | null {
288
throw firstError;
289
}
290
return update;
261
- } else {
262
- throw new Error(
263
- 'Unexpected call to React Refresh in a production environment.',
264
- );
291
+ } finally {
292
+ isPerformingRefresh = false;
293
}
294
}
295
@@ -411,6 +439,11 @@ export function injectIntoGlobalHook(globalObject: any): void {
439
inject(injected) {
440
return nextID++;
441
},
442
+ onScheduleFiberRoot(
443
+ id: number,
444
+ root: FiberRoot,
445
+ children: ReactNodeList,
446
+ ) {},
447
onCommitFiberRoot(
448
id: number,
449
root: FiberRoot,
@@ -437,6 +470,22 @@ export function injectIntoGlobalHook(globalObject: any): void {
470
471
// We also want to track currently mounted roots.
472
const oldOnCommitFiberRoot = hook.onCommitFiberRoot;
473
+ const oldOnScheduleFiberRoot = hook.onScheduleFiberRoot || (() => {});
474
+ hook.onScheduleFiberRoot = function(
475
+ id: number,
476
+ root: FiberRoot,
477
+ children: mixed,
478
+ ) {
479
+ if (!isPerformingRefresh) {
480
+ // If it was intentionally scheduled, don't attempt to restore.
481
+ // This includes intentionally scheduled unmounts.
482
+ failedRoots.delete(root);
483
+ if (rootElements !== null) {
484
+ rootElements.set(root, children);
485
+ }
486
+ }
487
+ return oldOnScheduleFiberRoot.apply(this, arguments);
488
+ };
489
hook.onCommitFiberRoot = function(
490
id: number,
491
root: FiberRoot,
@@ -476,27 +525,14 @@ export function injectIntoGlobalHook(globalObject: any): void {
525
mountedRoots.delete(root);
526
if (didError) {
527
// We'll remount it on future edits.
479
- // Remember what was rendered so we can restore it.
480
- failedRoots.set(root, alternate.memoizedState.element);
528
+ failedRoots.add(root);
529
} else {
530
helpersByRoot.delete(root);
531
}
532
} else if (!wasMounted && !isMounted) {
485
- if (didError && !failedRoots.has(root)) {
486
- // The root had an error during the initial mount.
487
- // We can't read its last element from the memoized state
488
- // because there was no previously committed alternate.
489
- // Ideally, it would be nice if we had a way to extract
490
- // the last attempted rendered element, but accessing the update queue
491
- // would tie this package too closely to the reconciler version.
492
- // So instead, we just set a flag.
493
- // TODO: Maybe we could fix this as the same time as when we fix
494
- // DevTools to not depend on `alternate.memoizedState.element`.
495
- didSomeRootFailOnMount = true;
496
- } else if (!didError && failedRoots.has(root)) {
497
- // The error is fixed but the component is still unmounted.
498
- // This means that the unmount was not caused by a failed refresh.
499
- failedRoots.delete(root);
533
+ if (didError) {
534
+ // We'll remount it on future edits.
535
+ failedRoots.add(root);
536
}
537
}
538
} else {
@@ -514,7 +550,8 @@ export function injectIntoGlobalHook(globalObject: any): void {
550
}
551
552
export function hasUnrecoverableErrors() {
517
- return didSomeRootFailOnMount;
553
+ // TODO: delete this after removing dependency in RN.
554
+ return false;
555
}
556
557
// Exposed for testing.
packages/react-refresh/src/__tests__/ReactFresh-test.js
+96
-17
@@ -2755,11 +2755,8 @@ describe('ReactFresh', () => {
2755
}
2756
});
2757
2758
- // TODO: we can make this recoverable in the future
2759
- // if we add a way to track the last attempted element.
2760
- it('records an unrecoverable error if a root fails on mount', () => {
2758
+ it('remounts a failed root on mount', () => {
2759
if (__DEV__) {
2762
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2760
expect(() => {
2761
render(() => {
2762
function Hello() {
@@ -2770,13 +2767,106 @@ describe('ReactFresh', () => {
2767
return Hello;
2768
});
2769
}).toThrow('No');
2773
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(true);
2770
+ expect(container.innerHTML).toBe('');
2771
+
2772
+ // A bad retry
2773
+ expect(() => {
2774
+ patch(() => {
2775
+ function Hello() {
2776
+ throw new Error('Not yet');
2777
+ }
2778
+ $RefreshReg$(Hello, 'Hello');
2779
+ });
2780
+ }).toThrow('Not yet');
2781
+ expect(container.innerHTML).toBe('');
2782
+
2783
+ // Perform a hot update that fixes the error.
2784
+ patch(() => {
2785
+ function Hello() {
2786
+ return <h1>Fixed!</h1>;
2787
+ }
2788
+ $RefreshReg$(Hello, 'Hello');
2789
+ });
2790
+ // This should mount the root.
2791
+ expect(container.innerHTML).toBe('<h1>Fixed!</h1>');
2792
+
2793
+ // Ensure we can keep failing and recovering later.
2794
+ expect(() => {
2795
+ patch(() => {
2796
+ function Hello() {
2797
+ throw new Error('No 2');
2798
+ }
2799
+ $RefreshReg$(Hello, 'Hello');
2800
+ });
2801
+ }).toThrow('No 2');
2802
+ expect(container.innerHTML).toBe('');
2803
+ expect(() => {
2804
+ patch(() => {
2805
+ function Hello() {
2806
+ throw new Error('Not yet 2');
2807
+ }
2808
+ $RefreshReg$(Hello, 'Hello');
2809
+ });
2810
+ }).toThrow('Not yet 2');
2811
+ expect(container.innerHTML).toBe('');
2812
+ patch(() => {
2813
+ function Hello() {
2814
+ return <h1>Fixed 2!</h1>;
2815
+ }
2816
+ $RefreshReg$(Hello, 'Hello');
2817
+ });
2818
+ expect(container.innerHTML).toBe('<h1>Fixed 2!</h1>');
2819
+
2820
+ // Updates after intentional unmount are ignored.
2821
+ ReactDOM.unmountComponentAtNode(container);
2822
+ patch(() => {
2823
+ function Hello() {
2824
+ throw new Error('Ignored');
2825
+ }
2826
+ $RefreshReg$(Hello, 'Hello');
2827
+ });
2828
+ expect(container.innerHTML).toBe('');
2829
+ patch(() => {
2830
+ function Hello() {
2831
+ return <h1>Ignored</h1>;
2832
+ }
2833
+ $RefreshReg$(Hello, 'Hello');
2834
+ });
2835
+ expect(container.innerHTML).toBe('');
2836
+ }
2837
+ });
2838
+
2839
+ it('does not retry an intentionally unmounted failed root', () => {
2840
+ if (__DEV__) {
2841
+ expect(() => {
2842
+ render(() => {
2843
+ function Hello() {
2844
+ throw new Error('No');
2845
+ }
2846
+ $RefreshReg$(Hello, 'Hello');
2847
+
2848
+ return Hello;
2849
+ });
2850
+ }).toThrow('No');
2851
+ expect(container.innerHTML).toBe('');
2852
+
2853
+ // Intentional unmount.
2854
+ ReactDOM.unmountComponentAtNode(container);
2855
+
2856
+ // Perform a hot update that fixes the error.
2857
+ patch(() => {
2858
+ function Hello() {
2859
+ return <h1>Fixed!</h1>;
2860
+ }
2861
+ $RefreshReg$(Hello, 'Hello');
2862
+ });
2863
+ // This should stay unmounted.
2864
+ expect(container.innerHTML).toBe('');
2865
}
2866
});
2867
2868
it('remounts a failed root on update', () => {
2869
if (__DEV__) {
2779
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2870
render(() => {
2871
function Hello() {
2872
return <h1>Hi</h1>;
@@ -2786,7 +2876,6 @@ describe('ReactFresh', () => {
2876
return Hello;
2877
});
2878
expect(container.innerHTML).toBe('<h1>Hi</h1>');
2789
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2879
2880
// Perform a hot update that fails.
2881
// This removes the root.
@@ -2799,7 +2888,6 @@ describe('ReactFresh', () => {
2888
});
2889
}).toThrow('No');
2890
expect(container.innerHTML).toBe('');
2802
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2891
2892
// A bad retry
2893
expect(() => {
@@ -2811,7 +2899,6 @@ describe('ReactFresh', () => {
2899
});
2900
}).toThrow('Not yet');
2901
expect(container.innerHTML).toBe('');
2814
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2902
2903
// Perform a hot update that fixes the error.
2904
patch(() => {
@@ -2822,7 +2909,6 @@ describe('ReactFresh', () => {
2909
});
2910
// This should remount the root.
2911
expect(container.innerHTML).toBe('<h1>Fixed!</h1>');
2825
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2912
2913
// Verify next hot reload doesn't remount anything.
2914
let helloNode = container.firstChild;
@@ -2834,7 +2920,6 @@ describe('ReactFresh', () => {
2920
});
2921
expect(container.firstChild).toBe(helloNode);
2922
expect(helloNode.textContent).toBe('Nice.');
2837
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2923
2924
// Break again.
2925
expect(() => {
@@ -2846,7 +2931,6 @@ describe('ReactFresh', () => {
2931
});
2932
}).toThrow('Oops');
2933
expect(container.innerHTML).toBe('');
2849
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2934
2935
// Perform a hot update that fixes the error.
2936
patch(() => {
@@ -2857,7 +2941,6 @@ describe('ReactFresh', () => {
2941
});
2942
// This should remount the root.
2943
expect(container.innerHTML).toBe('<h1>At last.</h1>');
2860
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2944
2945
// Check we don't attempt to reverse an intentional unmount.
2946
ReactDOM.unmountComponentAtNode(container);
@@ -2869,7 +2952,6 @@ describe('ReactFresh', () => {
2952
$RefreshReg$(Hello, 'Hello');
2953
});
2954
expect(container.innerHTML).toBe('');
2872
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2955
2956
// Mount a new container.
2957
render(() => {
@@ -2881,7 +2963,6 @@ describe('ReactFresh', () => {
2963
return Hello;
2964
});
2965
expect(container.innerHTML).toBe('<h1>Hi</h1>');
2884
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2966
2967
// Break again.
2968
expect(() => {
@@ -2893,7 +2974,6 @@ describe('ReactFresh', () => {
2974
});
2975
}).toThrow('Oops');
2976
expect(container.innerHTML).toBe('');
2896
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2977
2978
// Check we don't attempt to reverse an intentional unmount, even after an error.
2979
ReactDOM.unmountComponentAtNode(container);
@@ -2905,7 +2985,6 @@ describe('ReactFresh', () => {
2985
$RefreshReg$(Hello, 'Hello');
2986
});
2987
expect(container.innerHTML).toBe('');
2908
- expect(ReactFreshRuntime.hasUnrecoverableErrors()).toBe(false);
2988
}
2989
});
2990