[DevTools] Avoid renders of stale Suspense store (#34396)
Sebastian "Sebbie" Silbermann committed
Sep 8, 2025 at 11:42 UTC
3fb190f729ddcf32e7a76961082929683a3395a7
4 files changed
+73
-76
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js
+3
-3
@@ -8,6 +8,7 @@
8
*/
9
10
import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
11
+import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
12
13
import * as React from 'react';
14
import {useContext} from 'react';
@@ -15,13 +16,12 @@ import {
16
TreeDispatcherContext,
17
TreeStateContext,
18
} from '../Components/TreeContext';
18
-import {StoreContext} from '../context';
19
import {useHighlightHostInstance} from '../hooks';
20
import styles from './SuspenseBreadcrumbs.css';
21
-import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
21
+import {useSuspenseStore} from './SuspenseTreeContext';
22
23
export default function SuspenseBreadcrumbs(): React$Node {
24
- const store = useContext(StoreContext);
24
+ const store = useSuspenseStore();
25
const dispatch = useContext(TreeDispatcherContext);
26
const {inspectedElementID} = useContext(TreeStateContext);
27
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+21
-22
@@ -19,10 +19,9 @@ import {
19
TreeDispatcherContext,
20
TreeStateContext,
21
} from '../Components/TreeContext';
22
-import {StoreContext} from '../context';
22
import {useHighlightHostInstance} from '../hooks';
23
import styles from './SuspenseRects.css';
25
-import {SuspenseTreeStateContext} from './SuspenseTreeContext';
24
+import {useSuspenseStore} from './SuspenseTreeContext';
25
import typeof {
26
SyntheticMouseEvent,
27
SyntheticPointerEvent,
@@ -46,7 +45,7 @@ function SuspenseRects({
45
suspenseID: SuspenseNode['id'],
46
}): React$Node {
47
const dispatch = useContext(TreeDispatcherContext);
49
- const store = useContext(StoreContext);
48
+ const store = useSuspenseStore();
49
50
const {inspectedElementID} = useContext(TreeStateContext);
51
@@ -109,9 +108,9 @@ function SuspenseRects({
108
109
function getDocumentBoundingRect(
110
store: Store,
112
- shells: $ReadOnlyArray<SuspenseNode['id']>,
111
+ roots: $ReadOnlyArray<SuspenseNode['id']>,
112
): Rect {
114
- if (shells.length === 0) {
113
+ if (roots.length === 0) {
114
return {x: 0, y: 0, width: 0, height: 0};
115
}
116
@@ -120,14 +119,14 @@ function getDocumentBoundingRect(
119
let maxX = Number.NEGATIVE_INFINITY;
120
let maxY = Number.NEGATIVE_INFINITY;
121
123
- for (let i = 0; i < shells.length; i++) {
124
- const shellID = shells[i];
125
- const shell = store.getSuspenseByID(shellID);
126
- if (shell === null) {
122
+ for (let i = 0; i < roots.length; i++) {
123
+ const rootID = roots[i];
124
+ const root = store.getSuspenseByID(rootID);
125
+ if (root === null) {
126
continue;
127
}
128
130
- const rects = shell.rects;
129
+ const rects = root.rects;
130
if (rects === null) {
131
continue;
132
}
@@ -154,20 +153,20 @@ function getDocumentBoundingRect(
153
}
154
155
function SuspenseRectsShell({
157
- shellID,
156
+ rootID,
157
}: {
159
- shellID: SuspenseNode['id'],
158
+ rootID: SuspenseNode['id'],
159
}): React$Node {
161
- const store = useContext(StoreContext);
162
- const shell = store.getSuspenseByID(shellID);
163
- if (shell === null) {
164
- console.warn(`<Element> Could not find suspense node id ${shellID}`);
160
+ const store = useSuspenseStore();
161
+ const root = store.getSuspenseByID(rootID);
162
+ if (root === null) {
163
+ console.warn(`<Element> Could not find suspense node id ${rootID}`);
164
return null;
165
}
166
167
return (
168
<g>
170
- {shell.children.map(childID => {
169
+ {root.children.map(childID => {
170
return <SuspenseRects key={childID} suspenseID={childID} />;
171
})}
172
</g>
@@ -175,11 +174,11 @@ function SuspenseRectsShell({
174
}
175
176
function SuspenseRectsContainer(): React$Node {
178
- const store = useContext(StoreContext);
177
+ const store = useSuspenseStore();
178
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
180
- const {shells} = useContext(SuspenseTreeStateContext);
179
+ const roots = store.roots;
180
182
- const boundingRect = getDocumentBoundingRect(store, shells);
181
+ const boundingRect = getDocumentBoundingRect(store, roots);
182
183
const width = '100%';
184
const boundingRectWidth = boundingRect.width;
@@ -193,8 +192,8 @@ function SuspenseRectsContainer(): React$Node {
192
<svg
193
style={{width, height}}
194
viewBox={`${boundingRect.x} ${boundingRect.y} ${boundingRect.width} ${boundingRect.height}`}>
196
- {shells.map(shellID => {
197
- return <SuspenseRectsShell key={shellID} shellID={shellID} />;
195
+ {roots.map(rootID => {
196
+ return <SuspenseRectsShell key={rootID} rootID={rootID} />;
197
})}
198
</svg>
199
</div>
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+13
-14
@@ -12,10 +12,10 @@ import type Store from '../../store';
12
13
import * as React from 'react';
14
import {useContext, useLayoutEffect, useMemo, useRef, useState} from 'react';
15
-import {BridgeContext, StoreContext} from '../context';
15
+import {BridgeContext} from '../context';
16
import {TreeDispatcherContext} from '../Components/TreeContext';
17
import {useHighlightHostInstance} from '../hooks';
18
-import {SuspenseTreeStateContext} from './SuspenseTreeContext';
18
+import {useSuspenseStore} from './SuspenseTreeContext';
19
import styles from './SuspenseTimeline.css';
20
import typeof {
21
SyntheticEvent,
@@ -63,14 +63,14 @@ function getSuspendableDocumentOrderSuspense(
63
64
function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
65
const bridge = useContext(BridgeContext);
66
- const store = useContext(StoreContext);
66
+ const store = useSuspenseStore();
67
const dispatch = useContext(TreeDispatcherContext);
68
const {highlightHostInstance, clearHighlightHostInstance} =
69
useHighlightHostInstance();
70
71
const timeline = useMemo(() => {
72
return getSuspendableDocumentOrderSuspense(store, rootID);
73
- }, [store, rootID]);
73
+ }, [store, store.revisionSuspense, rootID]);
74
75
const inputRef = useRef<HTMLElement | null>(null);
76
const inputBBox = useRef<ClientRect | null>(null);
@@ -161,6 +161,7 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
161
function handleFocus() {
162
const suspense = timeline[value];
163
164
+ dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspense.id});
165
highlightHostInstance(suspense.id);
166
}
167
@@ -213,10 +214,10 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
214
}
215
216
export default function SuspenseTimeline(): React$Node {
216
- const store = useContext(StoreContext);
217
- const {shells} = useContext(SuspenseTreeStateContext);
217
+ const store = useSuspenseStore();
218
219
- const defaultSelectedRootID = shells.find(rootID => {
219
+ const roots = store.roots;
220
+ const defaultSelectedRootID = roots.find(rootID => {
221
const suspense = store.getSuspenseByID(rootID);
222
return (
223
store.supportsTogglingSuspense(rootID) &&
@@ -239,20 +240,18 @@ export default function SuspenseTimeline(): React$Node {
240
return (
241
<div className={styles.SuspenseTimelineContainer}>
242
<SuspenseTimelineInput key={selectedRootID} rootID={selectedRootID} />
242
- {shells.length > 0 && (
243
+ {roots.length > 0 && (
244
<select
245
aria-label="Select Suspense Root"
246
className={styles.SuspenseTimelineRootSwitcher}
246
- onChange={handleChange}>
247
- {shells.map(rootID => {
247
+ onChange={handleChange}
248
+ value={selectedRootID}>
249
+ {roots.map(rootID => {
250
// TODO: Use name
251
const name = '#' + rootID;
252
// TODO: Highlight host on hover
253
return (
252
- <option
253
- key={rootID}
254
- selected={rootID === selectedRootID}
255
- value={rootID}>
254
+ <option key={rootID} value={rootID}>
255
{name}
256
</option>
257
);
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+36
-37
@@ -7,6 +7,7 @@
7
* @flow
8
*/
9
import type {ReactContext} from 'shared/ReactTypes';
10
+import type Store from '../../store';
11
12
import * as React from 'react';
13
import {
@@ -17,17 +18,12 @@ import {
18
useMemo,
19
useReducer,
20
} from 'react';
20
-import type {SuspenseNode} from '../../../frontend/types';
21
import {StoreContext} from '../context';
22
23
-export type SuspenseTreeState = {
24
- shells: $ReadOnlyArray<SuspenseNode['id']>,
25
-};
23
+export type SuspenseTreeState = {};
24
27
-type ACTION_HANDLE_SUSPENSE_TREE_MUTATION = {
28
- type: 'HANDLE_SUSPENSE_TREE_MUTATION',
29
-};
30
-export type SuspenseTreeAction = ACTION_HANDLE_SUSPENSE_TREE_MUTATION;
25
+// unused for now
26
+export type SuspenseTreeAction = {type: 'unused'};
27
export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void;
28
29
const SuspenseTreeStateContext: ReactContext<SuspenseTreeState> =
@@ -42,11 +38,39 @@ type Props = {
38
children: React$Node,
39
};
40
45
-function SuspenseTreeContextController({children}: Props): React.Node {
41
+/**
42
+ * The Store is mutable. This Hook ensures renders read the latest Suspense related
43
+ * data.
44
+ */
45
+function useSuspenseStore(): Store {
46
const store = useContext(StoreContext);
47
-
47
+ const [, storeUpdated] = useReducer<number, number, void>(
48
+ (x: number) => (x + 1) % Number.MAX_SAFE_INTEGER,
49
+ 0,
50
+ );
51
const initialRevision = useMemo(() => store.revisionSuspense, [store]);
52
+ // We're currently storing everything Suspense related in the same Store as
53
+ // Components. However, most reads are currently stateless. This ensures
54
+ // the latest state is always read from the Store.
55
+ useEffect(() => {
56
+ const handleSuspenseTreeMutated = () => {
57
+ storeUpdated();
58
+ };
59
+
60
+ // Since this is a passive effect, the tree may have been mutated before our initial subscription.
61
+ if (store.revisionSuspense !== initialRevision) {
62
+ // At the moment, we can treat this as a mutation.
63
+ handleSuspenseTreeMutated();
64
+ }
65
+
66
+ store.addListener('suspenseTreeMutated', handleSuspenseTreeMutated);
67
+ return () =>
68
+ store.removeListener('suspenseTreeMutated', handleSuspenseTreeMutated);
69
+ }, [initialRevision, store]);
70
+ return store;
71
+}
72
73
+function SuspenseTreeContextController({children}: Props): React.Node {
74
// This reducer is created inline because it needs access to the Store.
75
// The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
76
// so it's okay for the reducer to have an empty dependencies array.
@@ -58,8 +82,6 @@ function SuspenseTreeContextController({children}: Props): React.Node {
82
): SuspenseTreeState => {
83
const {type} = action;
84
switch (type) {
61
- case 'HANDLE_SUSPENSE_TREE_MUTATION':
62
- return {...state, shells: store.roots};
85
default:
86
throw new Error(`Unrecognized action "${type}"`);
87
}
@@ -67,9 +89,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
89
[],
90
);
91
70
- const initialState: SuspenseTreeState = {
71
- shells: store.roots,
72
- };
92
+ const initialState: SuspenseTreeState = {};
93
const [state, dispatch] = useReducer(reducer, initialState);
94
const transitionDispatch = useMemo(
95
() => (action: SuspenseTreeAction) =>
@@ -79,28 +99,6 @@ function SuspenseTreeContextController({children}: Props): React.Node {
99
[dispatch],
100
);
101
82
- useEffect(() => {
83
- const handleSuspenseTreeMutated = () => {
84
- dispatch({
85
- type: 'HANDLE_SUSPENSE_TREE_MUTATION',
86
- });
87
- };
88
-
89
- // Since this is a passive effect, the tree may have been mutated before our initial subscription.
90
- if (store.revisionSuspense !== initialRevision) {
91
- // At the moment, we can treat this as a mutation.
92
- // We don't know which Elements were newly added/removed, but that should be okay in this case.
93
- // It would only impact the search state, which is unlikely to exist yet at this point.
94
- dispatch({
95
- type: 'HANDLE_SUSPENSE_TREE_MUTATION',
96
- });
97
- }
98
-
99
- store.addListener('suspenseTreeMutated', handleSuspenseTreeMutated);
100
- return () =>
101
- store.removeListener('suspenseTreeMutated', handleSuspenseTreeMutated);
102
- }, [dispatch, initialRevision, store]);
103
-
102
return (
103
<SuspenseTreeStateContext.Provider value={state}>
104
<SuspenseTreeDispatcherContext.Provider value={transitionDispatch}>
@@ -114,4 +112,5 @@ export {
112
SuspenseTreeDispatcherContext,
113
SuspenseTreeStateContext,
114
SuspenseTreeContextController,
115
+ useSuspenseStore,
116
};