@samitouri / QOS-React / commits / 3fb190f729

[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 };