[DevTools] Reset forced states when changing component filters (#34929)
Sebastian "Sebbie" Silbermann committed
Oct 31, 2025 at 12:57 UTC
c9ddee7e36c64311ad4e5e7acfca6c092d8a6264
3 files changed
+128
packages/react-devtools-shared/src/__tests__/setupTests.js
+10
@@ -116,6 +116,16 @@ function shouldIgnoreConsoleErrorOrWarn(args) {
116
return false;
117
}
118
119
+ const maybeError = args[1];
120
+ if (
121
+ maybeError !== null &&
122
+ typeof maybeError === 'object' &&
123
+ maybeError.message === 'Simulated error coming from DevTools'
124
+ ) {
125
+ // Error from forcing an error boundary.
126
+ return true;
127
+ }
128
+
129
return global._ignoredErrorOrWarningMessages.some(errorOrWarningMessage => {
130
return firstArg.indexOf(errorOrWarningMessage) !== -1;
131
});
packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js
+78
@@ -18,12 +18,14 @@ import {
18
describe('Store component filters', () => {
19
let React;
20
let Types;
21
+ let agent;
22
let bridge: FrontendBridge;
23
let store: Store;
24
let utils;
25
let actAsync;
26
27
beforeEach(() => {
28
+ agent = global.agent;
29
bridge = global.bridge;
30
store = global.store;
31
store.collapseNodesByDefault = false;
@@ -740,4 +742,80 @@ describe('Store component filters', () => {
742
`);
743
});
744
});
745
+
746
+ // @reactVersion >= 16.6
747
+ it('resets forced error and fallback states when filters are changed', async () => {
748
+ store.componentFilters = [];
749
+ class ErrorBoundary extends React.Component {
750
+ state = {hasError: false};
751
+
752
+ static getDerivedStateFromError() {
753
+ return {hasError: true};
754
+ }
755
+
756
+ render() {
757
+ if (this.state.hasError) {
758
+ return <div key="did-error" />;
759
+ }
760
+ return this.props.children;
761
+ }
762
+ }
763
+
764
+ function App() {
765
+ return (
766
+ <>
767
+ <React.Suspense fallback={<div key="loading" />}>
768
+ <div key="suspense-content" />
769
+ </React.Suspense>
770
+ <ErrorBoundary>
771
+ <div key="error-content" />
772
+ </ErrorBoundary>
773
+ </>
774
+ );
775
+ }
776
+
777
+ await actAsync(async () => {
778
+ render(<App />);
779
+ });
780
+ const rendererID = utils.getRendererID();
781
+ await actAsync(() => {
782
+ agent.overrideSuspense({
783
+ id: store.getElementIDAtIndex(2),
784
+ rendererID,
785
+ forceFallback: true,
786
+ });
787
+ agent.overrideError({
788
+ id: store.getElementIDAtIndex(4),
789
+ rendererID,
790
+ forceError: true,
791
+ });
792
+ });
793
+
794
+ expect(store).toMatchInlineSnapshot(`
795
+ [root]
796
+ ▾ <App>
797
+ ▾ <Suspense>
798
+ <div key="loading">
799
+ ▾ <ErrorBoundary>
800
+ <div key="did-error">
801
+ [suspense-root] rects={[]}
802
+ <Suspense name="App" rects={[]}>
803
+ `);
804
+
805
+ await actAsync(() => {
806
+ store.componentFilters = [
807
+ utils.createElementTypeFilter(Types.ElementTypeFunction, true),
808
+ ];
809
+ });
810
+
811
+ expect(store).toMatchInlineSnapshot(`
812
+ [root]
813
+ ▾ <Suspense>
814
+ <div key="suspense-content">
815
+ ▾ <ErrorBoundary>
816
+ <div key="error-content">
817
+ [suspense-root] rects={[]}
818
+ <Suspense name="Unknown" rects={[]}>
819
+ `);
820
+ });
821
});
packages/react-devtools-shared/src/backend/fiber/renderer.js
+40
@@ -1512,6 +1512,11 @@ export function attach(
1512
throw Error('Cannot modify filter preferences while profiling');
1513
}
1514
1515
+ const previousForcedFallbacks =
1516
+ forceFallbackForFibers.size > 0 ? new Set(forceFallbackForFibers) : null;
1517
+ const previousForcedErrors =
1518
+ forceErrorForFibers.size > 0 ? new Map(forceErrorForFibers) : null;
1519
+
1520
// Recursively unmount all roots.
1521
hook.getFiberRoots(rendererID).forEach(root => {
1522
const rootInstance = rootToFiberInstanceMap.get(root);
@@ -1532,6 +1537,41 @@ export function attach(
1537
// Reset pseudo counters so that new path selections will be persisted.
1538
rootDisplayNameCounter.clear();
1539
1540
+ // We just cleared all the forced states. Schedule updates on the affected Fibers
1541
+ // so that we get their initial states again according to the new filters.
1542
+ if (typeof scheduleUpdate === 'function') {
1543
+ if (previousForcedFallbacks !== null) {
1544
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
1545
+ for (const fiber of previousForcedFallbacks) {
1546
+ if (typeof scheduleRetry === 'function') {
1547
+ scheduleRetry(fiber);
1548
+ } else {
1549
+ scheduleUpdate(fiber);
1550
+ }
1551
+ }
1552
+ }
1553
+ if (
1554
+ previousForcedErrors !== null &&
1555
+ typeof setErrorHandler === 'function'
1556
+ ) {
1557
+ // Unlike for Suspense, disabling the forced error state requires setting
1558
+ // the status to false first. `shouldErrorFiberAccordingToMap` will clear
1559
+ // the Fibers later.
1560
+ setErrorHandler(shouldErrorFiberAccordingToMap);
1561
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
1562
+ for (const [fiber, shouldError] of previousForcedErrors) {
1563
+ forceErrorForFibers.set(fiber, false);
1564
+ if (shouldError) {
1565
+ if (typeof scheduleRetry === 'function') {
1566
+ scheduleRetry(fiber);
1567
+ } else {
1568
+ scheduleUpdate(fiber);
1569
+ }
1570
+ }
1571
+ }
1572
+ }
1573
+ }
1574
+
1575
// Recursively re-mount all roots with new filter criteria applied.
1576
hook.getFiberRoots(rendererID).forEach(root => {
1577
const current = root.current;