Repository: facebook/react
Pull request: [DevTools] Reset forced states when changing component filters

When we change component filters, we unmount the whole tree and mount it again. That means that any Suspense rect measurement will be gone. On mount, we're not measuring Suspense nodes since there's nothing to measure. Users can't even get back the original state since we no longer know which Fibers we forced a fallback for.

Now we're scheduling updates on the Fibers we removed forced states from when applying new filters. We could also do this in `untrackFiber` directly but most of the time this will be for Fibers that are no longer alive so the checks would be wasted.

As an alternative, we could collect all rects of suspended boundaries and restore those rects. We'd also have to restore the Sets of Fibers with forced states.

This is mainly relevant when editing the Suspense timeline and moving in and out of Activity slices (https://github.com/facebook/react/pull/34908)

Changed files:
filename: packages/react-devtools-shared/src/__tests__/setupTests.js
patch: @@ -116,6 +116,16 @@ function shouldIgnoreConsoleErrorOrWarn(args) {
     return false;
   }
 
+  const maybeError = args[1];
+  if (
+    maybeError !== null &&
+    typeof maybeError === 'object' &&
+    maybeError.message === 'Simulated error coming from DevTools'
+  ) {
+    // Error from forcing an error boundary.
+    return true;
+  }
+
   return global._ignoredErrorOrWarningMessages.some(errorOrWarningMessage => {
     return firstArg.indexOf(errorOrWarningMessage) !== -1;
   });

filename: packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js
patch: @@ -18,12 +18,14 @@ import {
 describe('Store component filters', () => {
   let React;
   let Types;
+  let agent;
   let bridge: FrontendBridge;
   let store: Store;
   let utils;
   let actAsync;
 
   beforeEach(() => {
+    agent = global.agent;
     bridge = global.bridge;
     store = global.store;
     store.collapseNodesByDefault = false;
@@ -740,4 +742,80 @@ describe('Store component filters', () => {
       `);
     });
   });
+
+  // @reactVersion >= 16.6
+  it('resets forced error and fallback states when filters are changed', async () => {
+    store.componentFilters = [];
+    class ErrorBoundary extends React.Component {
+      state = {hasError: false};
+
+      static getDerivedStateFromError() {
+        return {hasError: true};
+      }
+
+      render() {
+        if (this.state.hasError) {
+          return <div key="did-error" />;
+        }
+        return this.props.children;
+      }
+    }
+
+    function App() {
+      return (
+        <>
+          <React.Suspense fallback={<div key="loading" />}>
+            <div key="suspense-content" />
+          </React.Suspense>
+          <ErrorBoundary>
+            <div key="error-content" />
+          </ErrorBoundary>
+        </>
+      );
+    }
+
+    await actAsync(async () => {
+      render(<App />);
+    });
+    const rendererID = utils.getRendererID();
+    await actAsync(() => {
+      agent.overrideSuspense({
+        id: store.getElementIDAtIndex(2),
+        rendererID,
+        forceFallback: true,
+      });
+      agent.overrideError({
+        id: store.getElementIDAtIndex(4),
+        rendererID,
+        forceError: true,
+      });
+    });
+
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+        ▾ <App>
+          ▾ <Suspense>
+              <div key="loading">
+          ▾ <ErrorBoundary>
+              <div key="did-error">
+      [suspense-root]  rects={[]}
+        <Suspense name="App" rects={[]}>
+    `);
+
+    await actAsync(() => {
+      store.componentFilters = [
+        utils.createElementTypeFilter(Types.ElementTypeFunction, true),
+      ];
+    });
+
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+        ▾ <Suspense>
+            <div key="suspense-content">
+        ▾ <ErrorBoundary>
+            <div key="error-content">
+      [suspense-root]  rects={[]}
+        <Suspense name="Unknown" rects={[]}>
+    `);
+  });
 });

filename: packages/react-devtools-shared/src/backend/fiber/renderer.js
patch: @@ -1512,6 +1512,11 @@ export function attach(
       throw Error('Cannot modify filter preferences while profiling');
     }
 
+    const previousForcedFallbacks =
+      forceFallbackForFibers.size > 0 ? new Set(forceFallbackForFibers) : null;
+    const previousForcedErrors =
+      forceErrorForFibers.size > 0 ? new Map(forceErrorForFibers) : null;
+
     // Recursively unmount all roots.
     hook.getFiberRoots(rendererID).forEach(root => {
       const rootInstance = rootToFiberInstanceMap.get(root);
@@ -1532,6 +1537,41 @@ export function attach(
     // Reset pseudo counters so that new path selections will be persisted.
     rootDisplayNameCounter.clear();
 
+    // We just cleared all the forced states. Schedule updates on the affected Fibers
+    // so that we get their initial states again according to the new filters.
+    if (typeof scheduleUpdate === 'function') {
+      if (previousForcedFallbacks !== null) {
+        // eslint-disable-next-line no-for-of-loops/no-for-of-loops
+        for (const fiber of previousForcedFallbacks) {
+          if (typeof scheduleRetry === 'function') {
+            scheduleRetry(fiber);
+          } else {
+            scheduleUpdate(fiber);
+          }
+        }
+      }
+      if (
+        previousForcedErrors !== null &&
+        typeof setErrorHandler === 'function'
+      ) {
+        // Unlike for Suspense, disabling the forced error state requires setting
+        // the status to false first. `shouldErrorFiberAccordingToMap` will clear
+        // the Fibers later.
+        setErrorHandler(shouldErrorFiberAccordingToMap);
+        // eslint-disable-next-line no-for-of-loops/no-for-of-loops
+        for (const [fiber, shouldError] of previousForcedErrors) {
+          forceErrorForFibers.set(fiber, false);
+          if (shouldError) {
+            if (typeof scheduleRetry === 'function') {
+              scheduleRetry(fiber);
+            } else {
+              scheduleUpdate(fiber);
+            }
+          }
+        }
+      }
+    }
+
     // Recursively re-mount all roots with new filter criteria applied.
     hook.getFiberRoots(rendererID).forEach(root => {
       const current = root.current;