Skip to content

Commit d6604ac

Browse files
author
Brian Vaughn
authored
Account for another DevTools + Fast Refresh edge case (#21523)
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 b15bf2b commit d6604ac

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,
@@ -1610,13 +1672,13 @@ export function attach(
16101672
}
16111673

16121674
function recordMount(fiber: Fiber, parentFiber: Fiber | null) {
1675+
const isRoot = fiber.tag === HostRoot;
1676+
const id = getOrGenerateFiberID(fiber);
1677+
16131678
if (__DEBUG__) {
16141679
debug('recordMount()', fiber, parentFiber);
16151680
}
16161681

1617-
const isRoot = fiber.tag === HostRoot;
1618-
const id = getOrGenerateFiberID(fiber);
1619-
16201682
const hasOwnerMetadata = fiber.hasOwnProperty('_debugOwner');
16211683
const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration');
16221684

@@ -1748,6 +1810,9 @@ export function attach(
17481810
// This reduces the chance of stack overflow for wide trees (e.g. lists with many items).
17491811
let fiber: Fiber | null = firstChild;
17501812
while (fiber !== null) {
1813+
// Generate an ID even for filtered Fibers, in case it's needed later (e.g. for Profiling).
1814+
getOrGenerateFiberID(fiber);
1815+
17511816
if (__DEBUG__) {
17521817
debug('mountFiberRecursively()', fiber, parentFiber);
17531818
}
@@ -1761,9 +1826,6 @@ export function attach(
17611826
const shouldIncludeInTree = !shouldFilterFiber(fiber);
17621827
if (shouldIncludeInTree) {
17631828
recordMount(fiber, parentFiber);
1764-
} else {
1765-
// Generate an ID even for filtered Fibers, in case it's needed later (e.g. for Profiling).
1766-
getOrGenerateFiberID(fiber);
17671829
}
17681830

17691831
if (traceUpdatesEnabled) {
@@ -2008,12 +2070,12 @@ export function attach(
20082070
parentFiber: Fiber | null,
20092071
traceNearestHostComponentUpdate: boolean,
20102072
): boolean {
2073+
const id = getOrGenerateFiberID(nextFiber);
2074+
20112075
if (__DEBUG__) {
20122076
debug('updateFiberRecursively()', nextFiber, parentFiber);
20132077
}
20142078

2015-
const id = getOrGenerateFiberID(nextFiber);
2016-
20172079
if (traceUpdatesEnabled) {
20182080
const elementType = getElementTypeForFiber(nextFiber);
20192081
if (traceNearestHostComponentUpdate) {
@@ -2322,6 +2384,10 @@ export function attach(
23222384
const current = root.current;
23232385
const alternate = current.alternate;
23242386

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

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