Skip to content

Commit 0f72f73

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 0f72f73

File tree

3 files changed

+101
-28
lines changed

3 files changed

+101
-28
lines changed

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

Lines changed: 13 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
`);

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

Lines changed: 87 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,17 +1070,64 @@ 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);
1096+
}
1097+
}
1098+
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+
if (__DEBUG__) {
1109+
console.log(
1110+
'untrackFibers() after delay:',
1111+
Array.from(untrackFibersSet)
1112+
.map(getFiberIDUnsafe)
1113+
.join(','),
1114+
);
10631115
}
1116+
1117+
untrackFibersSet.forEach(fiber => {
1118+
const fiberID = getFiberIDUnsafe(fiber);
1119+
if (fiberID !== null) {
1120+
idToArbitraryFiberMap.delete(fiberID);
1121+
}
1122+
1123+
fiberToIDMap.delete(fiber);
1124+
1125+
const {alternate} = fiber;
1126+
if (alternate !== null) {
1127+
fiberToIDMap.delete(alternate);
1128+
}
1129+
});
1130+
untrackFibersSet.clear();
10641131
}
10651132

10661133
function getChangeDescription(
@@ -1607,13 +1674,13 @@ export function attach(
16071674
}
16081675

16091676
function recordMount(fiber: Fiber, parentFiber: Fiber | null) {
1677+
const isRoot = fiber.tag === HostRoot;
1678+
const id = getOrGenerateFiberID(fiber);
1679+
16101680
if (__DEBUG__) {
16111681
debug('recordMount()', fiber, parentFiber);
16121682
}
16131683

1614-
const isRoot = fiber.tag === HostRoot;
1615-
const id = getOrGenerateFiberID(fiber);
1616-
16171684
const hasOwnerMetadata = fiber.hasOwnProperty('_debugOwner');
16181685
const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration');
16191686

@@ -1745,6 +1812,9 @@ export function attach(
17451812
// This reduces the chance of stack overflow for wide trees (e.g. lists with many items).
17461813
let fiber: Fiber | null = firstChild;
17471814
while (fiber !== null) {
1815+
// Generate an ID even for filtered Fibers, in case it's needed later (e.g. for Profiling).
1816+
getOrGenerateFiberID(fiber);
1817+
17481818
if (__DEBUG__) {
17491819
debug('mountFiberRecursively()', fiber, parentFiber);
17501820
}
@@ -1758,9 +1828,6 @@ export function attach(
17581828
const shouldIncludeInTree = !shouldFilterFiber(fiber);
17591829
if (shouldIncludeInTree) {
17601830
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);
17641831
}
17651832

17661833
if (traceUpdatesEnabled) {
@@ -2005,12 +2072,12 @@ export function attach(
20052072
parentFiber: Fiber | null,
20062073
traceNearestHostComponentUpdate: boolean,
20072074
): boolean {
2075+
const id = getOrGenerateFiberID(nextFiber);
2076+
20082077
if (__DEBUG__) {
20092078
debug('updateFiberRecursively()', nextFiber, parentFiber);
20102079
}
20112080

2012-
const id = getOrGenerateFiberID(nextFiber);
2013-
20142081
if (traceUpdatesEnabled) {
20152082
const elementType = getElementTypeForFiber(nextFiber);
20162083
if (traceNearestHostComponentUpdate) {
@@ -2319,6 +2386,10 @@ export function attach(
23192386
const current = root.current;
23202387
const alternate = current.alternate;
23212388

2389+
// Flush any pending Fibers that we are untracking before processing the new commit.
2390+
// If we don't do this, we might end up double-deleting Fibers in some cases (like Legacy Suspense).
2391+
untrackFibers();
2392+
23222393
currentRootID = getOrGenerateFiberID(current);
23232394

23242395
// 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)