Skip to content

Commit 0a5fb67

Browse files
authored
[DevTools] Sort suspense timeline by end time instead of just document order (facebook#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.
1 parent 4f93170 commit 0a5fb67

File tree

7 files changed

+91
-12
lines changed

7 files changed

+91
-12
lines changed

packages/react-devtools-shared/src/backend/fiber/renderer.js

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ type SuspenseNode = {
301301
rects: null | Array<Rect>, // The bounding rects of content children.
302302
suspendedBy: Map<ReactIOInfo, Set<DevToolsInstance>>, // Tracks which data we're suspended by and the children that suspend it.
303303
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.
304305
// Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all
305306
// also in the parent sets. This determine whether this could contribute in the loading sequence.
306307
hasUniqueSuspenders: boolean,
@@ -330,6 +331,7 @@ function createSuspenseNode(
330331
rects: null,
331332
suspendedBy: new Map(),
332333
environments: new Map(),
334+
endTime: 0,
333335
hasUniqueSuspenders: false,
334336
hasUnknownSuspenders: false,
335337
});
@@ -2156,8 +2158,8 @@ export function attach(
21562158
// Regular operations
21572159
pendingOperations.length +
21582160
// 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),
21612163
);
21622164
21632165
// Identify which renderer this update is coming from.
@@ -2242,6 +2244,7 @@ export function attach(
22422244
}
22432245
operations[i++] = fiberIdWithChanges;
22442246
operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
2247+
operations[i++] = Math.round(suspense.endTime * 1000);
22452248
const instance = suspense.instance;
22462249
const isSuspended =
22472250
// TODO: Track if other SuspenseNode like SuspenseList rows are suspended.
@@ -2912,12 +2915,19 @@ export function attach(
29122915
// like owner instances to link down into the tree.
29132916
if (!suspendedBySet.has(parentInstance)) {
29142917
suspendedBySet.add(parentInstance);
2918+
const virtualEndTime = getVirtualEndTime(ioInfo);
29152919
if (
29162920
!parentSuspenseNode.hasUniqueSuspenders &&
29172921
!ioExistsInSuspenseAncestor(parentSuspenseNode, ioInfo)
29182922
) {
29192923
// This didn't exist in the parent before, so let's mark this boundary as having a unique suspender.
29202924
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;
29212931
recordSuspenseSuspenders(parentSuspenseNode);
29222932
}
29232933
}
@@ -2979,6 +2989,26 @@ export function attach(
29792989
}
29802990
}
29812991
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+
29823012
function removePreviousSuspendedBy(
29833013
instance: DevToolsInstance,
29843014
previousSuspendedBy: null | Array<ReactAsyncInfo>,
@@ -2996,6 +3026,7 @@ export function attach(
29963026
if (previousSuspendedBy !== null && suspenseNode !== null) {
29973027
const nextSuspendedBy = instance.suspendedBy;
29983028
let changedEnvironment = false;
3029+
let mayHaveChangedEndTime = false;
29993030
for (let i = 0; i < previousSuspendedBy.length; i++) {
30003031
const asyncInfo = previousSuspendedBy[i];
30013032
if (
@@ -3009,6 +3040,11 @@ export function attach(
30093040
const ioInfo = asyncInfo.awaited;
30103041
const suspendedBySet = suspenseNode.suspendedBy.get(ioInfo);
30113042
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+
30123048
if (
30133049
suspendedBySet === undefined ||
30143050
!suspendedBySet.delete(instance)
@@ -3066,7 +3102,11 @@ export function attach(
30663102
}
30673103
}
30683104
}
3069-
if (changedEnvironment) {
3105+
const newEndTime = mayHaveChangedEndTime
3106+
? computeEndTime(suspenseNode)
3107+
: suspenseNode.endTime;
3108+
if (changedEnvironment || newEndTime !== suspenseNode.endTime) {
3109+
suspenseNode.endTime = newEndTime;
30703110
recordSuspenseSuspenders(suspenseNode);
30713111
}
30723112
}

packages/react-devtools-shared/src/devtools/store.js

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -925,7 +925,7 @@ export default class Store extends EventEmitter<{
925925
*/
926926
getSuspendableDocumentOrderSuspense(
927927
uniqueSuspendersOnly: boolean,
928-
): $ReadOnlyArray<SuspenseTimelineStep> {
928+
): Array<SuspenseTimelineStep> {
929929
const target: Array<SuspenseTimelineStep> = [];
930930
const roots = this.roots;
931931
let rootStep: null | SuspenseTimelineStep = null;
@@ -949,17 +949,25 @@ export default class Store extends EventEmitter<{
949949
rootStep = {
950950
id: suspense.id,
951951
environment: environmentName,
952+
endTime: suspense.endTime,
952953
};
953954
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+
}
957964
}
958965
this.pushTimelineStepsInDocumentOrder(
959966
suspense.children,
960967
target,
961968
uniqueSuspendersOnly,
962969
environments,
970+
0, // Don't pass a minimum end time at the root. The root is always first so doesn't matter.
963971
);
964972
}
965973
}
@@ -972,6 +980,7 @@ export default class Store extends EventEmitter<{
972980
target: Array<SuspenseTimelineStep>,
973981
uniqueSuspendersOnly: boolean,
974982
parentEnvironments: Array<string>,
983+
parentEndTime: number,
975984
): void {
976985
for (let i = 0; i < children.length; i++) {
977986
const child = this.getSuspenseByID(children[i]);
@@ -996,21 +1005,44 @@ export default class Store extends EventEmitter<{
9961005
unionEnvironments.length > 0
9971006
? unionEnvironments[unionEnvironments.length - 1]
9981007
: 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;
9991012
if (hasRects && (!uniqueSuspendersOnly || child.hasUniqueSuspenders)) {
10001013
target.push({
10011014
id: child.id,
10021015
environment: environmentName,
1016+
endTime: maxEndTime,
10031017
});
10041018
}
10051019
this.pushTimelineStepsInDocumentOrder(
10061020
child.children,
10071021
target,
10081022
uniqueSuspendersOnly,
10091023
unionEnvironments,
1024+
maxEndTime,
10101025
);
10111026
}
10121027
}
10131028

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+
10141046
getRendererIDForElement(id: number): number | null {
10151047
let current = this._idToElement.get(id);
10161048
while (current !== undefined) {
@@ -1688,6 +1720,7 @@ export default class Store extends EventEmitter<{
16881720
hasUniqueSuspenders: false,
16891721
isSuspended: isSuspended,
16901722
environments: [],
1723+
endTime: 0,
16911724
});
16921725

16931726
hasSuspenseTreeChanged = true;
@@ -1884,6 +1917,7 @@ export default class Store extends EventEmitter<{
18841917
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
18851918
const id = operations[i++];
18861919
const hasUniqueSuspenders = operations[i++] === 1;
1920+
const endTime = operations[i++] / 1000;
18871921
const isSuspended = operations[i++] === 1;
18881922
const environmentNamesLength = operations[i++];
18891923
const environmentNames = [];
@@ -1919,6 +1953,7 @@ export default class Store extends EventEmitter<{
19191953
}
19201954

19211955
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
1956+
suspense.endTime = endTime;
19221957
suspense.isSuspended = isSuspended;
19231958
suspense.environments = environmentNames;
19241959
}

packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,13 +460,14 @@ function updateTree(
460460
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
461461
const suspenseNodeId = operations[i++];
462462
const hasUniqueSuspenders = operations[i++] === 1;
463+
const endTime = operations[i++] / 1000;
463464
const isSuspended = operations[i++] === 1;
464465
const environmentNamesLength = operations[i++];
465466
i += environmentNamesLength;
466467
if (__DEBUG__) {
467468
debug(
468469
'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`,
470471
);
471472
}
472473
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ function ToggleUniqueSuspenders() {
7474
function handleToggleUniqueSuspenders() {
7575
const nextUniqueSuspendersOnly = !uniqueSuspendersOnly;
7676
// TODO: Handle different timeline modes (e.g. random order)
77-
const nextTimeline = store.getSuspendableDocumentOrderSuspense(
77+
const nextTimeline = store.getEndTimeOrDocumentOrderSuspense(
7878
nextUniqueSuspendersOnly,
7979
);
8080
suspenseTreeDispatch({

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ type Props = {
111111
function getInitialState(store: Store): SuspenseTreeState {
112112
const uniqueSuspendersOnly = true;
113113
const timeline =
114-
store.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
114+
store.getEndTimeOrDocumentOrderSuspense(uniqueSuspendersOnly);
115115
const timelineIndex = timeline.length - 1;
116116
const selectedSuspenseID =
117117
timelineIndex === -1 ? null : timeline[timelineIndex].id;
@@ -182,7 +182,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
182182
}
183183

184184
// TODO: Handle different timeline modes (e.g. random order)
185-
const nextTimeline = store.getSuspendableDocumentOrderSuspense(
185+
const nextTimeline = store.getEndTimeOrDocumentOrderSuspense(
186186
state.uniqueSuspendersOnly,
187187
);
188188

packages/react-devtools-shared/src/frontend/types.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export type Rect = {
196196
export type SuspenseTimelineStep = {
197197
id: SuspenseNode['id'], // TODO: Will become a group.
198198
environment: null | string,
199+
endTime: number,
199200
};
200201

201202
export type SuspenseNode = {
@@ -207,6 +208,7 @@ export type SuspenseNode = {
207208
hasUniqueSuspenders: boolean,
208209
isSuspended: boolean,
209210
environments: Array<string>,
211+
endTime: number,
210212
};
211213

212214
// Serialized version of ReactIOInfo

packages/react-devtools-shared/src/utils.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,11 +432,12 @@ export function printOperationsArray(operations: Array<number>) {
432432
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
433433
const id = operations[i++];
434434
const hasUniqueSuspenders = operations[i++] === 1;
435+
const endTime = operations[i++] / 1000;
435436
const isSuspended = operations[i++] === 1;
436437
const environmentNamesLength = operations[i++];
437438
i += environmentNamesLength;
438439
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`,
440441
);
441442
}
442443

0 commit comments

Comments
 (0)