@samitouri / QOS-React / commits / ab2681af03

[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<{}>);