[DevTools] Batch Suspense toggles when advancing the Suspense timeline (#34251)
Sebastian "Sebbie" Silbermann committed
Aug 26, 2025 at 17:22 UTC
cb1e73be043513fd52fdf21366aa35c141ec5f1a
13 files changed
+342
-43
packages/react-devtools-shared/src/__tests__/store-test.js
+88
@@ -901,6 +901,94 @@ describe('Store', () => {
901
`);
902
});
903
904
+ // @reactVersion >= 18.0
905
+ it('can override multiple Suspense simultaneously', async () => {
906
+ const Component = () => {
907
+ return <div>Hello</div>;
908
+ };
909
+ const App = () => (
910
+ <React.Fragment>
911
+ <Component key="Outside" />
912
+ <React.Suspense
913
+ name="parent"
914
+ fallback={<Component key="Parent Fallback" />}>
915
+ <Component key="Unrelated at Start" />
916
+ <React.Suspense
917
+ name="one"
918
+ fallback={<Component key="Suspense 1 Fallback" />}>
919
+ <Component key="Suspense 1 Content" />
920
+ </React.Suspense>
921
+ <React.Suspense
922
+ name="two"
923
+ fallback={<Component key="Suspense 2 Fallback" />}>
924
+ <Component key="Suspense 2 Content" />
925
+ </React.Suspense>
926
+ <React.Suspense
927
+ name="three"
928
+ fallback={<Component key="Suspense 3 Fallback" />}>
929
+ <Component key="Suspense 3 Content" />
930
+ </React.Suspense>
931
+ <Component key="Unrelated at End" />
932
+ </React.Suspense>
933
+ </React.Fragment>
934
+ );
935
+
936
+ await actAsync(() => render(<App />));
937
+
938
+ expect(store).toMatchInlineSnapshot(`
939
+ [root]
940
+ ▾ <App>
941
+ <Component key="Outside">
942
+ ▾ <Suspense name="parent">
943
+ <Component key="Unrelated at Start">
944
+ ▾ <Suspense name="one">
945
+ <Component key="Suspense 1 Content">
946
+ ▾ <Suspense name="two">
947
+ <Component key="Suspense 2 Content">
948
+ ▾ <Suspense name="three">
949
+ <Component key="Suspense 3 Content">
950
+ <Component key="Unrelated at End">
951
+ [shell]
952
+ <Suspense name="parent" rects={[{x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}]}>
953
+ <Suspense name="one" rects={[{x:1,y:2,width:5,height:1}]}>
954
+ <Suspense name="two" rects={[{x:1,y:2,width:5,height:1}]}>
955
+ <Suspense name="three" rects={[{x:1,y:2,width:5,height:1}]}>
956
+ `);
957
+
958
+ const rendererID = getRendererID();
959
+ const rootID = store.getRootIDForElement(store.getElementIDAtIndex(0));
960
+ await actAsync(() => {
961
+ agent.overrideSuspenseMilestone({
962
+ rendererID,
963
+ rootID,
964
+ suspendedSet: [
965
+ store.getElementIDAtIndex(4),
966
+ store.getElementIDAtIndex(8),
967
+ ],
968
+ });
969
+ });
970
+
971
+ expect(store).toMatchInlineSnapshot(`
972
+ [root]
973
+ ▾ <App>
974
+ <Component key="Outside">
975
+ ▾ <Suspense name="parent">
976
+ <Component key="Unrelated at Start">
977
+ ▾ <Suspense name="one">
978
+ <Component key="Suspense 1 Fallback">
979
+ ▾ <Suspense name="two">
980
+ <Component key="Suspense 2 Content">
981
+ ▾ <Suspense name="three">
982
+ <Component key="Suspense 3 Fallback">
983
+ <Component key="Unrelated at End">
984
+ [shell]
985
+ <Suspense name="parent" rects={[{x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}]}>
986
+ <Suspense name="one" rects={[{x:1,y:2,width:5,height:1}]}>
987
+ <Suspense name="two" rects={[{x:1,y:2,width:5,height:1}]}>
988
+ <Suspense name="three" rects={[{x:1,y:2,width:5,height:1}]}>
989
+ `);
990
+ });
991
+
992
it('should display a partially rendered SuspenseList', async () => {
993
const Loading = () => <div>Loading...</div>;
994
const SuspendingComponent = () => {
packages/react-devtools-shared/src/backend/agent.js
+25
@@ -130,6 +130,12 @@ type OverrideSuspenseParams = {
130
forceFallback: boolean,
131
};
132
133
+type OverrideSuspenseMilestoneParams = {
134
+ rendererID: number,
135
+ rootID: number,
136
+ suspendedSet: Array<number>,
137
+};
138
+
139
type PersistedSelection = {
140
rendererID: number,
141
path: Array<PathFrame>,
@@ -198,6 +204,10 @@ export default class Agent extends EventEmitter<{
204
bridge.addListener('logElementToConsole', this.logElementToConsole);
205
bridge.addListener('overrideError', this.overrideError);
206
bridge.addListener('overrideSuspense', this.overrideSuspense);
207
+ bridge.addListener(
208
+ 'overrideSuspenseMilestone',
209
+ this.overrideSuspenseMilestone,
210
+ );
211
bridge.addListener('overrideValueAtPath', this.overrideValueAtPath);
212
bridge.addListener('reloadAndProfile', this.reloadAndProfile);
213
bridge.addListener('renamePath', this.renamePath);
@@ -556,6 +566,21 @@ export default class Agent extends EventEmitter<{
566
}
567
};
568
569
+ overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({
570
+ rendererID,
571
+ rootID,
572
+ suspendedSet,
573
+ }) => {
574
+ const renderer = this._rendererInterfaces[rendererID];
575
+ if (renderer == null) {
576
+ console.warn(
577
+ `Invalid renderer id "${rendererID}" to override suspense milestone`,
578
+ );
579
+ } else {
580
+ renderer.overrideSuspenseMilestone(rootID, suspendedSet);
581
+ }
582
+ };
583
+
584
overrideValueAtPath: OverrideValueAtPathParams => void = ({
585
hookID,
586
id,
packages/react-devtools-shared/src/backend/fiber/renderer.js
+55
-7
@@ -2366,6 +2366,7 @@ export function attach(
2366
!isProductionBuildOfRenderer && StrictModeBits !== 0 ? 1 : 0,
2367
);
2368
pushOperation(hasOwnerMetadata ? 1 : 0);
2369
+ pushOperation(supportsTogglingSuspense ? 1 : 0);
2370
2371
if (isProfiling) {
2372
if (displayNamesByRootID !== null) {
@@ -7455,13 +7456,6 @@ export function attach(
7456
}
7457
7458
function overrideSuspense(id: number, forceFallback: boolean) {
7458
- if (!supportsTogglingSuspense) {
7459
- // TODO:: Add getter to decide if overrideSuspense is available.
7460
- // Currently only available on inspectElement.
7461
- // Probably need a different affordance to batch since the timeline
7462
- // fallback is not the same as resuspending.
7463
- return;
7464
- }
7459
if (
7460
typeof setSuspenseHandler !== 'function' ||
7461
typeof scheduleUpdate !== 'function'
@@ -7506,6 +7500,58 @@ export function attach(
7500
scheduleUpdate(fiber);
7501
}
7502
7503
+ /**
7504
+ * Resets the all other roots of this renderer.
7505
+ * @param rootID The root that contains this milestone
7506
+ * @param suspendedSet List of IDs of SuspenseComponent Fibers
7507
+ */
7508
+ function overrideSuspenseMilestone(
7509
+ rootID: FiberInstance['id'],
7510
+ suspendedSet: Array<FiberInstance['id']>,
7511
+ ) {
7512
+ if (
7513
+ typeof setSuspenseHandler !== 'function' ||
7514
+ typeof scheduleUpdate !== 'function'
7515
+ ) {
7516
+ throw new Error(
7517
+ 'Expected overrideSuspenseMilestone() to not get called for earlier React versions.',
7518
+ );
7519
+ }
7520
+
7521
+ // TODO: Allow overriding the timeline for the specified root.
7522
+ forceFallbackForFibers.clear();
7523
+
7524
+ for (let i = 0; i < suspendedSet.length; ++i) {
7525
+ const instance = idToDevToolsInstanceMap.get(suspendedSet[i]);
7526
+ if (instance === undefined) {
7527
+ console.warn(
7528
+ `Could not suspend ID '${suspendedSet[i]}' since the instance can't be found.`,
7529
+ );
7530
+ continue;
7531
+ }
7532
+
7533
+ if (instance.kind === FIBER_INSTANCE) {
7534
+ const fiber = instance.data;
7535
+ forceFallbackForFibers.add(fiber);
7536
+ // We could find a minimal set that covers all the Fibers in this suspended set.
7537
+ // For now we rely on React's batching of updates.
7538
+ scheduleUpdate(fiber);
7539
+ } else {
7540
+ console.warn(`Cannot not suspend ID '${suspendedSet[i]}'.`);
7541
+ }
7542
+ }
7543
+
7544
+ if (forceFallbackForFibers.size > 0) {
7545
+ // First override is added. Switch React to slower path.
7546
+ // TODO: Semantics for suspending a timeline are different. We want a suspended
7547
+ // timeline to act like a first reveal which is relevant for SuspenseList.
7548
+ // Resuspending would not affect rows in SuspenseList
7549
+ setSuspenseHandler(shouldSuspendFiberAccordingToSet);
7550
+ } else {
7551
+ setSuspenseHandler(shouldSuspendFiberAlwaysFalse);
7552
+ }
7553
+ }
7554
+
7555
// Remember if we're trying to restore the selection after reload.
7556
// In that case, we'll do some extra checks for matching mounts.
7557
let trackedPath: Array<PathFrame> | null = null;
@@ -8006,6 +8052,7 @@ export function attach(
8052
onErrorOrWarning,
8053
overrideError,
8054
overrideSuspense,
8055
+ overrideSuspenseMilestone,
8056
overrideValueAtPath,
8057
renamePath,
8058
renderer,
@@ -8014,6 +8061,7 @@ export function attach(
8061
startProfiling,
8062
stopProfiling,
8063
storeAsGlobal,
8064
+ supportsTogglingSuspense,
8065
updateComponentFilters,
8066
getEnvironmentNames,
8067
...internalMcpFunctions,
packages/react-devtools-shared/src/backend/flight/renderer.js
+4
@@ -140,6 +140,8 @@ export function attach(
140
// The changes will be flushed later when we commit this tree to Fiber.
141
}
142
143
+ const supportsTogglingSuspense = false;
144
+
145
return {
146
cleanup() {},
147
clearErrorsAndWarnings() {},
@@ -202,6 +204,7 @@ export function attach(
204
onErrorOrWarning,
205
overrideError() {},
206
overrideSuspense() {},
207
+ overrideSuspenseMilestone() {},
208
overrideValueAtPath() {},
209
renamePath() {},
210
renderer,
@@ -210,6 +213,7 @@ export function attach(
213
startProfiling() {},
214
stopProfiling() {},
215
storeAsGlobal() {},
216
+ supportsTogglingSuspense,
217
updateComponentFilters() {},
218
getEnvironmentNames() {
219
return [];
packages/react-devtools-shared/src/backend/legacy/renderer.js
+8
@@ -180,6 +180,8 @@ export function attach(
180
};
181
}
182
183
+ const supportsTogglingSuspense = false;
184
+
185
function getDisplayNameForElementID(id: number): string | null {
186
const internalInstance = idToInternalInstanceMap.get(id);
187
return internalInstance ? getData(internalInstance).displayName : null;
@@ -408,6 +410,7 @@ export function attach(
410
pushOperation(0); // Profiling flag
411
pushOperation(0); // StrictMode supported?
412
pushOperation(hasOwnerMetadata ? 1 : 0);
413
+ pushOperation(supportsTogglingSuspense ? 1 : 0);
414
} else {
415
const type = getElementType(internalInstance);
416
const {displayName, key} = getData(internalInstance);
@@ -1070,6 +1073,9 @@ export function attach(
1073
const overrideSuspense = () => {
1074
throw new Error('overrideSuspense not supported by this renderer');
1075
};
1076
+ const overrideSuspenseMilestone = () => {
1077
+ throw new Error('overrideSuspenseMilestone not supported by this renderer');
1078
+ };
1079
const startProfiling = () => {
1080
// Do not throw, since this would break a multi-root scenario where v15 and v16 were both present.
1081
};
@@ -1153,6 +1159,7 @@ export function attach(
1159
logElementToConsole,
1160
overrideError,
1161
overrideSuspense,
1162
+ overrideSuspenseMilestone,
1163
overrideValueAtPath,
1164
renamePath,
1165
getElementAttributeByPath,
@@ -1163,6 +1170,7 @@ export function attach(
1170
startProfiling,
1171
stopProfiling,
1172
storeAsGlobal,
1173
+ supportsTogglingSuspense,
1174
updateComponentFilters,
1175
getEnvironmentNames,
1176
};
packages/react-devtools-shared/src/backend/types.js
+5
@@ -437,6 +437,10 @@ export type RendererInterface = {
437
onErrorOrWarning?: OnErrorOrWarning,
438
overrideError: (id: number, forceError: boolean) => void,
439
overrideSuspense: (id: number, forceFallback: boolean) => void,
440
+ overrideSuspenseMilestone: (
441
+ rootID: number,
442
+ suspendedSet: Array<number>,
443
+ ) => void,
444
overrideValueAtPath: (
445
type: Type,
446
id: number,
@@ -469,6 +473,7 @@ export type RendererInterface = {
473
path: Array<string | number>,
474
count: number,
475
) => void,
476
+ supportsTogglingSuspense: boolean,
477
updateComponentFilters: (componentFilters: Array<ComponentFilter>) => void,
478
getEnvironmentNames: () => Array<string>,
479
packages/react-devtools-shared/src/bridge.js
+14
-1
@@ -27,7 +27,7 @@ export type BridgeProtocol = {
27
// Version supported by the current frontend/backend.
28
version: number,
29
30
- // NPM version range that also supports this version.
30
+ // NPM version range of `react-devtools-inline` that also supports this version.
31
// Note that 'maxNpmVersion' is only set when the version is bumped.
32
minNpmVersion: string,
33
maxNpmVersion: string | null,
@@ -65,6 +65,12 @@ export const BRIDGE_PROTOCOL: Array<BridgeProtocol> = [
65
{
66
version: 2,
67
minNpmVersion: '4.22.0',
68
+ maxNpmVersion: '6.2.0',
69
+ },
70
+ // Version 3 adds supports-toggling-suspense bit to add-root
71
+ {
72
+ version: 3,
73
+ minNpmVersion: '6.2.0',
74
maxNpmVersion: null,
75
},
76
];
@@ -134,6 +140,12 @@ type OverrideSuspense = {
140
forceFallback: boolean,
141
};
142
143
+type OverrideSuspenseMilestone = {
144
+ rendererID: number,
145
+ rootID: number,
146
+ suspendedSet: Array<number>,
147
+};
148
+
149
type CopyElementPathParams = {
150
...ElementAndRendererID,
151
path: Array<string | number>,
@@ -231,6 +243,7 @@ type FrontendEvents = {
243
logElementToConsole: [ElementAndRendererID],
244
overrideError: [OverrideError],
245
overrideSuspense: [OverrideSuspense],
246
+ overrideSuspenseMilestone: [OverrideSuspenseMilestone],
247
overrideValueAtPath: [OverrideValueAtPath],
248
profilingData: [ProfilingDataBackend],
249
reloadAndProfile: [ReloadAndProfilingParams],
packages/react-devtools-shared/src/devtools/store.js
+14
@@ -89,6 +89,7 @@ export type Capabilities = {
89
supportsBasicProfiling: boolean,
90
hasOwnerMetadata: boolean,
91
supportsStrictMode: boolean,
92
+ supportsTogglingSuspense: boolean,
93
supportsTimeline: boolean,
94
};
95
@@ -491,6 +492,14 @@ export default class Store extends EventEmitter<{
492
);
493
}
494
495
+ supportsTogglingSuspense(rootID: Element['id']): boolean {
496
+ const capabilities = this._rootIDToCapabilities.get(rootID);
497
+ if (capabilities === undefined) {
498
+ throw new Error(`No capabilities registered for root ${rootID}`);
499
+ }
500
+ return capabilities.supportsTogglingSuspense;
501
+ }
502
+
503
// This build of DevTools supports the Timeline profiler.
504
// This is a static flag, controlled by the Store config.
505
get supportsTimeline(): boolean {
@@ -1080,6 +1089,7 @@ export default class Store extends EventEmitter<{
1089
1090
let supportsStrictMode = false;
1091
let hasOwnerMetadata = false;
1092
+ let supportsTogglingSuspense = false;
1093
1094
// If we don't know the bridge protocol, guess that we're dealing with the latest.
1095
// If we do know it, we can take it into consideration when parsing operations.
@@ -1092,6 +1102,9 @@ export default class Store extends EventEmitter<{
1102
1103
hasOwnerMetadata = operations[i] > 0;
1104
i++;
1105
+
1106
+ supportsTogglingSuspense = operations[i] > 0;
1107
+ i++;
1108
}
1109
1110
this._roots = this._roots.concat(id);
@@ -1100,6 +1113,7 @@ export default class Store extends EventEmitter<{
1113
supportsBasicProfiling,
1114
hasOwnerMetadata,
1115
supportsStrictMode,
1116
+ supportsTogglingSuspense,
1117
supportsTimeline,
1118
});
1119
packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js
+1
@@ -208,6 +208,7 @@ function updateTree(
208
i++; // Profiling flag
209
i++; // supportsStrictMode flag
210
i++; // hasOwnerMetadata flag
211
+ i++; // supportsTogglingSuspense flag
212
213
if (__DEBUG__) {
214
debug('Add', `new root fiber ${id}`);
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
+1
@@ -115,4 +115,5 @@
115
116
.Timeline {
117
flex-grow: 1;
118
+ align-self: anchor-center;
119
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
+15
-1
@@ -1,5 +1,18 @@
1
-.SuspenseTimelineSlider {
1
+.SuspenseTimelineContainer {
2
width: 100%;
3
+ display: flex;
4
+ flex-direction: row;
5
+}
6
+
7
+.SuspenseTimelineInput {
8
+ display: flex;
9
+ flex-direction: column;
10
+ flex-grow: 1;
11
+}
12
+
13
+.SuspenseTimelineRootSwitcher {
14
+ height: fit-content;
15
+ max-width: 3rem;
16
}
17
18
.SuspenseTimelineMarkers {
@@ -18,3 +31,4 @@
31
.SuspenseTimelineActiveMarker {
32
visibility: visible;
33
}
34
+
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+111
-34
@@ -29,34 +29,38 @@ import typeof {
29
SyntheticPointerEvent,
30
} from 'react-dom-bindings/src/events/SyntheticEvent';
31
32
-// TODO: This returns the roots which would mean we attempt to suspend the shell.
33
-// Suspending the shell is currently not supported and we don't have a good view
34
-// for inspecting the root. But we probably should?
35
-function getDocumentOrderSuspense(
32
+function getSuspendableDocumentOrderSuspense(
33
store: Store,
37
- roots: $ReadOnlyArray<Element['id']>,
34
+ rootID: Element['id'] | void,
35
): Array<SuspenseNode> {
36
+ if (rootID === undefined) {
37
+ return [];
38
+ }
39
+ const root = store.getElementByID(rootID);
40
+ if (root === null) {
41
+ return [];
42
+ }
43
+ if (!store.supportsTogglingSuspense(root.id)) {
44
+ return [];
45
+ }
46
const suspenseTreeList: SuspenseNode[] = [];
40
- for (let i = 0; i < roots.length; i++) {
41
- const root = store.getElementByID(roots[i]);
42
- if (root === null) {
43
- continue;
44
- }
45
- const suspense = store.getSuspenseByID(root.id);
46
- if (suspense !== null) {
47
- const stack = [suspense];
48
- while (stack.length > 0) {
49
- const current = stack.pop();
50
- if (current === undefined) {
51
- continue;
52
- }
47
+ const suspense = store.getSuspenseByID(root.id);
48
+ if (suspense !== null) {
49
+ const stack = [suspense];
50
+ while (stack.length > 0) {
51
+ const current = stack.pop();
52
+ if (current === undefined) {
53
+ continue;
54
+ }
55
+ // Don't include the root. It's currently not supported to suspend the shell.
56
+ if (current !== suspense) {
57
suspenseTreeList.push(current);
54
- // Add children in reverse order to maintain document order
55
- for (let j = current.children.length - 1; j >= 0; j--) {
56
- const childSuspense = store.getSuspenseByID(current.children[j]);
57
- if (childSuspense !== null) {
58
- stack.push(childSuspense);
59
- }
58
+ }
59
+ // Add children in reverse order to maintain document order
60
+ for (let j = current.children.length - 1; j >= 0; j--) {
61
+ const childSuspense = store.getSuspenseByID(current.children[j]);
62
+ if (childSuspense !== null) {
63
+ stack.push(childSuspense);
64
}
65
}
66
}
@@ -65,22 +69,24 @@ function getDocumentOrderSuspense(
69
return suspenseTreeList;
70
}
71
68
-export default function SuspenseTimeline(): React$Node {
72
+function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
73
const bridge = useContext(BridgeContext);
74
const store = useContext(StoreContext);
75
const dispatch = useContext(TreeDispatcherContext);
72
- const {shells} = useContext(SuspenseTreeStateContext);
73
-
74
- const timeline = useMemo(() => {
75
- return getDocumentOrderSuspense(store, shells);
76
- }, [store, shells]);
77
-
76
const {highlightHostInstance, clearHighlightHostInstance} =
77
useHighlightHostInstance();
78
79
+ const timeline = useMemo(() => {
80
+ return getSuspendableDocumentOrderSuspense(store, rootID);
81
+ }, [store, rootID]);
82
+
83
const inputRef = useRef<HTMLElement | null>(null);
84
const inputBBox = useRef<ClientRect | null>(null);
85
useLayoutEffect(() => {
86
+ if (timeline.length === 0) {
87
+ return;
88
+ }
89
+
90
const input = inputRef.current;
91
if (input === null) {
92
throw new Error('Expected an input HTML element to be present.');
@@ -95,12 +101,12 @@ export default function SuspenseTimeline(): React$Node {
101
inputBBox.current = null;
102
observer.disconnect();
103
};
98
- }, []);
104
+ }, [timeline.length]);
105
106
const min = 0;
107
const max = timeline.length > 0 ? timeline.length - 1 : 0;
102
-
108
const [value, setValue] = useState(max);
109
+
110
if (value > max) {
111
// TODO: Handle timeline changes
112
setValue(max);
@@ -130,6 +136,26 @@ export default function SuspenseTimeline(): React$Node {
136
});
137
}, [timeline, value]);
138
139
+ if (rootID === undefined) {
140
+ return <div className={styles.SuspenseTimelineInput}>Root not found.</div>;
141
+ }
142
+
143
+ if (!store.supportsTogglingSuspense(rootID)) {
144
+ return (
145
+ <div className={styles.SuspenseTimelineInput}>
146
+ Can't step through Suspense in production apps.
147
+ </div>
148
+ );
149
+ }
150
+
151
+ if (timeline.length === 0) {
152
+ return (
153
+ <div className={styles.SuspenseTimelineInput}>
154
+ Root contains no Suspense nodes.
155
+ </div>
156
+ );
157
+ }
158
+
159
function handleChange(event: SyntheticEvent) {
160
const pendingValue = +event.currentTarget.value;
161
for (let i = 0; i < timeline.length; i++) {
@@ -193,7 +219,7 @@ export default function SuspenseTimeline(): React$Node {
219
}
220
221
return (
196
- <div>
222
+ <div className={styles.SuspenseTimelineInput}>
223
<input
224
className={styles.SuspenseTimelineSlider}
225
type="range"
@@ -214,3 +240,54 @@ export default function SuspenseTimeline(): React$Node {
240
</div>
241
);
242
}
243
+
244
+export default function SuspenseTimeline(): React$Node {
245
+ const store = useContext(StoreContext);
246
+ const {shells} = useContext(SuspenseTreeStateContext);
247
+
248
+ const defaultSelectedRootID = shells.find(rootID => {
249
+ const suspense = store.getSuspenseByID(rootID);
250
+ return (
251
+ store.supportsTogglingSuspense(rootID) &&
252
+ suspense !== null &&
253
+ suspense.children.length > 1
254
+ );
255
+ });
256
+ const [selectedRootID, setSelectedRootID] = useState(defaultSelectedRootID);
257
+
258
+ if (selectedRootID === undefined && defaultSelectedRootID !== undefined) {
259
+ setSelectedRootID(defaultSelectedRootID);
260
+ }
261
+
262
+ function handleChange(event: SyntheticEvent) {
263
+ const newRootID = +event.currentTarget.value;
264
+ // TODO: scrollIntoView both suspense rects and host instance.
265
+ setSelectedRootID(newRootID);
266
+ }
267
+
268
+ return (
269
+ <div className={styles.SuspenseTimelineContainer}>
270
+ <SuspenseTimelineInput key={selectedRootID} rootID={selectedRootID} />
271
+ {shells.length > 0 && (
272
+ <select
273
+ aria-label="Select Suspense Root"
274
+ className={styles.SuspenseTimelineRootSwitcher}
275
+ onChange={handleChange}>
276
+ {shells.map(rootID => {
277
+ // TODO: Use name
278
+ const name = '#' + rootID;
279
+ // TODO: Highlight host on hover
280
+ return (
281
+ <option
282
+ key={rootID}
283
+ selected={rootID === selectedRootID}
284
+ value={rootID}>
285
+ {name}
286
+ </option>
287
+ );
288
+ })}
289
+ </select>
290
+ )}
291
+ </div>
292
+ );
293
+}
packages/react-devtools-shared/src/utils.js
+1
@@ -261,6 +261,7 @@ export function printOperationsArray(operations: Array<number>) {
261
i++; // supportsProfiling
262
i++; // supportsStrictMode
263
i++; // hasOwnerMetadata
264
+ i++; // supportsTogglingSuspense
265
} else {
266
const parentID = ((operations[i]: any): number);
267
i++;