Skip to content

Commit 6fbca5a

Browse files
author
Brian Vaughn
committed
Account for another DevTools + Fast Refresh edge case
DevTools now 'untrack' Fibers (cleans up the ID-to-Fiber mapping) after a slight delay in order to support a Fast Refresh edge case: 1. Component type is updated and Fast Refresh schedules an update+remount. 2. flushPendingErrorsAndWarningsAfterDelay() runs, sees the old Fiber is no longer mounted (it's been disconnected by Fast Refresh), and calls untrackFiberID() to clear it from the Map. 3. React flushes pending passive effects before it runs the next render, which logs an error or warning, which causes a new ID to be generated for this Fiber. 4. DevTools now tries to unmount the old Component with the new ID. The underlying problem here is the premature clearing of the Fiber ID, but DevTools has no way to detect that a given Fiber has been scheduled for Fast Refresh. (The '_debugNeedsRemount' flag won't necessarily be set.) The best we can do is to delay untracking by a small amount, and give React time to process the Fast Refresh delay.
1 parent 343776f commit 6fbca5a

File tree

3 files changed

+98
-28
lines changed

3 files changed

+98
-28
lines changed

packages/react-devtools-shared/src/__tests__/FastRefreshDevToolsIntegration-test.js

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,13 @@ describe('Fast Refresh', () => {
188188
});
189189

190190
it('should not break when there are warnings in between patching', () => {
191-
withErrorsOrWarningsIgnored(['Expected warning during render'], () => {
191+
withErrorsOrWarningsIgnored(['Expected:'], () => {
192192
render(`
193193
const {useState} = React;
194194
195195
export default function Component() {
196196
const [state, setState] = useState(1);
197-
console.warn("Expected warning during render");
197+
console.warn("Expected: warning during render");
198198
return null;
199199
}
200200
`);
@@ -205,13 +205,13 @@ describe('Fast Refresh', () => {
205205
<Component> ⚠
206206
`);
207207

208-
withErrorsOrWarningsIgnored(['Expected warning during render'], () => {
208+
withErrorsOrWarningsIgnored(['Expected:'], () => {
209209
patch(`
210210
const {useEffect, useState} = React;
211211
212212
export default function Component() {
213213
const [state, setState] = useState(1);
214-
console.warn("Expected warning during render");
214+
console.warn("Expected: warning during render");
215215
return null;
216216
}
217217
`);
@@ -222,31 +222,33 @@ describe('Fast Refresh', () => {
222222
<Component> ⚠
223223
`);
224224

225-
withErrorsOrWarningsIgnored(['Expected warning during render'], () => {
225+
withErrorsOrWarningsIgnored(['Expected:'], () => {
226226
patch(`
227227
const {useEffect, useState} = React;
228228
229229
export default function Component() {
230230
const [state, setState] = useState(1);
231-
useEffect(() => {});
232-
console.warn("Expected warning during render");
231+
useEffect(() => {
232+
console.error("Expected: error during effect");
233+
});
234+
console.warn("Expected: warning during render");
233235
return null;
234236
}
235237
`);
236238
});
237239
expect(store).toMatchInlineSnapshot(`
238-
0, ⚠ 1
240+
1, ⚠ 1
239241
[root]
240-
<Component> ⚠
242+
<Component>
241243
`);
242244

243-
withErrorsOrWarningsIgnored(['Expected warning during render'], () => {
245+
withErrorsOrWarningsIgnored(['Expected:'], () => {
244246
patch(`
245247
const {useEffect, useState} = React;
246248
247249
export default function Component() {
248250
const [state, setState] = useState(1);
249-
console.warn("Expected warning during render");
251+
console.warn("Expected: warning during render");
250252
return null;
251253
}
252254
`);
@@ -257,4 +259,6 @@ describe('Fast Refresh', () => {
257259
<Component> ⚠
258260
`);
259261
});
262+
263+
// TODO (bvaughn) Write a test that checks in between the steps of patch
260264
});

packages/react-devtools-shared/src/backend/renderer.js

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,7 @@ export function attach(
717717
? getFiberIDUnsafe(parentFiber) || '<no-id>'
718718
: '';
719719

720-
console.log(
720+
console.groupCollapsed(
721721
`[renderer] %c${name} %c${displayName} (${maybeID}) %c${
722722
parentFiber ? `${parentDisplayName} (${maybeParentID})` : ''
723723
} %c${extraString}`,
@@ -726,6 +726,13 @@ export function attach(
726726
'color: purple;',
727727
'color: black;',
728728
);
729+
console.log(
730+
new Error().stack
731+
.split('\n')
732+
.slice(1)
733+
.join('\n'),
734+
);
735+
console.groupEnd();
729736
}
730737
};
731738

@@ -996,7 +1003,9 @@ export function attach(
9961003
}
9971004
}
9981005

1006+
let didGenerateID = false;
9991007
if (id === null) {
1008+
didGenerateID = true;
10001009
id = getUID();
10011010
}
10021011

@@ -1019,6 +1028,17 @@ export function attach(
10191028
}
10201029
}
10211030

1031+
if (__DEBUG__) {
1032+
if (didGenerateID) {
1033+
debug(
1034+
'getOrGenerateFiberID()',
1035+
fiber,
1036+
fiber.return,
1037+
'Generated a new UID',
1038+
);
1039+
}
1040+
}
1041+
10221042
return refinedID;
10231043
}
10241044

@@ -1050,19 +1070,61 @@ export function attach(
10501070
// Removes a Fiber (and its alternate) from the Maps used to track their id.
10511071
// This method should always be called when a Fiber is unmounting.
10521072
function untrackFiberID(fiber: Fiber) {
1053-
const fiberID = getFiberIDUnsafe(fiber);
1054-
if (fiberID !== null) {
1055-
idToArbitraryFiberMap.delete(fiberID);
1073+
if (__DEBUG__) {
1074+
debug('untrackFiberID()', fiber, fiber.return, 'schedule after delay');
10561075
}
10571076

1058-
fiberToIDMap.delete(fiber);
1077+
// Untrack Fibers after a slight delay in order to support a Fast Refresh edge case:
1078+
// 1. Component type is updated and Fast Refresh schedules an update+remount.
1079+
// 2. flushPendingErrorsAndWarningsAfterDelay() runs, sees the old Fiber is no longer mounted
1080+
// (it's been disconnected by Fast Refresh), and calls untrackFiberID() to clear it from the Map.
1081+
// 3. React flushes pending passive effects before it runs the next render,
1082+
// which logs an error or warning, which causes a new ID to be generated for this Fiber.
1083+
// 4. DevTools now tries to unmount the old Component with the new ID.
1084+
//
1085+
// The underlying problem here is the premature clearing of the Fiber ID,
1086+
// but DevTools has no way to detect that a given Fiber has been scheduled for Fast Refresh.
1087+
// (The "_debugNeedsRemount" flag won't necessarily be set.)
1088+
//
1089+
// The best we can do is to delay untracking by a small amount,
1090+
// and give React time to process the Fast Refresh delay.
10591091

1060-
const {alternate} = fiber;
1061-
if (alternate !== null) {
1062-
fiberToIDMap.delete(alternate);
1092+
untrackFibersSet.add(fiber);
1093+
1094+
if (untrackFibersTimeoutID === null) {
1095+
untrackFibersTimeoutID = setTimeout(untrackFibers, 1000);
10631096
}
10641097
}
10651098

1099+
const untrackFibersSet: Set<Fiber> = new Set();
1100+
let untrackFibersTimeoutID: TimeoutID | null = null;
1101+
1102+
function untrackFibers() {
1103+
if (untrackFibersTimeoutID !== null) {
1104+
clearTimeout(untrackFibersTimeoutID);
1105+
untrackFibersTimeoutID = null;
1106+
}
1107+
1108+
untrackFibersSet.forEach(fiber => {
1109+
const fiberID = getFiberIDUnsafe(fiber);
1110+
if (fiberID !== null) {
1111+
idToArbitraryFiberMap.delete(fiberID);
1112+
1113+
// Also clear any errors/warnings associated with this fiber.
1114+
clearErrorsForFiberID(fiberID);
1115+
clearWarningsForFiberID(fiberID);
1116+
}
1117+
1118+
fiberToIDMap.delete(fiber);
1119+
1120+
const {alternate} = fiber;
1121+
if (alternate !== null) {
1122+
fiberToIDMap.delete(alternate);
1123+
}
1124+
});
1125+
untrackFibersSet.clear();
1126+
}
1127+
10661128
function getChangeDescription(
10671129
prevFiber: Fiber | null,
10681130
nextFiber: Fiber,
@@ -1607,13 +1669,13 @@ export function attach(
16071669
}
16081670

16091671
function recordMount(fiber: Fiber, parentFiber: Fiber | null) {
1672+
const isRoot = fiber.tag === HostRoot;
1673+
const id = getOrGenerateFiberID(fiber);
1674+
16101675
if (__DEBUG__) {
16111676
debug('recordMount()', fiber, parentFiber);
16121677
}
16131678

1614-
const isRoot = fiber.tag === HostRoot;
1615-
const id = getOrGenerateFiberID(fiber);
1616-
16171679
const hasOwnerMetadata = fiber.hasOwnProperty('_debugOwner');
16181680
const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration');
16191681

@@ -1745,6 +1807,9 @@ export function attach(
17451807
// This reduces the chance of stack overflow for wide trees (e.g. lists with many items).
17461808
let fiber: Fiber | null = firstChild;
17471809
while (fiber !== null) {
1810+
// Generate an ID even for filtered Fibers, in case it's needed later (e.g. for Profiling).
1811+
getOrGenerateFiberID(fiber);
1812+
17481813
if (__DEBUG__) {
17491814
debug('mountFiberRecursively()', fiber, parentFiber);
17501815
}
@@ -1758,9 +1823,6 @@ export function attach(
17581823
const shouldIncludeInTree = !shouldFilterFiber(fiber);
17591824
if (shouldIncludeInTree) {
17601825
recordMount(fiber, parentFiber);
1761-
} else {
1762-
// Generate an ID even for filtered Fibers, in case it's needed later (e.g. for Profiling).
1763-
getOrGenerateFiberID(fiber);
17641826
}
17651827

17661828
if (traceUpdatesEnabled) {
@@ -2005,12 +2067,12 @@ export function attach(
20052067
parentFiber: Fiber | null,
20062068
traceNearestHostComponentUpdate: boolean,
20072069
): boolean {
2070+
const id = getOrGenerateFiberID(nextFiber);
2071+
20082072
if (__DEBUG__) {
20092073
debug('updateFiberRecursively()', nextFiber, parentFiber);
20102074
}
20112075

2012-
const id = getOrGenerateFiberID(nextFiber);
2013-
20142076
if (traceUpdatesEnabled) {
20152077
const elementType = getElementTypeForFiber(nextFiber);
20162078
if (traceNearestHostComponentUpdate) {
@@ -2319,6 +2381,10 @@ export function attach(
23192381
const current = root.current;
23202382
const alternate = current.alternate;
23212383

2384+
// Flush any pending Fibers that we are untracking before processing the new commit.
2385+
// If we don't do this, we might end up double-deleting Fibers in some cases (like Legacy Suspense).
2386+
untrackFibers();
2387+
23222388
currentRootID = getOrGenerateFiberID(current);
23232389

23242390
// Before the traversals, remember to start tracking

packages/react-devtools/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* Updated `react` and `react-dom` API imports in preparation for upcoming stable release ([bvaughn](https://github.com/bvaughn) in [#21488](https://github.com/facebook/react/pull/21488))
1515

1616
#### Bugfix
17-
* Reload all roots after Fast Refresh force-remount (to avoid corrupted Store state) ([bvaughn](https://github.com/bvaughn) in [#21516](https://github.com/facebook/react/pull/21516))
17+
* Reload all roots after Fast Refresh force-remount (to avoid corrupted Store state) ([bvaughn](https://github.com/bvaughn) in [#21516](https://github.com/facebook/react/pull/21516) and [#21523](https://github.com/facebook/react/pull/21523))
1818
* Errors thrown by Store can be dismissed so DevTools remain usable in many cases ([bvaughn](https://github.com/bvaughn) in [#21520](https://github.com/facebook/react/pull/21520))
1919
* Fixed string concatenation problem when a `Symbol` was logged to `console.error` or `console.warn` ([bvaughn](https://github.com/bvaughn) in [#21521](https://github.com/facebook/react/pull/21521))
2020
* DevTools: Fixed version range NPM syntax

0 commit comments

Comments
 (0)