@samitouri / QOS-React / commits / b1c519f3d4

[DevTools] Only show boundaries with unique suspenders by default in the timeline (#34397)

Sebastian "Sebbie" Silbermann committed Sep 11, 2025 at 11:33 UTC b1c519f3d40932c126523dcad745ed9e06ddd636
10 files changed +187 -22
packages/react-devtools-shared/src/backend/fiber/renderer.js
+50 -2
@@ -88,6 +88,7 @@ import {
88 SUSPENSE_TREE_OPERATION_REMOVE,
89 SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
90 SUSPENSE_TREE_OPERATION_RESIZE,
91 + SUSPENSE_TREE_OPERATION_SUSPENDERS,
92 UNKNOWN_SUSPENDERS_NONE,
93 UNKNOWN_SUSPENDERS_REASON_PRODUCTION,
94 UNKNOWN_SUSPENDERS_REASON_OLD_VERSION,
@@ -2016,6 +2017,7 @@ export function attach(
2017 const pendingOperations: OperationsArray = [];
2018 const pendingRealUnmountedIDs: Array<FiberInstance['id']> = [];
2019 const pendingRealUnmountedSuspenseIDs: Array<FiberInstance['id']> = [];
2020 + const pendingSuspenderChanges: Set<FiberInstance['id']> = new Set();
2021 let pendingOperationsQueue: Array<OperationsArray> | null = [];
2022 const pendingStringTable: Map<string, StringTableEntry> = new Map();
2023 let pendingStringTableLength: number = 0;
@@ -2047,6 +2049,7 @@ export function attach(
2049 pendingOperations.length === 0 &&
2050 pendingRealUnmountedIDs.length === 0 &&
2051 pendingRealUnmountedSuspenseIDs.length === 0 &&
2052 + pendingSuspenderChanges.size === 0 &&
2053 pendingUnmountedRootID === null
2054 );
2055 }
@@ -2113,6 +2116,7 @@ export function attach(
2116 pendingRealUnmountedIDs.length +
2117 (pendingUnmountedRootID === null ? 0 : 1);
2118 const numUnmountSuspenseIDs = pendingRealUnmountedSuspenseIDs.length;
2119 + const numSuspenderChanges = pendingSuspenderChanges.size;
2120
2121 const operations = new Array<number>(
2122 // Identify which renderer this update is coming from.
@@ -2128,7 +2132,10 @@ export function attach(
2132 // [TREE_OPERATION_REMOVE, removedIDLength, ...ids]
2133 (numUnmountIDs > 0 ? 2 + numUnmountIDs : 0) +
2134 // Regular operations
2131 - pendingOperations.length,
2135 + pendingOperations.length +
2136 + // All suspender changes are batched in a single message.
2137 + // [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders]]
2138 + (numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 2 : 0),
2139 );
2140
2141 // Identify which renderer this update is coming from.
@@ -2191,12 +2198,31 @@ export function attach(
2198 i++;
2199 }
2200 }
2194 - // Fill in the rest of the operations.
2201 +
2202 + // Fill in pending operations.
2203 for (let j = 0; j < pendingOperations.length; j++) {
2204 operations[i + j] = pendingOperations[j];
2205 }
2206 i += pendingOperations.length;
2207
2208 + // Suspender changes might affect newly mounted nodes that we already recorded
2209 + // in pending operations.
2210 + if (numSuspenderChanges > 0) {
2211 + operations[i++] = SUSPENSE_TREE_OPERATION_SUSPENDERS;
2212 + operations[i++] = numSuspenderChanges;
2213 + pendingSuspenderChanges.forEach(fiberIdWithChanges => {
2214 + const suspense = idToSuspenseNodeMap.get(fiberIdWithChanges);
2215 + if (suspense === undefined) {
2216 + // Probably forgot to cleanup pendingSuspenderChanges when this node was removed.
2217 + throw new Error(
2218 + `Could not send suspender changes for "${fiberIdWithChanges}" since the Fiber no longer exists.`,
2219 + );
2220 + }
2221 + operations[i++] = fiberIdWithChanges;
2222 + operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
2223 + });
2224 + }
2225 +
2226 // Let the frontend know about tree operations.
2227 flushOrQueueOperations(operations);
2228
@@ -2204,6 +2230,7 @@ export function attach(
2230 pendingOperations.length = 0;
2231 pendingRealUnmountedIDs.length = 0;
2232 pendingRealUnmountedSuspenseIDs.length = 0;
2233 + pendingSuspenderChanges.clear();
2234 pendingUnmountedRootID = null;
2235 pendingStringTable.clear();
2236 pendingStringTableLength = 0;
@@ -2688,6 +2715,19 @@ export function attach(
2715 }
2716 }
2717
2718 + function recordSuspenseSuspenders(suspenseNode: SuspenseNode): void {
2719 + if (__DEBUG__) {
2720 + console.log('recordSuspenseSuspenders()', suspenseNode);
2721 + }
2722 + const fiberInstance = suspenseNode.instance;
2723 + if (fiberInstance.kind !== FIBER_INSTANCE) {
2724 + // TODO: Suspender updates of filtered Suspense nodes are currently dropped.
2725 + return;
2726 + }
2727 +
2728 + pendingSuspenderChanges.add(fiberInstance.id);
2729 + }
2730 +
2731 function recordSuspenseUnmount(suspenseInstance: SuspenseNode): void {
2732 if (__DEBUG__) {
2733 console.log(
@@ -2709,6 +2749,7 @@ export function attach(
2749 // and later arrange them in the correct order.
2750 pendingRealUnmountedSuspenseIDs.push(id);
2751
2752 + pendingSuspenderChanges.delete(id);
2753 idToSuspenseNodeMap.delete(id);
2754 }
2755
@@ -2779,6 +2820,7 @@ export function attach(
2820 ) {
2821 // This didn't exist in the parent before, so let's mark this boundary as having a unique suspender.
2822 parentSuspenseNode.hasUniqueSuspenders = true;
2823 + recordSuspenseSuspenders(parentSuspenseNode);
2824 }
2825 }
2826 // We have observed at least one known reason this might have been suspended.
@@ -2820,6 +2862,9 @@ export function attach(
2862 // We have found a child boundary that depended on the unblocked I/O.
2863 // It can now be marked as having unique suspenders. We can skip its children
2864 // since they'll still be blocked by this one.
2865 + if (!node.hasUniqueSuspenders) {
2866 + recordSuspenseSuspenders(node);
2867 + }
2868 node.hasUniqueSuspenders = true;
2869 node.hasUnknownSuspenders = false;
2870 } else if (node.firstChild !== null) {
@@ -3522,6 +3567,9 @@ export function attach(
3567 // Unfortunately if we don't have any DEV time debug info or debug thenables then
3568 // we have no meta data to show. However, we still mark this Suspense boundary as
3569 // participating in the loading sequence since apparently it can suspend.
3570 + if (!suspenseNode.hasUniqueSuspenders) {
3571 + recordSuspenseSuspenders(suspenseNode);
3572 + }
3573 suspenseNode.hasUniqueSuspenders = true;
3574 // We have not seen any reason yet for why this suspense node might have been
3575 // suspended but it clearly has been at some point. If we later discover a reason
packages/react-devtools-shared/src/constants.js
+1
@@ -28,6 +28,7 @@ export const SUSPENSE_TREE_OPERATION_ADD = 8;
28 export const SUSPENSE_TREE_OPERATION_REMOVE = 9;
29 export const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10;
30 export const SUSPENSE_TREE_OPERATION_RESIZE = 11;
31 +export const SUSPENSE_TREE_OPERATION_SUSPENDERS = 12;
32
33 export const PROFILING_FLAG_BASIC_SUPPORT = 0b01;
34 export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b10;
packages/react-devtools-shared/src/devtools/store.js
+49 -4
@@ -24,6 +24,7 @@ import {
24 SUSPENSE_TREE_OPERATION_REMOVE,
25 SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
26 SUSPENSE_TREE_OPERATION_RESIZE,
27 + SUSPENSE_TREE_OPERATION_SUSPENDERS,
28 } from '../constants';
29 import {ElementTypeRoot} from '../frontend/types';
30 import {
@@ -879,8 +880,13 @@ export default class Store extends EventEmitter<{
880 return null;
881 }
882
883 + /**
884 + * @param rootID
885 + * @param uniqueSuspendersOnly Filters out boundaries without unique suspenders
886 + */
887 getSuspendableDocumentOrderSuspense(
888 rootID: Element['id'] | void,
889 + uniqueSuspendersOnly: boolean,
890 ): $ReadOnlyArray<SuspenseNode['id']> {
891 if (rootID === undefined) {
892 return [];
@@ -892,7 +898,7 @@ export default class Store extends EventEmitter<{
898 if (!this.supportsTogglingSuspense(root.id)) {
899 return [];
900 }
895 - const suspenseTreeList: SuspenseNode['id'][] = [];
901 + const list: SuspenseNode['id'][] = [];
902 const suspense = this.getSuspenseByID(root.id);
903 if (suspense !== null) {
904 const stack = [suspense];
@@ -901,9 +907,11 @@ export default class Store extends EventEmitter<{
907 if (current === undefined) {
908 continue;
909 }
904 - // Include the root even if we won't suspend it.
910 + // Include the root even if we won't show it suspended (because that's just blank).
911 // You should be able to see what suspended the shell.
906 - suspenseTreeList.push(current.id);
912 + if (!uniqueSuspendersOnly || current.hasUniqueSuspenders) {
913 + list.push(current.id);
914 + }
915 // Add children in reverse order to maintain document order
916 for (let j = current.children.length - 1; j >= 0; j--) {
917 const childSuspense = this.getSuspenseByID(current.children[j]);
@@ -914,7 +922,7 @@ export default class Store extends EventEmitter<{
922 }
923 }
924
917 - return suspenseTreeList;
925 + return list;
926 }
927
928 getRendererIDForElement(id: number): number | null {
@@ -1580,6 +1588,7 @@ export default class Store extends EventEmitter<{
1588 children: [],
1589 name,
1590 rects,
1591 + hasUniqueSuspenders: false,
1592 });
1593
1594 hasSuspenseTreeChanged = true;
@@ -1749,6 +1758,42 @@ export default class Store extends EventEmitter<{
1758
1759 break;
1760 }
1761 + case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
1762 + const changeLength = operations[i + 1];
1763 + i += 2;
1764 +
1765 + for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
1766 + const id = operations[i];
1767 + const hasUniqueSuspenders = operations[i + 1] === 1;
1768 + const suspense = this._idToSuspense.get(id);
1769 +
1770 + if (suspense === undefined) {
1771 + this._throwAndEmitError(
1772 + Error(
1773 + `Cannot update suspenders of suspense node "${id}" because no matching node was found in the Store.`,
1774 + ),
1775 + );
1776 +
1777 + break;
1778 + }
1779 +
1780 + i += 2;
1781 +
1782 + if (__DEBUG__) {
1783 + const previousHasUniqueSuspenders = suspense.hasUniqueSuspenders;
1784 + debug(
1785 + 'Suspender changes',
1786 + `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} (was ${String(previousHasUniqueSuspenders)})`,
1787 + );
1788 + }
1789 +
1790 + suspense.hasUniqueSuspenders = hasUniqueSuspenders;
1791 + }
1792 +
1793 + hasSuspenseTreeChanged = true;
1794 +
1795 + break;
1796 + }
1797 default:
1798 this._throwAndEmitError(
1799 new UnsupportedBridgeOperationError(
packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
+7 -7
@@ -52,7 +52,7 @@ type Props = {
52 type: IconType,
53 };
54
55 -const materialIconsViewBox = '0 -960 960 960';
55 +const panelIcons = '0 -960 960 820';
56 export default function ButtonIcon({className = '', type}: Props): React.Node {
57 let pathData = null;
58 let viewBox = '0 0 24 24';
@@ -131,27 +131,27 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
131 break;
132 case 'panel-left-close':
133 pathData = PATH_MATERIAL_PANEL_LEFT_CLOSE;
134 - viewBox = materialIconsViewBox;
134 + viewBox = panelIcons;
135 break;
136 case 'panel-left-open':
137 pathData = PATH_MATERIAL_PANEL_LEFT_OPEN;
138 - viewBox = materialIconsViewBox;
138 + viewBox = panelIcons;
139 break;
140 case 'panel-right-close':
141 pathData = PATH_MATERIAL_PANEL_RIGHT_CLOSE;
142 - viewBox = materialIconsViewBox;
142 + viewBox = panelIcons;
143 break;
144 case 'panel-right-open':
145 pathData = PATH_MATERIAL_PANEL_RIGHT_OPEN;
146 - viewBox = materialIconsViewBox;
146 + viewBox = panelIcons;
147 break;
148 case 'panel-bottom-open':
149 pathData = PATH_MATERIAL_PANEL_BOTTOM_OPEN;
150 - viewBox = materialIconsViewBox;
150 + viewBox = panelIcons;
151 break;
152 case 'panel-bottom-close':
153 pathData = PATH_MATERIAL_PANEL_BOTTOM_CLOSE;
154 - viewBox = materialIconsViewBox;
154 + viewBox = panelIcons;
155 break;
156 case 'suspend':
157 pathData = PATH_SUSPEND;
packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js
+13
@@ -20,6 +20,7 @@ import {
20 SUSPENSE_TREE_OPERATION_REMOVE,
21 SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
22 SUSPENSE_TREE_OPERATION_RESIZE,
23 + SUSPENSE_TREE_OPERATION_SUSPENDERS,
24 } from 'react-devtools-shared/src/constants';
25 import {
26 parseElementDisplayNameFromBackend,
@@ -452,6 +453,18 @@ function updateTree(
453 break;
454 }
455
456 + case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
457 + const changesLength = ((operations[i + 1]: any): number);
458 +
459 + if (__DEBUG__) {
460 + const changes = operations.slice(i + 2, i + 2 + changesLength * 2);
461 + debug('Suspender changes', `[${changes.join(',')}]`);
462 + }
463 +
464 + i += 2 + changesLength * 2;
465 + break;
466 + }
467 +
468 default:
469 throw Error(`Unsupported Bridge operation "${operation}"`);
470 }
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
+1
@@ -3,6 +3,7 @@
3 display: flex;
4 flex-direction: row;
5 padding: 0.25rem;
6 + align-items: center;
7 }
8
9 .SuspenseTimelineInput {
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+35 -6
@@ -11,6 +11,7 @@ import * as React from 'react';
11 import {useContext, useLayoutEffect, useRef} from 'react';
12 import {BridgeContext, StoreContext} from '../context';
13 import {TreeDispatcherContext} from '../Components/TreeContext';
14 +import Tooltip from '../Components/reach-ui/tooltip';
15 import {useHighlightHostInstance} from '../hooks';
16 import {
17 SuspenseTreeDispatcherContext,
@@ -34,8 +35,26 @@ function SuspenseTimelineInput() {
35 selectedRootID: rootID,
36 timeline,
37 timelineIndex,
38 + uniqueSuspendersOnly,
39 } = useContext(SuspenseTreeStateContext);
40
41 + function handleToggleUniqueSuspenders(event: SyntheticEvent) {
42 + const nextUniqueSuspendersOnly = (event.currentTarget as HTMLInputElement)
43 + .checked;
44 + const nextTimeline =
45 + rootID === null
46 + ? []
47 + : // TODO: Handle different timeline modes (e.g. random order)
48 + store.getSuspendableDocumentOrderSuspense(
49 + rootID,
50 + nextUniqueSuspendersOnly,
51 + );
52 + suspenseTreeDispatch({
53 + type: 'SET_SUSPENSE_TIMELINE',
54 + payload: [nextTimeline, null, nextUniqueSuspendersOnly],
55 + });
56 + }
57 +
58 const inputRef = useRef<HTMLElement | null>(null);
59 const inputBBox = useRef<ClientRect | null>(null);
60 useLayoutEffect(() => {
@@ -155,9 +174,7 @@ function SuspenseTimelineInput() {
174
175 return (
176 <>
158 - <div>
159 - {timelineIndex}/{max}
160 - </div>
177 + {timelineIndex}/{max}
178 <div className={styles.SuspenseTimelineInput}>
179 <input
180 className={styles.SuspenseTimelineSlider}
@@ -173,23 +190,35 @@ function SuspenseTimelineInput() {
190 ref={inputRef}
191 />
192 </div>
193 + <Tooltip label="Only include boundaries with unique suspenders">
194 + <input
195 + checked={uniqueSuspendersOnly}
196 + type="checkbox"
197 + onChange={handleToggleUniqueSuspenders}
198 + />
199 + </Tooltip>
200 </>
201 );
202 }
203
204 export default function SuspenseTimeline(): React$Node {
205 const store = useContext(StoreContext);
182 - const {roots, selectedRootID} = useContext(SuspenseTreeStateContext);
206 + const {roots, selectedRootID, uniqueSuspendersOnly} = useContext(
207 + SuspenseTreeStateContext,
208 + );
209 const treeDispatch = useContext(TreeDispatcherContext);
210 const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
211
212 function handleChange(event: SyntheticEvent) {
213 const newRootID = +event.currentTarget.value;
214 // TODO: scrollIntoView both suspense rects and host instance.
189 - const nextTimeline = store.getSuspendableDocumentOrderSuspense(newRootID);
215 + const nextTimeline = store.getSuspendableDocumentOrderSuspense(
216 + newRootID,
217 + uniqueSuspendersOnly,
218 + );
219 suspenseTreeDispatch({
220 type: 'SET_SUSPENSE_TIMELINE',
192 - payload: [nextTimeline, newRootID],
221 + payload: [nextTimeline, newRootID, uniqueSuspendersOnly],
222 });
223 if (nextTimeline.length > 0) {
224 const milestone = nextTimeline[nextTimeline.length - 1];
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+19 -3
@@ -31,6 +31,7 @@ export type SuspenseTreeState = {
31 selectedSuspenseID: SuspenseNode['id'] | null,
32 timeline: $ReadOnlyArray<SuspenseNode['id']>,
33 timelineIndex: number | -1,
34 + uniqueSuspendersOnly: boolean,
35 };
36
37 type ACTION_SUSPENSE_TREE_MUTATION = {
@@ -51,6 +52,8 @@ type ACTION_SET_SUSPENSE_TIMELINE = {
52 $ReadOnlyArray<SuspenseNode['id']>,
53 // The next Suspense ID to select in the timeline
54 SuspenseNode['id'] | null,
55 + // Whether this timeline includes only unique suspenders
56 + boolean,
57 ],
58 };
59 type ACTION_SUSPENSE_SET_TIMELINE_INDEX = {
@@ -92,6 +95,7 @@ function getDefaultRootID(store: Store): Element['id'] | null {
95
96 function getInitialState(store: Store): SuspenseTreeState {
97 let initialState: SuspenseTreeState;
98 + const uniqueSuspendersOnly = true;
99 const selectedRootID = getDefaultRootID(store);
100 // TODO: Default to nearest from inspected
101 if (selectedRootID === null) {
@@ -102,9 +106,13 @@ function getInitialState(store: Store): SuspenseTreeState {
106 selectedRootID,
107 timeline: [],
108 timelineIndex: -1,
109 + uniqueSuspendersOnly,
110 };
111 } else {
107 - const timeline = store.getSuspendableDocumentOrderSuspense(selectedRootID);
112 + const timeline = store.getSuspendableDocumentOrderSuspense(
113 + selectedRootID,
114 + uniqueSuspendersOnly,
115 + );
116 const timelineIndex = timeline.length - 1;
117 const selectedSuspenseID =
118 timelineIndex === -1 ? null : timeline[timelineIndex];
@@ -119,6 +127,7 @@ function getInitialState(store: Store): SuspenseTreeState {
127 selectedRootID,
128 timeline,
129 timelineIndex,
130 + uniqueSuspendersOnly,
131 };
132 }
133
@@ -182,7 +191,10 @@ function SuspenseTreeContextController({children}: Props): React.Node {
191 nextRootID === null
192 ? []
193 : // TODO: Handle different timeline modes (e.g. random order)
185 - store.getSuspendableDocumentOrderSuspense(nextRootID);
194 + store.getSuspendableDocumentOrderSuspense(
195 + nextRootID,
196 + state.uniqueSuspendersOnly,
197 + );
198
199 let nextTimelineIndex =
200 selectedTimelineID === null || nextTimeline.length === 0
@@ -242,6 +254,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
254 const previousTimeline = state.timeline;
255 const nextTimeline = action.payload[0];
256 const nextRootID: SuspenseNode['id'] | null = action.payload[1];
257 + const nextUniqueSuspendersOnly = action.payload[2];
258 let nextLineage = state.lineage;
259 let nextMilestoneIndex: number | -1 = -1;
260 let nextSelectedSuspenseID = state.selectedSuspenseID;
@@ -255,8 +268,10 @@ function SuspenseTreeContextController({children}: Props): React.Node {
268 const previousMilestoneID =
269 previousTimeline[previousMilestoneIndex];
270 nextMilestoneIndex = nextTimeline.indexOf(previousMilestoneID);
258 - if (nextMilestoneIndex === -1) {
271 + if (nextMilestoneIndex === -1 && nextTimeline.length > 0) {
272 nextMilestoneIndex = nextTimeline.length - 1;
273 + nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
274 + nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
275 }
276 } else if (nextRootID !== null) {
277 nextMilestoneIndex = nextTimeline.length - 1;
@@ -272,6 +287,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
287 nextRootID === null ? state.selectedRootID : nextRootID,
288 timeline: nextTimeline,
289 timelineIndex: nextMilestoneIndex,
290 + uniqueSuspendersOnly: nextUniqueSuspendersOnly,
291 };
292 }
293 case 'SUSPENSE_SET_TIMELINE_INDEX': {
packages/react-devtools-shared/src/frontend/types.js
+1
@@ -199,6 +199,7 @@ export type SuspenseNode = {
199 children: Array<SuspenseNode['id']>,
200 name: string | null,
201 rects: null | Array<Rect>,
202 + hasUniqueSuspenders: boolean,
203 };
204
205 // Serialized version of ReactIOInfo
packages/react-devtools-shared/src/utils.js
+11
@@ -44,6 +44,7 @@ import {
44 SUSPENSE_TREE_OPERATION_REMOVE,
45 SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
46 SUSPENSE_TREE_OPERATION_RESIZE,
47 + SUSPENSE_TREE_OPERATION_SUSPENDERS,
48 } from './constants';
49 import {
50 ComponentFilterElementType,
@@ -424,6 +425,16 @@ export function printOperationsArray(operations: Array<number>) {
425
426 break;
427 }
428 + case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
429 + const changeLength = operations[i + 1];
430 + i += 2;
431 + const changes = operations.slice(i, i + changeLength * 2);
432 + i += changeLength;
433 +
434 + logs.push(`Suspense node suspender changes ${changes.join(',')}`);
435 +
436 + break;
437 + }
438 default:
439 throw Error(`Unsupported Bridge operation "${operation}"`);
440 }