[DevTools] Only schedule a single update per Supense when changing timeline (#35927)
Sebastian "Sebbie" Silbermann committed
Mar 3, 2026 at 12:21 UTC
93a3935d0292d66e0bf426a7e28bb1433bbeea30
6 files changed
+86
-20
packages/react-devtools-shared/src/__tests__/store-test.js
+2
@@ -981,6 +981,7 @@ describe('Store', () => {
981
982
await actAsync(() => {
983
agent.overrideSuspenseMilestone({
984
+ rendererID: getRendererID(),
985
suspendedSet: [
986
store.getElementIDAtIndex(4),
987
store.getElementIDAtIndex(8),
@@ -1010,6 +1011,7 @@ describe('Store', () => {
1011
1012
await actAsync(() => {
1013
agent.overrideSuspenseMilestone({
1014
+ rendererID: getRendererID(),
1015
suspendedSet: [],
1016
});
1017
});
packages/react-devtools-shared/src/backend/agent.js
+7
-7
@@ -147,6 +147,7 @@ type OverrideSuspenseParams = {
147
};
148
149
type OverrideSuspenseMilestoneParams = {
150
+ rendererID: number,
151
suspendedSet: Array<number>,
152
};
153
@@ -787,15 +788,14 @@ export default class Agent extends EventEmitter<{
788
};
789
790
overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({
791
+ rendererID,
792
suspendedSet,
793
}) => {
792
- for (const rendererID in this._rendererInterfaces) {
793
- const renderer = ((this._rendererInterfaces[
794
- (rendererID: any)
795
- ]: any): RendererInterface);
796
- if (renderer.supportsTogglingSuspense) {
797
- renderer.overrideSuspenseMilestone(suspendedSet);
798
- }
794
+ const renderer = ((this._rendererInterfaces[
795
+ (rendererID: any)
796
+ ]: any): RendererInterface);
797
+ if (renderer.supportsTogglingSuspense) {
798
+ renderer.overrideSuspenseMilestone(suspendedSet);
799
}
800
};
801
packages/react-devtools-shared/src/bridge.js
+1
@@ -145,6 +145,7 @@ type OverrideSuspense = {
145
};
146
147
type OverrideSuspenseMilestone = {
148
+ rendererID: number,
149
suspendedSet: Array<number>,
150
};
151
packages/react-devtools-shared/src/devtools/store.js
+40
-8
@@ -957,6 +957,12 @@ export default class Store extends EventEmitter<{
957
if (root === null) {
958
continue;
959
}
960
+ const rendererID = this._rootIDToRendererID.get(rootID);
961
+ if (rendererID === undefined) {
962
+ throw new Error(
963
+ 'Failed to find renderer ID for root. This is a bug in React DevTools.',
964
+ );
965
+ }
966
// TODO: This includes boundaries that can't be suspended due to no support from the renderer.
967
968
const suspense = this.getSuspenseByID(rootID);
@@ -972,6 +978,7 @@ export default class Store extends EventEmitter<{
978
id: suspense.id,
979
environment: environmentName,
980
endTime: suspense.endTime,
981
+ rendererID,
982
};
983
target.push(rootStep);
984
} else {
@@ -990,6 +997,7 @@ export default class Store extends EventEmitter<{
997
uniqueSuspendersOnly,
998
environments,
999
0, // Don't pass a minimum end time at the root. The root is always first so doesn't matter.
1000
+ rendererID,
1001
);
1002
}
1003
}
@@ -1039,6 +1047,7 @@ export default class Store extends EventEmitter<{
1047
*/
1048
getSuspendableDocumentOrderSuspenseTransition(
1049
uniqueSuspendersOnly: boolean,
1050
+ rendererID: number,
1051
): Array<SuspenseTimelineStep> {
1052
const target: Array<SuspenseTimelineStep> = [];
1053
const focusedTransitionID = this._focusedTransition;
@@ -1051,6 +1060,7 @@ export default class Store extends EventEmitter<{
1060
// TODO: Get environment for Activity
1061
environment: null,
1062
endTime: 0,
1063
+ rendererID,
1064
});
1065
1066
const transitionChildren = this.getSuspenseChildren(focusedTransitionID);
@@ -1062,6 +1072,7 @@ export default class Store extends EventEmitter<{
1072
// TODO: Get environment for Activity
1073
[],
1074
0, // Don't pass a minimum end time at the root. The root is always first so doesn't matter.
1075
+ rendererID,
1076
);
1077
1078
return target;
@@ -1073,6 +1084,7 @@ export default class Store extends EventEmitter<{
1084
uniqueSuspendersOnly: boolean,
1085
parentEnvironments: Array<string>,
1086
parentEndTime: number,
1087
+ rendererID: number,
1088
): void {
1089
for (let i = 0; i < children.length; i++) {
1090
const child = this.getSuspenseByID(children[i]);
@@ -1106,6 +1118,7 @@ export default class Store extends EventEmitter<{
1118
id: child.id,
1119
environment: environmentName,
1120
endTime: maxEndTime,
1121
+ rendererID,
1122
});
1123
}
1124
this.pushTimelineStepsInDocumentOrder(
@@ -1114,6 +1127,7 @@ export default class Store extends EventEmitter<{
1127
uniqueSuspendersOnly,
1128
unionEnvironments,
1129
maxEndTime,
1130
+ rendererID,
1131
);
1132
}
1133
}
@@ -1121,14 +1135,32 @@ export default class Store extends EventEmitter<{
1135
getEndTimeOrDocumentOrderSuspense(
1136
uniqueSuspendersOnly: boolean,
1137
): $ReadOnlyArray<SuspenseTimelineStep> {
1124
- const timeline =
1125
- this._focusedTransition === 0
1126
- ? this.getSuspendableDocumentOrderSuspenseInitialPaint(
1127
- uniqueSuspendersOnly,
1128
- )
1129
- : this.getSuspendableDocumentOrderSuspenseTransition(
1130
- uniqueSuspendersOnly,
1131
- );
1138
+ let timeline: SuspenseTimelineStep[];
1139
+ if (this._focusedTransition === 0) {
1140
+ timeline =
1141
+ this.getSuspendableDocumentOrderSuspenseInitialPaint(
1142
+ uniqueSuspendersOnly,
1143
+ );
1144
+ } else {
1145
+ const focusedTransitionRootID = this.getRootIDForElement(
1146
+ this._focusedTransition,
1147
+ );
1148
+ if (focusedTransitionRootID === null) {
1149
+ throw new Error(
1150
+ 'Failed to find root ID for focused transition. This is a bug in React DevTools.',
1151
+ );
1152
+ }
1153
+ const rendererID = this._rootIDToRendererID.get(focusedTransitionRootID);
1154
+ if (rendererID === undefined) {
1155
+ throw new Error(
1156
+ 'Failed to find renderer ID for focused transition root. This is a bug in React DevTools.',
1157
+ );
1158
+ }
1159
+ timeline = this.getSuspendableDocumentOrderSuspenseTransition(
1160
+ uniqueSuspendersOnly,
1161
+ rendererID,
1162
+ );
1163
+ }
1164
1165
if (timeline.length === 0) {
1166
return timeline;
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+35
-5
@@ -9,7 +9,7 @@
9
10
import * as React from 'react';
11
import {useContext, useEffect} from 'react';
12
-import {BridgeContext} from '../context';
12
+import {BridgeContext, StoreContext} from '../context';
13
import {TreeDispatcherContext} from '../Components/TreeContext';
14
import {useScrollToHostInstance} from '../hooks';
15
import {
@@ -20,9 +20,11 @@ import styles from './SuspenseTimeline.css';
20
import SuspenseScrubber from './SuspenseScrubber';
21
import Button from '../Button';
22
import ButtonIcon from '../ButtonIcon';
23
+import type {SuspenseNode} from '../../../frontend/types';
24
25
function SuspenseTimelineInput() {
26
const bridge = useContext(BridgeContext);
27
+ const store = useContext(StoreContext);
28
const treeDispatch = useContext(TreeDispatcherContext);
29
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
30
const scrollToHostInstance = useScrollToHostInstance();
@@ -101,15 +103,43 @@ function SuspenseTimelineInput() {
103
// TODO: useEffectEvent here once it's supported in all versions DevTools supports.
104
// For now we just exclude it from deps since we don't lint those anyway.
105
function changeTimelineIndex(newIndex: number) {
106
+ const suspendedSetByRendererID = new Map<
107
+ number,
108
+ Array<SuspenseNode['id']>,
109
+ >();
110
+ // Unsuspend everything by default.
111
+ // We might not encounter every renderer after the milestone e.g.
112
+ // if we clicked at the end of the timeline.
113
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
114
+ for (const rendererID of store.rootIDToRendererID.values()) {
115
+ suspendedSetByRendererID.set(rendererID, []);
116
+ }
117
+
118
// Synchronize timeline index with what is resuspended.
119
// We suspend everything after the current selection. The root isn't showing
120
// anything suspended in the root. The step after that should have one less
121
// thing suspended. I.e. the first suspense boundary should be unsuspended
122
// when it's selected. This also lets you show everything in the last step.
109
- const suspendedSet = timeline.slice(timelineIndex + 1).map(step => step.id);
110
- bridge.send('overrideSuspenseMilestone', {
111
- suspendedSet,
112
- });
123
+ for (let i = timelineIndex + 1; i < timeline.length; i++) {
124
+ const step = timeline[i];
125
+ const {rendererID} = step;
126
+ const suspendedSetForRendererID =
127
+ suspendedSetByRendererID.get(rendererID);
128
+ if (suspendedSetForRendererID === undefined) {
129
+ throw new Error(
130
+ `Should have initialized suspended set for renderer ID "${rendererID}" earlier. This is a bug in React DevTools.`,
131
+ );
132
+ }
133
+ suspendedSetForRendererID.push(step.id);
134
+ }
135
+
136
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
137
+ for (const [rendererID, suspendedSet] of suspendedSetByRendererID) {
138
+ bridge.send('overrideSuspenseMilestone', {
139
+ rendererID,
140
+ suspendedSet,
141
+ });
142
+ }
143
}
144
145
useEffect(() => {
packages/react-devtools-shared/src/frontend/types.js
+1
@@ -210,6 +210,7 @@ export type SuspenseTimelineStep = {
210
*/
211
id: SuspenseNode['id'] | Element['id'], // TODO: Will become a group.
212
environment: null | string,
213
+ rendererID: number,
214
endTime: number,
215
};
216