[DevTools] Skeleton for Suspense tab (#34020)
Sebastian "Sebbie" Silbermann committed
Jul 28, 2025 at 18:26 UTC
ab2681af0305aa1f7451bd15524d4d783dd78f69
9 files changed
+169
-7
packages/react-devtools-extensions/src/main/index.js
+64
-2
@@ -151,6 +151,10 @@ function createBridgeAndStore() {
151
supportsClickToInspect: true,
152
});
153
154
+ store.addListener('enableSuspenseTab', () => {
155
+ createSuspensePanel();
156
+ });
157
+
158
store.addListener('settingsUpdated', settings => {
159
chrome.storage.local.set(settings);
160
});
@@ -209,6 +213,7 @@ function createBridgeAndStore() {
213
overrideTab,
214
showTabBar: false,
215
store,
216
+ suspensePortalContainer,
217
warnIfUnsupportedVersionDetected: true,
218
viewAttributeSourceFunction,
219
// Firefox doesn't support chrome.devtools.panels.openResource yet
@@ -354,6 +359,42 @@ function createSourcesEditorPanel() {
359
});
360
}
361
362
+function createSuspensePanel() {
363
+ if (suspensePortalContainer) {
364
+ // Panel is created and user opened it at least once
365
+ ensureInitialHTMLIsCleared(suspensePortalContainer);
366
+ render('suspense');
367
+
368
+ return;
369
+ }
370
+
371
+ if (suspensePanel) {
372
+ // Panel is created, but wasn't opened yet, so no document is present for it
373
+ return;
374
+ }
375
+
376
+ chrome.devtools.panels.create(
377
+ __IS_CHROME__ || __IS_EDGE__ ? 'Suspense ⚛' : 'Suspense',
378
+ __IS_EDGE__ ? 'icons/production.svg' : '',
379
+ 'panel.html',
380
+ createdPanel => {
381
+ suspensePanel = createdPanel;
382
+
383
+ createdPanel.onShown.addListener(portal => {
384
+ suspensePortalContainer = portal.container;
385
+ if (suspensePortalContainer != null && render) {
386
+ ensureInitialHTMLIsCleared(suspensePortalContainer);
387
+
388
+ render('suspense');
389
+ portal.injectStyles(cloneStyleTags);
390
+
391
+ logEvent({event_name: 'selected-suspense-tab'});
392
+ }
393
+ });
394
+ },
395
+ );
396
+}
397
+
398
function performInTabNavigationCleanup() {
399
// Potentially, if react hasn't loaded yet and user performs in-tab navigation
400
clearReactPollingInstance();
@@ -365,7 +406,12 @@ function performInTabNavigationCleanup() {
406
407
// If panels were already created, and we have already mounted React root to display
408
// tabs (Components or Profiler), we should unmount root first and render them again
368
- if ((componentsPortalContainer || profilerPortalContainer) && root) {
409
+ if (
410
+ (componentsPortalContainer ||
411
+ profilerPortalContainer ||
412
+ suspensePortalContainer) &&
413
+ root
414
+ ) {
415
// It's easiest to recreate the DevTools panel (to clean up potential stale state).
416
// We can revisit this in the future as a small optimization.
417
// This should also emit bridge.shutdown, but only if this root was mounted
@@ -395,7 +441,12 @@ function performFullCleanup() {
441
// Potentially, if react hasn't loaded yet and user closed the browser DevTools
442
clearReactPollingInstance();
443
398
- if ((componentsPortalContainer || profilerPortalContainer) && root) {
444
+ if (
445
+ (componentsPortalContainer ||
446
+ profilerPortalContainer ||
447
+ suspensePortalContainer) &&
448
+ root
449
+ ) {
450
// This should also emit bridge.shutdown, but only if this root was mounted
451
flushSync(() => root.unmount());
452
} else {
@@ -404,6 +455,7 @@ function performFullCleanup() {
455
456
componentsPortalContainer = null;
457
profilerPortalContainer = null;
458
+ suspensePortalContainer = null;
459
root = null;
460
461
mostRecentOverrideTab = null;
@@ -454,6 +506,8 @@ function mountReactDevTools() {
506
createComponentsPanel();
507
createProfilerPanel();
508
createSourcesEditorPanel();
509
+ // Suspense Tab is created via the hook
510
+ // TODO(enableSuspenseTab): Create eagerly once Suspense tab is stable
511
}
512
513
let reactPollingInstance = null;
@@ -474,6 +528,12 @@ function showNoReactDisclaimer() {
528
'<h1 class="no-react-disclaimer">Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.</h1>';
529
delete profilerPortalContainer._hasInitialHTMLBeenCleared;
530
}
531
+
532
+ if (suspensePortalContainer) {
533
+ suspensePortalContainer.innerHTML =
534
+ '<h1 class="no-react-disclaimer">Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.</h1>';
535
+ delete suspensePortalContainer._hasInitialHTMLBeenCleared;
536
+ }
537
}
538
539
function mountReactDevToolsWhenReactHasLoaded() {
@@ -492,9 +552,11 @@ let profilingData = null;
552
553
let componentsPanel = null;
554
let profilerPanel = null;
555
+let suspensePanel = null;
556
let editorPane = null;
557
let componentsPortalContainer = null;
558
let profilerPortalContainer = null;
559
+let suspensePortalContainer = null;
560
let editorPortalContainer = null;
561
562
let mostRecentOverrideTab = null;
packages/react-devtools-shared/src/Logger.js
+3
@@ -25,6 +25,9 @@ export type LoggerEvent =
25
| {
26
+event_name: 'selected-profiler-tab',
27
}
28
+ | {
29
+ +event_name: 'selected-suspense-tab',
30
+ }
31
| {
32
+event_name: 'load-hook-names',
33
+event_status: 'success' | 'error' | 'timeout' | 'unknown',
packages/react-devtools-shared/src/backend/agent.js
+10
@@ -710,6 +710,16 @@ export default class Agent extends EventEmitter<{
710
711
rendererInterface.setTraceUpdatesEnabled(this._traceUpdatesEnabled);
712
713
+ const renderer = rendererInterface.renderer;
714
+ if (renderer !== null) {
715
+ const devRenderer = renderer.bundleType === 1;
716
+ const enableSuspenseTab =
717
+ devRenderer && renderer.version.includes('-experimental-');
718
+ if (enableSuspenseTab) {
719
+ this._bridge.send('enableSuspenseTab');
720
+ }
721
+ }
722
+
723
// When the renderer is attached, we need to tell it whether
724
// we remember the previous selection that we'd like to restore.
725
// It'll start tracking mounts for matches to the last selection path.
packages/react-devtools-shared/src/bridge.js
+1
@@ -178,6 +178,7 @@ export type BackendEvents = {
178
backendInitialized: [],
179
backendVersion: [string],
180
bridgeProtocol: [BridgeProtocol],
181
+ enableSuspenseTab: [],
182
extensionBackendInitialized: [],
183
fastRefreshScheduled: [],
184
getSavedPreferences: [],
packages/react-devtools-shared/src/devtools/store.js
+13
@@ -95,6 +95,7 @@ export default class Store extends EventEmitter<{
95
backendVersion: [],
96
collapseNodesByDefault: [],
97
componentFilters: [],
98
+ enableSuspenseTab: [],
99
error: [Error],
100
hookSettings: [$ReadOnly<DevToolsHookSettings>],
101
hostInstanceSelected: [Element['id']],
@@ -172,6 +173,8 @@ export default class Store extends EventEmitter<{
173
_supportsClickToInspect: boolean = false;
174
_supportsTimeline: boolean = false;
175
_supportsTraceUpdates: boolean = false;
176
+ // Dynamically set if the renderer supports the Suspense tab.
177
+ _supportsSuspenseTab: boolean = false;
178
179
_isReloadAndProfileFrontendSupported: boolean = false;
180
_isReloadAndProfileBackendSupported: boolean = false;
@@ -275,6 +278,7 @@ export default class Store extends EventEmitter<{
278
bridge.addListener('hookSettings', this.onHookSettings);
279
bridge.addListener('backendInitialized', this.onBackendInitialized);
280
bridge.addListener('selectElement', this.onHostInstanceSelected);
281
+ bridge.addListener('enableSuspenseTab', this.onEnableSuspenseTab);
282
}
283
284
// This is only used in tests to avoid memory leaks.
@@ -1624,6 +1628,15 @@ export default class Store extends EventEmitter<{
1628
}
1629
}
1630
1631
+ get supportsSuspenseTab(): boolean {
1632
+ return this._supportsSuspenseTab;
1633
+ }
1634
+
1635
+ onEnableSuspenseTab = (): void => {
1636
+ this._supportsSuspenseTab = true;
1637
+ this.emit('enableSuspenseTab');
1638
+ };
1639
+
1640
// The Store should never throw an Error without also emitting an event.
1641
// Otherwise Store errors will be invisible to users,
1642
// but the downstream errors they cause will be reported as bugs.
packages/react-devtools-shared/src/devtools/views/DevTools.js
+45
-3
@@ -23,6 +23,7 @@ import {
23
} from './context';
24
import Components from './Components/Components';
25
import Profiler from './Profiler/Profiler';
26
+import SuspenseTab from './SuspenseTab/SuspenseTab';
27
import TabBar from './TabBar';
28
import EditorPane from './Editor/EditorPane';
29
import {SettingsContextController} from './Settings/SettingsContext';
@@ -54,7 +55,7 @@ import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types';
55
import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
56
import type {SourceSelection} from './Editor/EditorPane';
57
57
-export type TabID = 'components' | 'profiler';
58
+export type TabID = 'components' | 'profiler' | 'suspense';
59
60
export type ViewElementSource = (
61
source: ReactFunctionLocation | ReactCallSite,
@@ -99,7 +100,9 @@ export type Props = {
100
// but individual tabs (e.g. Components, Profiling) can be rendered into portals within their browser panels.
101
componentsPortalContainer?: Element,
102
profilerPortalContainer?: Element,
103
+ suspensePortalContainer?: Element,
104
editorPortalContainer?: Element,
105
+
106
currentSelectedSource?: null | SourceSelection,
107
108
// Loads and parses source maps for function components
@@ -122,16 +125,37 @@ const profilerTab = {
125
label: 'Profiler',
126
title: 'React Profiler',
127
};
128
+const suspenseTab = {
129
+ id: ('suspense': TabID),
130
+ icon: 'suspense',
131
+ label: 'Suspense',
132
+ title: 'React Suspense',
133
+};
134
126
-const tabs = [componentsTab, profilerTab];
135
+const defaultTabs = [componentsTab, profilerTab];
136
+const tabsWithSuspense = [componentsTab, profilerTab, suspenseTab];
137
+
138
+function useIsSuspenseTabEnabled(store: Store): boolean {
139
+ const subscribe = useCallback(
140
+ (onStoreChange: () => void) => {
141
+ store.addListener('enableSuspenseTab', onStoreChange);
142
+ return () => {
143
+ store.removeListener('enableSuspenseTab', onStoreChange);
144
+ };
145
+ },
146
+ [store],
147
+ );
148
+ return React.useSyncExternalStore(subscribe, () => store.supportsSuspenseTab);
149
+}
150
151
export default function DevTools({
152
bridge,
153
browserTheme = 'light',
154
canViewElementSourceFunction,
155
componentsPortalContainer,
133
- profilerPortalContainer,
156
editorPortalContainer,
157
+ profilerPortalContainer,
158
+ suspensePortalContainer,
159
currentSelectedSource,
160
defaultTab = 'components',
161
enabledInspectedElementContextMenu = false,
@@ -155,6 +179,8 @@ export default function DevTools({
179
LOCAL_STORAGE_DEFAULT_TAB_KEY,
180
defaultTab,
181
);
182
+ const enableSuspenseTab = useIsSuspenseTabEnabled(store);
183
+ const tabs = enableSuspenseTab ? tabsWithSuspense : defaultTabs;
184
185
let tab = currentTab;
186
@@ -171,6 +197,8 @@ export default function DevTools({
197
if (showTabBar === true) {
198
if (tabId === 'components') {
199
logEvent({event_name: 'selected-components-tab'});
200
+ } else if (tabId === 'suspense') {
201
+ logEvent({event_name: 'selected-suspense-tab'});
202
} else {
203
logEvent({event_name: 'selected-profiler-tab'});
204
}
@@ -241,6 +269,13 @@ export default function DevTools({
269
event.preventDefault();
270
event.stopPropagation();
271
break;
272
+ case '3':
273
+ if (tabs.length > 2) {
274
+ selectTab(tabs[2].id);
275
+ event.preventDefault();
276
+ event.stopPropagation();
277
+ }
278
+ break;
279
}
280
}
281
};
@@ -321,6 +356,13 @@ export default function DevTools({
356
portalContainer={profilerPortalContainer}
357
/>
358
</div>
359
+ <div
360
+ className={styles.TabContent}
361
+ hidden={tab !== 'suspense'}>
362
+ <SuspenseTab
363
+ portalContainer={suspensePortalContainer}
364
+ />
365
+ </div>
366
</div>
367
{editorPortalContainer ? (
368
<EditorPane
packages/react-devtools-shared/src/devtools/views/Icon.js
+12
-1
@@ -26,6 +26,7 @@ export type IconType =
26
| 'settings'
27
| 'store-as-global-variable'
28
| 'strict-mode-non-compliant'
29
+ | 'suspense'
30
| 'warning';
31
32
type Props = {
@@ -40,6 +41,7 @@ export default function Icon({
41
type,
42
}: Props): React.Node {
43
let pathData = null;
44
+ let viewBox = '0 0 24 24';
45
switch (type) {
46
case 'arrow':
47
pathData = PATH_ARROW;
@@ -86,6 +88,10 @@ export default function Icon({
88
case 'strict-mode-non-compliant':
89
pathData = PATH_STRICT_MODE_NON_COMPLIANT;
90
break;
91
+ case 'suspense':
92
+ pathData = PATH_SUSPEND;
93
+ viewBox = '-2 -2 28 28';
94
+ break;
95
case 'warning':
96
pathData = PATH_WARNING;
97
break;
@@ -100,7 +106,7 @@ export default function Icon({
106
className={`${styles.Icon} ${className}`}
107
width="24"
108
height="24"
103
- viewBox="0 0 24 24">
109
+ viewBox={viewBox}>
110
{title && <title>{title}</title>}
111
<path d="M0 0h24v24H0z" fill="none" />
112
<path fill="currentColor" d={pathData} />
@@ -185,4 +191,9 @@ const PATH_STRICT_MODE_NON_COMPLIANT = `
191
14c-.55 0-1-.45-1-1v-2c0-.55.45-1 1-1s1 .45 1 1v2c0 .55-.45 1-1 1zm1 4h-2v-2h2v2z
192
`;
193
194
+const PATH_SUSPEND = `
195
+ M15 1H9v2h6V1zm-4 13h2V8h-2v6zm8.03-6.61l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42C16.07 4.74 14.12 4 12 4c-4.97
196
+ 0-9 4.03-9 9s4.02 9 9 9 9-4.03 9-9c0-2.12-.74-4.07-1.97-5.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z
197
+`;
198
+
199
const PATH_WARNING = `M12 1l-12 22h24l-12-22zm-1 8h2v7h-2v-7zm1 11.25c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25z`;
packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js
+13
-1
@@ -83,6 +83,7 @@ type Props = {
83
children: React$Node,
84
componentsPortalContainer?: Element,
85
profilerPortalContainer?: Element,
86
+ suspensePortalContainer?: Element,
87
};
88
89
function SettingsContextController({
@@ -90,6 +91,7 @@ function SettingsContextController({
91
children,
92
componentsPortalContainer,
93
profilerPortalContainer,
94
+ suspensePortalContainer,
95
}: Props): React.Node {
96
const bridge = useContext(BridgeContext);
97
@@ -128,8 +130,18 @@ function SettingsContextController({
130
.documentElement: any): HTMLElement),
131
);
132
}
133
+ if (suspensePortalContainer != null) {
134
+ array.push(
135
+ ((suspensePortalContainer.ownerDocument
136
+ .documentElement: any): HTMLElement),
137
+ );
138
+ }
139
return array;
132
- }, [componentsPortalContainer, profilerPortalContainer]);
140
+ }, [
141
+ componentsPortalContainer,
142
+ profilerPortalContainer,
143
+ suspensePortalContainer,
144
+ ]);
145
146
useLayoutEffect(() => {
147
switch (displayDensity) {
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
new
+8
@@ -0,0 +1,8 @@
1
+import * as React from 'react';
2
+import portaledContent from '../portaledContent';
3
+
4
+function SuspenseTab() {
5
+ return 'Under construction';
6
+}
7
+
8
+export default (portaledContent(SuspenseTab): React.ComponentType<{}>);