@samitouri / QOS-React-1 / commits / 93a3935d02

[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