@samitouri / QOS-React-2 / commits / 0a5fb67ddf

[DevTools] Sort suspense timeline by end time instead of just document order (#35011)

Right now it's possible for things like server environments to appear before other content in the timeline just because it's in a different document order. Ofc the order in production is not guaranteed but we can at least use the timing information we have as a hint towards the actual order. Unfortunately since the end time of the RSC stream itself is always after the content that resolved to produce it, it becomes kind of determined by the chunking. Similarly since for a clean refresh, the scripts and styles will typically load after the server content they appear later. Similarly SSR typically finishes after the RSC parts. Therefore a hack here is that I artificially delay everything with a non-null environment (RSC) so that RSC always comes after client-side (Suspense). This is also consistent with how we color things that have an environment even if children are just Suspense. To ensure that we never show a child before a parent, in the timeline, each child has a minimum time of its parent.

Sebastian Markbåge committed Oct 29, 2025 at 15:05 UTC 0a5fb67ddfbd50740e2cbd6f1e575e834f696444
7 files changed +91 -12
packages/react-devtools-shared/src/backend/fiber/renderer.js
+43 -3
@@ -301,6 +301,7 @@ type SuspenseNode = {
301 rects: null | Array<Rect>, // The bounding rects of content children.
302 suspendedBy: Map<ReactIOInfo, Set<DevToolsInstance>>, // Tracks which data we're suspended by and the children that suspend it.
303 environments: Map<string, number>, // Tracks the Flight environment names that suspended this. I.e. if the server blocked this.
304 + endTime: number, // Track a short cut to the maximum end time value within the suspendedBy set.
305 // Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all
306 // also in the parent sets. This determine whether this could contribute in the loading sequence.
307 hasUniqueSuspenders: boolean,
@@ -330,6 +331,7 @@ function createSuspenseNode(
331 rects: null,
332 suspendedBy: new Map(),
333 environments: new Map(),
334 + endTime: 0,
335 hasUniqueSuspenders: false,
336 hasUnknownSuspenders: false,
337 });
@@ -2156,8 +2158,8 @@ export function attach(
2158 // Regular operations
2159 pendingOperations.length +
2160 // All suspender changes are batched in a single message.
2159 - // [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders, isSuspended]]
2160 - (numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 3 : 0),
2161 + // [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders, endTime, isSuspended]]
2162 + (numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 4 : 0),
2163 );
2164
2165 // Identify which renderer this update is coming from.
@@ -2242,6 +2244,7 @@ export function attach(
2244 }
2245 operations[i++] = fiberIdWithChanges;
2246 operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
2247 + operations[i++] = Math.round(suspense.endTime * 1000);
2248 const instance = suspense.instance;
2249 const isSuspended =
2250 // TODO: Track if other SuspenseNode like SuspenseList rows are suspended.
@@ -2912,12 +2915,19 @@ export function attach(
2915 // like owner instances to link down into the tree.
2916 if (!suspendedBySet.has(parentInstance)) {
2917 suspendedBySet.add(parentInstance);
2918 + const virtualEndTime = getVirtualEndTime(ioInfo);
2919 if (
2920 !parentSuspenseNode.hasUniqueSuspenders &&
2921 !ioExistsInSuspenseAncestor(parentSuspenseNode, ioInfo)
2922 ) {
2923 // This didn't exist in the parent before, so let's mark this boundary as having a unique suspender.
2924 parentSuspenseNode.hasUniqueSuspenders = true;
2925 + if (parentSuspenseNode.endTime < virtualEndTime) {
2926 + parentSuspenseNode.endTime = virtualEndTime;
2927 + }
2928 + recordSuspenseSuspenders(parentSuspenseNode);
2929 + } else if (parentSuspenseNode.endTime < virtualEndTime) {
2930 + parentSuspenseNode.endTime = virtualEndTime;
2931 recordSuspenseSuspenders(parentSuspenseNode);
2932 }
2933 }
@@ -2979,6 +2989,26 @@ export function attach(
2989 }
2990 }
2991
2992 + function getVirtualEndTime(ioInfo: ReactIOInfo): number {
2993 + if (ioInfo.env != null) {
2994 + // Sort client side content first so that scripts and streams don't
2995 + // cover up the effect of server time.
2996 + return ioInfo.end + 1000000;
2997 + }
2998 + return ioInfo.end;
2999 + }
3000 +
3001 + function computeEndTime(suspenseNode: SuspenseNode) {
3002 + let maxEndTime = 0;
3003 + suspenseNode.suspendedBy.forEach((set, ioInfo) => {
3004 + const virtualEndTime = getVirtualEndTime(ioInfo);
3005 + if (virtualEndTime > maxEndTime) {
3006 + maxEndTime = virtualEndTime;
3007 + }
3008 + });
3009 + return maxEndTime;
3010 + }
3011 +
3012 function removePreviousSuspendedBy(
3013 instance: DevToolsInstance,
3014 previousSuspendedBy: null | Array<ReactAsyncInfo>,
@@ -2996,6 +3026,7 @@ export function attach(
3026 if (previousSuspendedBy !== null && suspenseNode !== null) {
3027 const nextSuspendedBy = instance.suspendedBy;
3028 let changedEnvironment = false;
3029 + let mayHaveChangedEndTime = false;
3030 for (let i = 0; i < previousSuspendedBy.length; i++) {
3031 const asyncInfo = previousSuspendedBy[i];
3032 if (
@@ -3009,6 +3040,11 @@ export function attach(
3040 const ioInfo = asyncInfo.awaited;
3041 const suspendedBySet = suspenseNode.suspendedBy.get(ioInfo);
3042
3043 + if (suspenseNode.endTime === getVirtualEndTime(ioInfo)) {
3044 + // This may be the only remaining entry at this end time. Recompute the end time.
3045 + mayHaveChangedEndTime = true;
3046 + }
3047 +
3048 if (
3049 suspendedBySet === undefined ||
3050 !suspendedBySet.delete(instance)
@@ -3066,7 +3102,11 @@ export function attach(
3102 }
3103 }
3104 }
3069 - if (changedEnvironment) {
3105 + const newEndTime = mayHaveChangedEndTime
3106 + ? computeEndTime(suspenseNode)
3107 + : suspenseNode.endTime;
3108 + if (changedEnvironment || newEndTime !== suspenseNode.endTime) {
3109 + suspenseNode.endTime = newEndTime;
3110 recordSuspenseSuspenders(suspenseNode);
3111 }
3112 }
packages/react-devtools-shared/src/devtools/store.js
+39 -4
@@ -925,7 +925,7 @@ export default class Store extends EventEmitter<{
925 */
926 getSuspendableDocumentOrderSuspense(
927 uniqueSuspendersOnly: boolean,
928 - ): $ReadOnlyArray<SuspenseTimelineStep> {
928 + ): Array<SuspenseTimelineStep> {
929 const target: Array<SuspenseTimelineStep> = [];
930 const roots = this.roots;
931 let rootStep: null | SuspenseTimelineStep = null;
@@ -949,17 +949,25 @@ export default class Store extends EventEmitter<{
949 rootStep = {
950 id: suspense.id,
951 environment: environmentName,
952 + endTime: suspense.endTime,
953 };
954 target.push(rootStep);
954 - } else if (rootStep.environment === null) {
955 - // If any root has an environment name, then let's use it.
956 - rootStep.environment = environmentName;
955 + } else {
956 + if (rootStep.environment === null) {
957 + // If any root has an environment name, then let's use it.
958 + rootStep.environment = environmentName;
959 + }
960 + if (suspense.endTime > rootStep.endTime) {
961 + // If any root has a higher end time, let's use that.
962 + rootStep.endTime = suspense.endTime;
963 + }
964 }
965 this.pushTimelineStepsInDocumentOrder(
966 suspense.children,
967 target,
968 uniqueSuspendersOnly,
969 environments,
970 + 0, // Don't pass a minimum end time at the root. The root is always first so doesn't matter.
971 );
972 }
973 }
@@ -972,6 +980,7 @@ export default class Store extends EventEmitter<{
980 target: Array<SuspenseTimelineStep>,
981 uniqueSuspendersOnly: boolean,
982 parentEnvironments: Array<string>,
983 + parentEndTime: number,
984 ): void {
985 for (let i = 0; i < children.length; i++) {
986 const child = this.getSuspenseByID(children[i]);
@@ -996,10 +1005,15 @@ export default class Store extends EventEmitter<{
1005 unionEnvironments.length > 0
1006 ? unionEnvironments[unionEnvironments.length - 1]
1007 : null;
1008 + // The end time of a child boundary can in effect never be earlier than its parent even if
1009 + // everything unsuspended before that.
1010 + const maxEndTime =
1011 + parentEndTime > child.endTime ? parentEndTime : child.endTime;
1012 if (hasRects && (!uniqueSuspendersOnly || child.hasUniqueSuspenders)) {
1013 target.push({
1014 id: child.id,
1015 environment: environmentName,
1016 + endTime: maxEndTime,
1017 });
1018 }
1019 this.pushTimelineStepsInDocumentOrder(
@@ -1007,10 +1021,28 @@ export default class Store extends EventEmitter<{
1021 target,
1022 uniqueSuspendersOnly,
1023 unionEnvironments,
1024 + maxEndTime,
1025 );
1026 }
1027 }
1028
1029 + getEndTimeOrDocumentOrderSuspense(
1030 + uniqueSuspendersOnly: boolean,
1031 + ): $ReadOnlyArray<SuspenseTimelineStep> {
1032 + const timeline =
1033 + this.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
1034 + if (timeline.length === 0) {
1035 + return timeline;
1036 + }
1037 + const root = timeline[0];
1038 + // We mutate in place since we assume we've got a fresh array.
1039 + timeline.sort((a, b) => {
1040 + // Root is always first
1041 + return a === root ? -1 : b === root ? 1 : a.endTime - b.endTime;
1042 + });
1043 + return timeline;
1044 + }
1045 +
1046 getRendererIDForElement(id: number): number | null {
1047 let current = this._idToElement.get(id);
1048 while (current !== undefined) {
@@ -1688,6 +1720,7 @@ export default class Store extends EventEmitter<{
1720 hasUniqueSuspenders: false,
1721 isSuspended: isSuspended,
1722 environments: [],
1723 + endTime: 0,
1724 });
1725
1726 hasSuspenseTreeChanged = true;
@@ -1884,6 +1917,7 @@ export default class Store extends EventEmitter<{
1917 for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
1918 const id = operations[i++];
1919 const hasUniqueSuspenders = operations[i++] === 1;
1920 + const endTime = operations[i++] / 1000;
1921 const isSuspended = operations[i++] === 1;
1922 const environmentNamesLength = operations[i++];
1923 const environmentNames = [];
@@ -1919,6 +1953,7 @@ export default class Store extends EventEmitter<{
1953 }
1954
1955 suspense.hasUniqueSuspenders = hasUniqueSuspenders;
1956 + suspense.endTime = endTime;
1957 suspense.isSuspended = isSuspended;
1958 suspense.environments = environmentNames;
1959 }
packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js
+2 -1
@@ -460,13 +460,14 @@ function updateTree(
460 for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
461 const suspenseNodeId = operations[i++];
462 const hasUniqueSuspenders = operations[i++] === 1;
463 + const endTime = operations[i++] / 1000;
464 const isSuspended = operations[i++] === 1;
465 const environmentNamesLength = operations[i++];
466 i += environmentNamesLength;
467 if (__DEBUG__) {
468 debug(
469 'Suspender changes',
469 - `Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
470 + `Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} ending at ${String(endTime)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
471 );
472 }
473 }
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+1 -1
@@ -74,7 +74,7 @@ function ToggleUniqueSuspenders() {
74 function handleToggleUniqueSuspenders() {
75 const nextUniqueSuspendersOnly = !uniqueSuspendersOnly;
76 // TODO: Handle different timeline modes (e.g. random order)
77 - const nextTimeline = store.getSuspendableDocumentOrderSuspense(
77 + const nextTimeline = store.getEndTimeOrDocumentOrderSuspense(
78 nextUniqueSuspendersOnly,
79 );
80 suspenseTreeDispatch({
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+2 -2
@@ -111,7 +111,7 @@ type Props = {
111 function getInitialState(store: Store): SuspenseTreeState {
112 const uniqueSuspendersOnly = true;
113 const timeline =
114 - store.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
114 + store.getEndTimeOrDocumentOrderSuspense(uniqueSuspendersOnly);
115 const timelineIndex = timeline.length - 1;
116 const selectedSuspenseID =
117 timelineIndex === -1 ? null : timeline[timelineIndex].id;
@@ -182,7 +182,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
182 }
183
184 // TODO: Handle different timeline modes (e.g. random order)
185 - const nextTimeline = store.getSuspendableDocumentOrderSuspense(
185 + const nextTimeline = store.getEndTimeOrDocumentOrderSuspense(
186 state.uniqueSuspendersOnly,
187 );
188
packages/react-devtools-shared/src/frontend/types.js
+2
@@ -196,6 +196,7 @@ export type Rect = {
196 export type SuspenseTimelineStep = {
197 id: SuspenseNode['id'], // TODO: Will become a group.
198 environment: null | string,
199 + endTime: number,
200 };
201
202 export type SuspenseNode = {
@@ -207,6 +208,7 @@ export type SuspenseNode = {
208 hasUniqueSuspenders: boolean,
209 isSuspended: boolean,
210 environments: Array<string>,
211 + endTime: number,
212 };
213
214 // Serialized version of ReactIOInfo
packages/react-devtools-shared/src/utils.js
+2 -1
@@ -432,11 +432,12 @@ export function printOperationsArray(operations: Array<number>) {
432 for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
433 const id = operations[i++];
434 const hasUniqueSuspenders = operations[i++] === 1;
435 + const endTime = operations[i++] / 1000;
436 const isSuspended = operations[i++] === 1;
437 const environmentNamesLength = operations[i++];
438 i += environmentNamesLength;
439 logs.push(
439 - `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
440 + `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} ending at ${String(endTime)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
441 );
442 }
443