Skip to content

Commit d420d2c

Browse files
authored
[Fresh] Retry failed roots on refresh (#15966)
* Retry failed roots on refresh * Don't prevent retry after error -> render(null) special case The check wasn't very resilient because in Concurrent Mode it looks like we can get further follow-up commits even if we captured an error. So we can't reliably distinguish the case where after an error you _manually_ rendered null. Retrying on an edit after a tree failed _and_ you rendered null in the same tree seems fine. It's also very unlikely a pattern like this actually exists in the wild.
1 parent 04b77c6 commit d420d2c

5 files changed

Lines changed: 165 additions & 6 deletions

File tree

packages/react-reconciler/src/ReactFiberDevToolsHook.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {Fiber} from './ReactFiber';
1515
import type {FiberRoot} from './ReactFiberRoot';
1616
import type {ExpirationTime} from './ReactFiberExpirationTime';
1717

18+
import {DidCapture} from 'shared/ReactSideEffectTags';
1819
import warningWithoutStack from 'shared/warningWithoutStack';
1920

2021
declare var __REACT_DEVTOOLS_GLOBAL_HOOK__: Object | void;
@@ -55,15 +56,16 @@ export function injectInternals(internals: Object): boolean {
5556
// We have successfully injected, so now it is safe to set up hooks.
5657
onCommitFiberRoot = (root, expirationTime) => {
5758
try {
59+
const didError = (root.current.effectTag & DidCapture) === DidCapture;
5860
if (enableProfilerTimer) {
5961
const currentTime = requestCurrentTime();
6062
const priorityLevel = inferPriorityFromExpirationTime(
6163
currentTime,
6264
expirationTime,
6365
);
64-
hook.onCommitFiberRoot(rendererID, root, priorityLevel);
66+
hook.onCommitFiberRoot(rendererID, root, priorityLevel, didError);
6567
} else {
66-
hook.onCommitFiberRoot(rendererID, root);
68+
hook.onCommitFiberRoot(rendererID, root, undefined, didError);
6769
}
6870
} catch (err) {
6971
if (__DEV__ && !hasLoggedError) {

packages/react-reconciler/src/ReactFiberHotReloading.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import type {ReactElement} from 'shared/ReactElementType';
1111
import type {Fiber} from './ReactFiber';
1212
import type {FiberRoot} from './ReactFiberRoot';
1313
import type {Instance} from './ReactFiberHostConfig';
14+
import type {ReactNodeList} from 'shared/ReactTypes';
1415

1516
import {
1617
flushSync,
1718
scheduleWork,
1819
flushPassiveEffects,
1920
} from './ReactFiberWorkLoop';
21+
import {updateContainerAtExpirationTime} from './ReactFiberReconciler';
22+
import {emptyContextObject} from './ReactFiberContext';
2023
import {Sync} from './ReactFiberExpirationTime';
2124
import {
2225
ClassComponent,
@@ -49,6 +52,7 @@ type RefreshHandler = any => Family | void;
4952
// Used by React Refresh runtime through DevTools Global Hook.
5053
export type SetRefreshHandler = (handler: RefreshHandler | null) => void;
5154
export type ScheduleRefresh = (root: FiberRoot, update: RefreshUpdate) => void;
55+
export type ScheduleRoot = (root: FiberRoot, element: ReactNodeList) => void;
5256
export type FindHostInstancesForRefresh = (
5357
root: FiberRoot,
5458
families: Array<Family>,
@@ -242,6 +246,22 @@ export let scheduleRefresh: ScheduleRefresh = (
242246
}
243247
};
244248

249+
export let scheduleRoot: ScheduleRoot = (
250+
root: FiberRoot,
251+
element: ReactNodeList,
252+
): void => {
253+
if (__DEV__) {
254+
if (root.context !== emptyContextObject) {
255+
// Super edge case: root has a legacy _renderSubtree context
256+
// but we don't know the parentComponent so we can't pass it.
257+
// Just ignore. We'll delete this with _renderSubtree code path later.
258+
return;
259+
}
260+
flushPassiveEffects();
261+
updateContainerAtExpirationTime(element, root, null, Sync, null);
262+
}
263+
};
264+
245265
function scheduleFibersWithFamiliesRecursively(
246266
fiber: Fiber,
247267
updatedFamilies: Set<Family>,

packages/react-reconciler/src/ReactFiberReconciler.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {revertPassiveEffectsChange} from 'shared/ReactFeatureFlags';
7272
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
7373
import {
7474
scheduleRefresh,
75+
scheduleRoot,
7576
setRefreshHandler,
7677
findHostInstancesForRefresh,
7778
} from './ReactFiberHotReloading';
@@ -498,6 +499,7 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean {
498499
// React Refresh
499500
findHostInstancesForRefresh: __DEV__ ? findHostInstancesForRefresh : null,
500501
scheduleRefresh: __DEV__ ? scheduleRefresh : null,
502+
scheduleRoot: __DEV__ ? scheduleRoot : null,
501503
setRefreshHandler: __DEV__ ? setRefreshHandler : null,
502504
});
503505
}

packages/react-refresh/src/ReactFreshRuntime.js

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import type {
1313
Family,
1414
RefreshUpdate,
1515
ScheduleRefresh,
16+
ScheduleRoot,
1617
FindHostInstancesForRefresh,
1718
SetRefreshHandler,
1819
} from 'react-reconciler/src/ReactFiberHotReloading';
20+
import type {ReactNodeList} from 'shared/ReactTypes';
1921

2022
import {REACT_MEMO_TYPE, REACT_FORWARD_REF_TYPE} from 'shared/ReactSymbols';
2123
import warningWithoutStack from 'shared/warningWithoutStack';
@@ -57,9 +59,13 @@ let pendingUpdates: Array<[Family, any]> = [];
5759
// This is injected by the renderer via DevTools global hook.
5860
let setRefreshHandler: null | SetRefreshHandler = null;
5961
let scheduleRefresh: null | ScheduleRefresh = null;
62+
let scheduleRoot: null | ScheduleRoot = null;
6063
let findHostInstancesForRefresh: null | FindHostInstancesForRefresh = null;
6164

62-
let mountedRoots = new Set();
65+
// We keep track of mounted roots so we can schedule updates.
66+
let mountedRoots: Set<FiberRoot> = new Set();
67+
// If a root captures an error, we add its element to this Map so we can retry on edit.
68+
let failedRoots: Map<FiberRoot, ReactNodeList> = new Map();
6369

6470
function computeFullKey(signature: Signature): string {
6571
if (signature.fullKey !== null) {
@@ -196,14 +202,36 @@ export function performReactRefresh(): RefreshUpdate | null {
196202
);
197203
return null;
198204
}
205+
if (typeof scheduleRoot !== 'function') {
206+
warningWithoutStack(
207+
false,
208+
'Could not find the scheduleRoot() implementation. ' +
209+
'This likely means that injectIntoGlobalHook() was either ' +
210+
'called before the global DevTools hook was set up, or after the ' +
211+
'renderer has already initialized. Please file an issue with a reproducing case.',
212+
);
213+
return null;
214+
}
199215
const scheduleRefreshForRoot = scheduleRefresh;
216+
const scheduleRenderForRoot = scheduleRoot;
200217

201218
// Even if there are no roots, set the handler on first update.
202219
// This ensures that if *new* roots are mounted, they'll use the resolve handler.
203220
setRefreshHandler(resolveFamily);
204221

205222
let didError = false;
206223
let firstError = null;
224+
failedRoots.forEach((element, root) => {
225+
try {
226+
scheduleRenderForRoot(root, element);
227+
} catch (err) {
228+
if (!didError) {
229+
didError = true;
230+
firstError = err;
231+
}
232+
// Keep trying other roots.
233+
}
234+
});
207235
mountedRoots.forEach(root => {
208236
try {
209237
scheduleRefreshForRoot(root, update);
@@ -245,7 +273,7 @@ export function register(type: any, id: string): void {
245273

246274
// Create family or remember to update it.
247275
// None of this bookkeeping affects reconciliation
248-
// until the first prepareUpdate() call above.
276+
// until the first performReactRefresh() call above.
249277
let family = allFamiliesByID.get(id);
250278
if (family === undefined) {
251279
family = {current: type};
@@ -362,7 +390,12 @@ export function injectIntoGlobalHook(globalObject: any): void {
362390
globalObject.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook = {
363391
supportsFiber: true,
364392
inject() {},
365-
onCommitFiberRoot(id: mixed, root: FiberRoot) {},
393+
onCommitFiberRoot(
394+
id: mixed,
395+
root: FiberRoot,
396+
maybePriorityLevel: mixed,
397+
didError: boolean,
398+
) {},
366399
onCommitFiberUnmount() {},
367400
};
368401
}
@@ -373,14 +406,20 @@ export function injectIntoGlobalHook(globalObject: any): void {
373406
findHostInstancesForRefresh = ((injected: any)
374407
.findHostInstancesForRefresh: FindHostInstancesForRefresh);
375408
scheduleRefresh = ((injected: any).scheduleRefresh: ScheduleRefresh);
409+
scheduleRoot = ((injected: any).scheduleRoot: ScheduleRoot);
376410
setRefreshHandler = ((injected: any)
377411
.setRefreshHandler: SetRefreshHandler);
378412
return oldInject.apply(this, arguments);
379413
};
380414

381415
// We also want to track currently mounted roots.
382416
const oldOnCommitFiberRoot = hook.onCommitFiberRoot;
383-
hook.onCommitFiberRoot = function(id: mixed, root: FiberRoot) {
417+
hook.onCommitFiberRoot = function(
418+
id: mixed,
419+
root: FiberRoot,
420+
maybePriorityLevel: mixed,
421+
didError: boolean,
422+
) {
384423
const current = root.current;
385424
const alternate = current.alternate;
386425

@@ -399,12 +438,18 @@ export function injectIntoGlobalHook(globalObject: any): void {
399438
if (!wasMounted && isMounted) {
400439
// Mount a new root.
401440
mountedRoots.add(root);
441+
failedRoots.delete(root);
402442
} else if (wasMounted && isMounted) {
403443
// Update an existing root.
404444
// This doesn't affect our mounted root Set.
405445
} else if (wasMounted && !isMounted) {
406446
// Unmount an existing root.
407447
mountedRoots.delete(root);
448+
if (didError) {
449+
// We'll remount it on future edits.
450+
// Remember what was rendered so we can restore it.
451+
failedRoots.set(root, alternate.memoizedState.element);
452+
}
408453
}
409454
} else {
410455
// Mount a new root.

packages/react-refresh/src/__tests__/ReactFresh-test.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,6 +2712,96 @@ describe('ReactFresh', () => {
27122712
}
27132713
});
27142714

2715+
it('remounts a failed root on update', () => {
2716+
if (__DEV__) {
2717+
render(() => {
2718+
function Hello() {
2719+
return <h1>Hi</h1>;
2720+
}
2721+
$RefreshReg$(Hello, 'Hello');
2722+
2723+
return Hello;
2724+
});
2725+
expect(container.innerHTML).toBe('<h1>Hi</h1>');
2726+
2727+
// Perform a hot update that fails.
2728+
// This removes the root.
2729+
expect(() => {
2730+
patch(() => {
2731+
function Hello() {
2732+
throw new Error('No');
2733+
}
2734+
$RefreshReg$(Hello, 'Hello');
2735+
});
2736+
}).toThrow('No');
2737+
expect(container.innerHTML).toBe('');
2738+
2739+
// A bad retry
2740+
expect(() => {
2741+
patch(() => {
2742+
function Hello() {
2743+
throw new Error('Not yet');
2744+
}
2745+
$RefreshReg$(Hello, 'Hello');
2746+
});
2747+
}).toThrow('Not yet');
2748+
expect(container.innerHTML).toBe('');
2749+
2750+
// Perform a hot update that fixes the error.
2751+
patch(() => {
2752+
function Hello() {
2753+
return <h1>Fixed!</h1>;
2754+
}
2755+
$RefreshReg$(Hello, 'Hello');
2756+
});
2757+
// This should remount the root.
2758+
expect(container.innerHTML).toBe('<h1>Fixed!</h1>');
2759+
2760+
// Verify next hot reload doesn't remount anything.
2761+
let helloNode = container.firstChild;
2762+
patch(() => {
2763+
function Hello() {
2764+
return <h1>Nice.</h1>;
2765+
}
2766+
$RefreshReg$(Hello, 'Hello');
2767+
});
2768+
expect(container.firstChild).toBe(helloNode);
2769+
expect(helloNode.textContent).toBe('Nice.');
2770+
2771+
// Break again.
2772+
expect(() => {
2773+
patch(() => {
2774+
function Hello() {
2775+
throw new Error('Oops');
2776+
}
2777+
$RefreshReg$(Hello, 'Hello');
2778+
});
2779+
}).toThrow('Oops');
2780+
expect(container.innerHTML).toBe('');
2781+
2782+
// Perform a hot update that fixes the error.
2783+
patch(() => {
2784+
function Hello() {
2785+
return <h1>At last.</h1>;
2786+
}
2787+
$RefreshReg$(Hello, 'Hello');
2788+
});
2789+
// This should remount the root.
2790+
expect(container.innerHTML).toBe('<h1>At last.</h1>');
2791+
2792+
// Check we don't attempt to reverse an intentional unmount.
2793+
ReactDOM.unmountComponentAtNode(container);
2794+
expect(container.innerHTML).toBe('');
2795+
patch(() => {
2796+
function Hello() {
2797+
return <h1>Never mind me!</h1>;
2798+
}
2799+
$RefreshReg$(Hello, 'Hello');
2800+
});
2801+
expect(container.innerHTML).toBe('');
2802+
}
2803+
});
2804+
27152805
it('remounts classes on every edit', () => {
27162806
if (__DEV__) {
27172807
let HelloV1 = render(() => {

0 commit comments

Comments
 (0)