Skip to content

Commit 3fb190f

Browse files
authored
[DevTools] Avoid renders of stale Suspense store (#34396)
1 parent f5e96b9 commit 3fb190f

File tree

4 files changed

+73
-76
lines changed

4 files changed

+73
-76
lines changed

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,20 @@
88
*/
99

1010
import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
11+
import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
1112

1213
import * as React from 'react';
1314
import {useContext} from 'react';
1415
import {
1516
TreeDispatcherContext,
1617
TreeStateContext,
1718
} from '../Components/TreeContext';
18-
import {StoreContext} from '../context';
1919
import {useHighlightHostInstance} from '../hooks';
2020
import styles from './SuspenseBreadcrumbs.css';
21-
import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
21+
import {useSuspenseStore} from './SuspenseTreeContext';
2222

2323
export default function SuspenseBreadcrumbs(): React$Node {
24-
const store = useContext(StoreContext);
24+
const store = useSuspenseStore();
2525
const dispatch = useContext(TreeDispatcherContext);
2626
const {inspectedElementID} = useContext(TreeStateContext);
2727

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,9 @@ import {
1919
TreeDispatcherContext,
2020
TreeStateContext,
2121
} from '../Components/TreeContext';
22-
import {StoreContext} from '../context';
2322
import {useHighlightHostInstance} from '../hooks';
2423
import styles from './SuspenseRects.css';
25-
import {SuspenseTreeStateContext} from './SuspenseTreeContext';
24+
import {useSuspenseStore} from './SuspenseTreeContext';
2625
import typeof {
2726
SyntheticMouseEvent,
2827
SyntheticPointerEvent,
@@ -46,7 +45,7 @@ function SuspenseRects({
4645
suspenseID: SuspenseNode['id'],
4746
}): React$Node {
4847
const dispatch = useContext(TreeDispatcherContext);
49-
const store = useContext(StoreContext);
48+
const store = useSuspenseStore();
5049

5150
const {inspectedElementID} = useContext(TreeStateContext);
5251

@@ -109,9 +108,9 @@ function SuspenseRects({
109108

110109
function getDocumentBoundingRect(
111110
store: Store,
112-
shells: $ReadOnlyArray<SuspenseNode['id']>,
111+
roots: $ReadOnlyArray<SuspenseNode['id']>,
113112
): Rect {
114-
if (shells.length === 0) {
113+
if (roots.length === 0) {
115114
return {x: 0, y: 0, width: 0, height: 0};
116115
}
117116

@@ -120,14 +119,14 @@ function getDocumentBoundingRect(
120119
let maxX = Number.NEGATIVE_INFINITY;
121120
let maxY = Number.NEGATIVE_INFINITY;
122121

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) {
127126
continue;
128127
}
129128

130-
const rects = shell.rects;
129+
const rects = root.rects;
131130
if (rects === null) {
132131
continue;
133132
}
@@ -154,32 +153,32 @@ function getDocumentBoundingRect(
154153
}
155154

156155
function SuspenseRectsShell({
157-
shellID,
156+
rootID,
158157
}: {
159-
shellID: SuspenseNode['id'],
158+
rootID: SuspenseNode['id'],
160159
}): 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}`);
165164
return null;
166165
}
167166

168167
return (
169168
<g>
170-
{shell.children.map(childID => {
169+
{root.children.map(childID => {
171170
return <SuspenseRects key={childID} suspenseID={childID} />;
172171
})}
173172
</g>
174173
);
175174
}
176175

177176
function SuspenseRectsContainer(): React$Node {
178-
const store = useContext(StoreContext);
177+
const store = useSuspenseStore();
179178
// 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;
181180

182-
const boundingRect = getDocumentBoundingRect(store, shells);
181+
const boundingRect = getDocumentBoundingRect(store, roots);
183182

184183
const width = '100%';
185184
const boundingRectWidth = boundingRect.width;
@@ -193,8 +192,8 @@ function SuspenseRectsContainer(): React$Node {
193192
<svg
194193
style={{width, height}}
195194
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} />;
198197
})}
199198
</svg>
200199
</div>

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import type Store from '../../store';
1212

1313
import * as React from 'react';
1414
import {useContext, useLayoutEffect, useMemo, useRef, useState} from 'react';
15-
import {BridgeContext, StoreContext} from '../context';
15+
import {BridgeContext} from '../context';
1616
import {TreeDispatcherContext} from '../Components/TreeContext';
1717
import {useHighlightHostInstance} from '../hooks';
18-
import {SuspenseTreeStateContext} from './SuspenseTreeContext';
18+
import {useSuspenseStore} from './SuspenseTreeContext';
1919
import styles from './SuspenseTimeline.css';
2020
import typeof {
2121
SyntheticEvent,
@@ -63,14 +63,14 @@ function getSuspendableDocumentOrderSuspense(
6363

6464
function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
6565
const bridge = useContext(BridgeContext);
66-
const store = useContext(StoreContext);
66+
const store = useSuspenseStore();
6767
const dispatch = useContext(TreeDispatcherContext);
6868
const {highlightHostInstance, clearHighlightHostInstance} =
6969
useHighlightHostInstance();
7070

7171
const timeline = useMemo(() => {
7272
return getSuspendableDocumentOrderSuspense(store, rootID);
73-
}, [store, rootID]);
73+
}, [store, store.revisionSuspense, rootID]);
7474

7575
const inputRef = useRef<HTMLElement | null>(null);
7676
const inputBBox = useRef<ClientRect | null>(null);
@@ -161,6 +161,7 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
161161
function handleFocus() {
162162
const suspense = timeline[value];
163163

164+
dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspense.id});
164165
highlightHostInstance(suspense.id);
165166
}
166167

@@ -213,10 +214,10 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
213214
}
214215

215216
export default function SuspenseTimeline(): React$Node {
216-
const store = useContext(StoreContext);
217-
const {shells} = useContext(SuspenseTreeStateContext);
217+
const store = useSuspenseStore();
218218

219-
const defaultSelectedRootID = shells.find(rootID => {
219+
const roots = store.roots;
220+
const defaultSelectedRootID = roots.find(rootID => {
220221
const suspense = store.getSuspenseByID(rootID);
221222
return (
222223
store.supportsTogglingSuspense(rootID) &&
@@ -239,20 +240,18 @@ export default function SuspenseTimeline(): React$Node {
239240
return (
240241
<div className={styles.SuspenseTimelineContainer}>
241242
<SuspenseTimelineInput key={selectedRootID} rootID={selectedRootID} />
242-
{shells.length > 0 && (
243+
{roots.length > 0 && (
243244
<select
244245
aria-label="Select Suspense Root"
245246
className={styles.SuspenseTimelineRootSwitcher}
246-
onChange={handleChange}>
247-
{shells.map(rootID => {
247+
onChange={handleChange}
248+
value={selectedRootID}>
249+
{roots.map(rootID => {
248250
// TODO: Use name
249251
const name = '#' + rootID;
250252
// TODO: Highlight host on hover
251253
return (
252-
<option
253-
key={rootID}
254-
selected={rootID === selectedRootID}
255-
value={rootID}>
254+
<option key={rootID} value={rootID}>
256255
{name}
257256
</option>
258257
);

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js

Lines changed: 36 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* @flow
88
*/
99
import type {ReactContext} from 'shared/ReactTypes';
10+
import type Store from '../../store';
1011

1112
import * as React from 'react';
1213
import {
@@ -17,17 +18,12 @@ import {
1718
useMemo,
1819
useReducer,
1920
} from 'react';
20-
import type {SuspenseNode} from '../../../frontend/types';
2121
import {StoreContext} from '../context';
2222

23-
export type SuspenseTreeState = {
24-
shells: $ReadOnlyArray<SuspenseNode['id']>,
25-
};
23+
export type SuspenseTreeState = {};
2624

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'};
3127
export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void;
3228

3329
const SuspenseTreeStateContext: ReactContext<SuspenseTreeState> =
@@ -42,11 +38,39 @@ type Props = {
4238
children: React$Node,
4339
};
4440

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 {
4646
const store = useContext(StoreContext);
47-
47+
const [, storeUpdated] = useReducer<number, number, void>(
48+
(x: number) => (x + 1) % Number.MAX_SAFE_INTEGER,
49+
0,
50+
);
4851
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+
}
4972

73+
function SuspenseTreeContextController({children}: Props): React.Node {
5074
// This reducer is created inline because it needs access to the Store.
5175
// The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
5276
// so it's okay for the reducer to have an empty dependencies array.
@@ -58,18 +82,14 @@ function SuspenseTreeContextController({children}: Props): React.Node {
5882
): SuspenseTreeState => {
5983
const {type} = action;
6084
switch (type) {
61-
case 'HANDLE_SUSPENSE_TREE_MUTATION':
62-
return {...state, shells: store.roots};
6385
default:
6486
throw new Error(`Unrecognized action "${type}"`);
6587
}
6688
},
6789
[],
6890
);
6991

70-
const initialState: SuspenseTreeState = {
71-
shells: store.roots,
72-
};
92+
const initialState: SuspenseTreeState = {};
7393
const [state, dispatch] = useReducer(reducer, initialState);
7494
const transitionDispatch = useMemo(
7595
() => (action: SuspenseTreeAction) =>
@@ -79,28 +99,6 @@ function SuspenseTreeContextController({children}: Props): React.Node {
7999
[dispatch],
80100
);
81101

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-
104102
return (
105103
<SuspenseTreeStateContext.Provider value={state}>
106104
<SuspenseTreeDispatcherContext.Provider value={transitionDispatch}>
@@ -114,4 +112,5 @@ export {
114112
SuspenseTreeDispatcherContext,
115113
SuspenseTreeStateContext,
116114
SuspenseTreeContextController,
115+
useSuspenseStore,
117116
};

0 commit comments

Comments
 (0)