Skip to content

Commit c3b1857

Browse files
authored
[DevTools][Bugfix] Fix DevTools Perf Issue When Unmounting Large React Subtrees (#24863)
We've recently had multiple reports where, if React DevTools was installed, unmounting large React subtrees would take a huge performance hit (ex. from 50ms to 7 seconds). Digging in more, we realized for every fiber that unmounts, we called `untrackFibers`, which calls `clearTimeout` (and does some work manipulating a set, but this wasn't the bulk of the time). We ten call `recordUnmount`, which adds the timer back. Adding and removing the timer so many times was taking upwards of 50ms per timer add/remove call, which was resulting in exorbitant amounts of time spent in DevTools deleting subtrees. It looks like we are calling `untrackFibers` so many times to avoid a race condition with Suspense children where we unmount them twice (first a "virtual" unmount when the suspense boundary is toggled from visible to invisible, and then an actual unmount when the new children are rendered) without modifying `fiberIDMap`. We can fix this race condition by using the `untrackFibersSet` as a lock and not calling `recordUnmount` if the fiber is in the set and hasn't been processed yet. This works because the only way fibers are added in the set is via `recordUnmount` anyway. This PR also adds a test to make sure this change doesn't regress the previous behavior. **Before** ![image](https://user-images.githubusercontent.com/2735514/177655428-774ee306-0568-49ce-987e-b5213b613265.png) **After** ![image](https://user-images.githubusercontent.com/2735514/177655604-a217583f-787e-438e-b6f9-18953fe32444.png)
1 parent 8e35b50 commit c3b1857

File tree

2 files changed

+53
-8
lines changed

2 files changed

+53
-8
lines changed

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe('Store', () => {
1313
let ReactDOMClient;
1414
let agent;
1515
let act;
16+
let actAsync;
1617
let bridge;
1718
let getRendererID;
1819
let legacyRender;
@@ -30,6 +31,7 @@ describe('Store', () => {
3031

3132
const utils = require('./utils');
3233
act = utils.act;
34+
actAsync = utils.actAsync;
3335
getRendererID = utils.getRendererID;
3436
legacyRender = utils.legacyRender;
3537
withErrorsOrWarningsIgnored = utils.withErrorsOrWarningsIgnored;
@@ -2064,5 +2066,47 @@ describe('Store', () => {
20642066
expect(store.errorCount).toBe(0);
20652067
expect(store.warningCount).toBe(0);
20662068
});
2069+
2070+
// Regression test for https://github.com/facebook/react/issues/23202
2071+
// @reactVersion >= 18.0
2072+
it('suspense boundary children should not double unmount and error', async () => {
2073+
async function fakeImport(result) {
2074+
return {default: result};
2075+
}
2076+
2077+
const ChildA = () => null;
2078+
const ChildB = () => null;
2079+
2080+
const LazyChildA = React.lazy(() => fakeImport(ChildA));
2081+
const LazyChildB = React.lazy(() => fakeImport(ChildB));
2082+
2083+
function App({renderA}) {
2084+
return (
2085+
<React.Suspense>
2086+
{renderA ? <LazyChildA /> : <LazyChildB />}
2087+
</React.Suspense>
2088+
);
2089+
}
2090+
2091+
const container = document.createElement('div');
2092+
const root = ReactDOMClient.createRoot(container);
2093+
await actAsync(() => root.render(<App renderA={true} />));
2094+
2095+
expect(store).toMatchInlineSnapshot(`
2096+
[root]
2097+
▾ <App>
2098+
▾ <Suspense>
2099+
<ChildA>
2100+
`);
2101+
2102+
await actAsync(() => root.render(<App renderA={false} />));
2103+
2104+
expect(store).toMatchInlineSnapshot(`
2105+
[root]
2106+
▾ <App>
2107+
▾ <Suspense>
2108+
<ChildB>
2109+
`);
2110+
});
20672111
});
20682112
});

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2632,14 +2632,15 @@ export function attach(
26322632
}
26332633

26342634
function handleCommitFiberUnmount(fiber) {
2635-
// Flush any pending Fibers that we are untracking before processing the new commit.
2636-
// If we don't do this, we might end up double-deleting Fibers in some cases (like Legacy Suspense).
2637-
untrackFibers();
2638-
2639-
// This is not recursive.
2640-
// We can't traverse fibers after unmounting so instead
2641-
// we rely on React telling us about each unmount.
2642-
recordUnmount(fiber, false);
2635+
// If the untrackFiberSet already has the unmounted Fiber, this means we've already
2636+
// recordedUnmount, so we don't need to do it again. If we don't do this, we might
2637+
// end up double-deleting Fibers in some cases (like Legacy Suspense).
2638+
if (!untrackFibersSet.has(fiber)) {
2639+
// This is not recursive.
2640+
// We can't traverse fibers after unmounting so instead
2641+
// we rely on React telling us about each unmount.
2642+
recordUnmount(fiber, false);
2643+
}
26432644
}
26442645

26452646
function handlePostCommitFiberRoot(root) {

0 commit comments

Comments
 (0)