Skip to content

Commit

Permalink
[DevTools][Bugfix] Fix DevTools Perf Issue When Unmounting Large Reac…
Browse files Browse the repository at this point in the history
…t 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)
  • Loading branch information
lunaruan authored Jul 7, 2022
1 parent 8e35b50 commit c3b1857
Show file tree
Hide file tree
Showing 2 changed files with 53 additions and 8 deletions.
44 changes: 44 additions & 0 deletions packages/react-devtools-shared/src/__tests__/store-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('Store', () => {
let ReactDOMClient;
let agent;
let act;
let actAsync;
let bridge;
let getRendererID;
let legacyRender;
Expand All @@ -30,6 +31,7 @@ describe('Store', () => {

const utils = require('./utils');
act = utils.act;
actAsync = utils.actAsync;
getRendererID = utils.getRendererID;
legacyRender = utils.legacyRender;
withErrorsOrWarningsIgnored = utils.withErrorsOrWarningsIgnored;
Expand Down Expand Up @@ -2064,5 +2066,47 @@ describe('Store', () => {
expect(store.errorCount).toBe(0);
expect(store.warningCount).toBe(0);
});

// Regression test for https://github.com/facebook/react/issues/23202
// @reactVersion >= 18.0
it('suspense boundary children should not double unmount and error', async () => {
async function fakeImport(result) {
return {default: result};
}

const ChildA = () => null;
const ChildB = () => null;

const LazyChildA = React.lazy(() => fakeImport(ChildA));
const LazyChildB = React.lazy(() => fakeImport(ChildB));

function App({renderA}) {
return (
<React.Suspense>
{renderA ? <LazyChildA /> : <LazyChildB />}
</React.Suspense>
);
}

const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await actAsync(() => root.render(<App renderA={true} />));

expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Suspense>
<ChildA>
`);

await actAsync(() => root.render(<App renderA={false} />));

expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Suspense>
<ChildB>
`);
});
});
});
17 changes: 9 additions & 8 deletions packages/react-devtools-shared/src/backend/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2632,14 +2632,15 @@ export function attach(
}

function handleCommitFiberUnmount(fiber) {
// Flush any pending Fibers that we are untracking before processing the new commit.
// If we don't do this, we might end up double-deleting Fibers in some cases (like Legacy Suspense).
untrackFibers();

// This is not recursive.
// We can't traverse fibers after unmounting so instead
// we rely on React telling us about each unmount.
recordUnmount(fiber, false);
// If the untrackFiberSet already has the unmounted Fiber, this means we've already
// recordedUnmount, so we don't need to do it again. If we don't do this, we might
// end up double-deleting Fibers in some cases (like Legacy Suspense).
if (!untrackFibersSet.has(fiber)) {
// This is not recursive.
// We can't traverse fibers after unmounting so instead
// we rely on React telling us about each unmount.
recordUnmount(fiber, false);
}
}

function handlePostCommitFiberRoot(root) {
Expand Down

0 comments on commit c3b1857

Please sign in to comment.