@samitouri / QOS-React / commits / cb1e73be04

[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++;