diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js
index f21bdc7a9a9..362793e3eb4 100644
--- a/packages/react-devtools-extensions/src/main/index.js
+++ b/packages/react-devtools-extensions/src/main/index.js
@@ -151,6 +151,10 @@ function createBridgeAndStore() {
supportsClickToInspect: true,
});
+ store.addListener('enableSuspenseTab', () => {
+ createSuspensePanel();
+ });
+
store.addListener('settingsUpdated', settings => {
chrome.storage.local.set(settings);
});
@@ -209,6 +213,7 @@ function createBridgeAndStore() {
overrideTab,
showTabBar: false,
store,
+ suspensePortalContainer,
warnIfUnsupportedVersionDetected: true,
viewAttributeSourceFunction,
// Firefox doesn't support chrome.devtools.panels.openResource yet
@@ -354,6 +359,42 @@ function createSourcesEditorPanel() {
});
}
+function createSuspensePanel() {
+ if (suspensePortalContainer) {
+ // Panel is created and user opened it at least once
+ ensureInitialHTMLIsCleared(suspensePortalContainer);
+ render('suspense');
+
+ return;
+ }
+
+ if (suspensePanel) {
+ // Panel is created, but wasn't opened yet, so no document is present for it
+ return;
+ }
+
+ chrome.devtools.panels.create(
+ __IS_CHROME__ || __IS_EDGE__ ? 'Suspense ⚛' : 'Suspense',
+ __IS_EDGE__ ? 'icons/production.svg' : '',
+ 'panel.html',
+ createdPanel => {
+ suspensePanel = createdPanel;
+
+ createdPanel.onShown.addListener(portal => {
+ suspensePortalContainer = portal.container;
+ if (suspensePortalContainer != null && render) {
+ ensureInitialHTMLIsCleared(suspensePortalContainer);
+
+ render('suspense');
+ portal.injectStyles(cloneStyleTags);
+
+ logEvent({event_name: 'selected-suspense-tab'});
+ }
+ });
+ },
+ );
+}
+
function performInTabNavigationCleanup() {
// Potentially, if react hasn't loaded yet and user performs in-tab navigation
clearReactPollingInstance();
@@ -365,7 +406,12 @@ function performInTabNavigationCleanup() {
// If panels were already created, and we have already mounted React root to display
// tabs (Components or Profiler), we should unmount root first and render them again
- if ((componentsPortalContainer || profilerPortalContainer) && root) {
+ if (
+ (componentsPortalContainer ||
+ profilerPortalContainer ||
+ suspensePortalContainer) &&
+ root
+ ) {
// It's easiest to recreate the DevTools panel (to clean up potential stale state).
// We can revisit this in the future as a small optimization.
// This should also emit bridge.shutdown, but only if this root was mounted
@@ -395,7 +441,12 @@ function performFullCleanup() {
// Potentially, if react hasn't loaded yet and user closed the browser DevTools
clearReactPollingInstance();
- if ((componentsPortalContainer || profilerPortalContainer) && root) {
+ if (
+ (componentsPortalContainer ||
+ profilerPortalContainer ||
+ suspensePortalContainer) &&
+ root
+ ) {
// This should also emit bridge.shutdown, but only if this root was mounted
flushSync(() => root.unmount());
} else {
@@ -404,6 +455,7 @@ function performFullCleanup() {
componentsPortalContainer = null;
profilerPortalContainer = null;
+ suspensePortalContainer = null;
root = null;
mostRecentOverrideTab = null;
@@ -454,6 +506,8 @@ function mountReactDevTools() {
createComponentsPanel();
createProfilerPanel();
createSourcesEditorPanel();
+ // Suspense Tab is created via the hook
+ // TODO(enableSuspenseTab): Create eagerly once Suspense tab is stable
}
let reactPollingInstance = null;
@@ -474,6 +528,12 @@ function showNoReactDisclaimer() {
'
Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.
';
delete profilerPortalContainer._hasInitialHTMLBeenCleared;
}
+
+ if (suspensePortalContainer) {
+ suspensePortalContainer.innerHTML =
+ 'Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.
';
+ delete suspensePortalContainer._hasInitialHTMLBeenCleared;
+ }
}
function mountReactDevToolsWhenReactHasLoaded() {
@@ -492,9 +552,11 @@ let profilingData = null;
let componentsPanel = null;
let profilerPanel = null;
+let suspensePanel = null;
let editorPane = null;
let componentsPortalContainer = null;
let profilerPortalContainer = null;
+let suspensePortalContainer = null;
let editorPortalContainer = null;
let mostRecentOverrideTab = null;
diff --git a/packages/react-devtools-shared/src/Logger.js b/packages/react-devtools-shared/src/Logger.js
index d37a33cf1c7..dd9dfb62025 100644
--- a/packages/react-devtools-shared/src/Logger.js
+++ b/packages/react-devtools-shared/src/Logger.js
@@ -25,6 +25,9 @@ export type LoggerEvent =
| {
+event_name: 'selected-profiler-tab',
}
+ | {
+ +event_name: 'selected-suspense-tab',
+ }
| {
+event_name: 'load-hook-names',
+event_status: 'success' | 'error' | 'timeout' | 'unknown',
diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js
index e883724f497..1ae7f5dfb11 100644
--- a/packages/react-devtools-shared/src/backend/agent.js
+++ b/packages/react-devtools-shared/src/backend/agent.js
@@ -710,6 +710,16 @@ export default class Agent extends EventEmitter<{
rendererInterface.setTraceUpdatesEnabled(this._traceUpdatesEnabled);
+ const renderer = rendererInterface.renderer;
+ if (renderer !== null) {
+ const devRenderer = renderer.bundleType === 1;
+ const enableSuspenseTab =
+ devRenderer && renderer.version.includes('-experimental-');
+ if (enableSuspenseTab) {
+ this._bridge.send('enableSuspenseTab');
+ }
+ }
+
// When the renderer is attached, we need to tell it whether
// we remember the previous selection that we'd like to restore.
// It'll start tracking mounts for matches to the last selection path.
diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js
index 3a12ae74150..f0638ae896b 100644
--- a/packages/react-devtools-shared/src/bridge.js
+++ b/packages/react-devtools-shared/src/bridge.js
@@ -178,6 +178,7 @@ export type BackendEvents = {
backendInitialized: [],
backendVersion: [string],
bridgeProtocol: [BridgeProtocol],
+ enableSuspenseTab: [],
extensionBackendInitialized: [],
fastRefreshScheduled: [],
getSavedPreferences: [],
diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js
index 97c3adc88f0..3035c0ae4ad 100644
--- a/packages/react-devtools-shared/src/devtools/store.js
+++ b/packages/react-devtools-shared/src/devtools/store.js
@@ -95,6 +95,7 @@ export default class Store extends EventEmitter<{
backendVersion: [],
collapseNodesByDefault: [],
componentFilters: [],
+ enableSuspenseTab: [],
error: [Error],
hookSettings: [$ReadOnly],
hostInstanceSelected: [Element['id']],
@@ -172,6 +173,8 @@ export default class Store extends EventEmitter<{
_supportsClickToInspect: boolean = false;
_supportsTimeline: boolean = false;
_supportsTraceUpdates: boolean = false;
+ // Dynamically set if the renderer supports the Suspense tab.
+ _supportsSuspenseTab: boolean = false;
_isReloadAndProfileFrontendSupported: boolean = false;
_isReloadAndProfileBackendSupported: boolean = false;
@@ -275,6 +278,7 @@ export default class Store extends EventEmitter<{
bridge.addListener('hookSettings', this.onHookSettings);
bridge.addListener('backendInitialized', this.onBackendInitialized);
bridge.addListener('selectElement', this.onHostInstanceSelected);
+ bridge.addListener('enableSuspenseTab', this.onEnableSuspenseTab);
}
// This is only used in tests to avoid memory leaks.
@@ -1624,6 +1628,15 @@ export default class Store extends EventEmitter<{
}
}
+ get supportsSuspenseTab(): boolean {
+ return this._supportsSuspenseTab;
+ }
+
+ onEnableSuspenseTab = (): void => {
+ this._supportsSuspenseTab = true;
+ this.emit('enableSuspenseTab');
+ };
+
// The Store should never throw an Error without also emitting an event.
// Otherwise Store errors will be invisible to users,
// but the downstream errors they cause will be reported as bugs.
diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js
index 0fe6293b9f2..fa02555e4ca 100644
--- a/packages/react-devtools-shared/src/devtools/views/DevTools.js
+++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js
@@ -23,6 +23,7 @@ import {
} from './context';
import Components from './Components/Components';
import Profiler from './Profiler/Profiler';
+import SuspenseTab from './SuspenseTab/SuspenseTab';
import TabBar from './TabBar';
import EditorPane from './Editor/EditorPane';
import {SettingsContextController} from './Settings/SettingsContext';
@@ -54,7 +55,7 @@ import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types';
import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
import type {SourceSelection} from './Editor/EditorPane';
-export type TabID = 'components' | 'profiler';
+export type TabID = 'components' | 'profiler' | 'suspense';
export type ViewElementSource = (
source: ReactFunctionLocation | ReactCallSite,
@@ -99,7 +100,9 @@ export type Props = {
// but individual tabs (e.g. Components, Profiling) can be rendered into portals within their browser panels.
componentsPortalContainer?: Element,
profilerPortalContainer?: Element,
+ suspensePortalContainer?: Element,
editorPortalContainer?: Element,
+
currentSelectedSource?: null | SourceSelection,
// Loads and parses source maps for function components
@@ -122,16 +125,37 @@ const profilerTab = {
label: 'Profiler',
title: 'React Profiler',
};
+const suspenseTab = {
+ id: ('suspense': TabID),
+ icon: 'suspense',
+ label: 'Suspense',
+ title: 'React Suspense',
+};
-const tabs = [componentsTab, profilerTab];
+const defaultTabs = [componentsTab, profilerTab];
+const tabsWithSuspense = [componentsTab, profilerTab, suspenseTab];
+
+function useIsSuspenseTabEnabled(store: Store): boolean {
+ const subscribe = useCallback(
+ (onStoreChange: () => void) => {
+ store.addListener('enableSuspenseTab', onStoreChange);
+ return () => {
+ store.removeListener('enableSuspenseTab', onStoreChange);
+ };
+ },
+ [store],
+ );
+ return React.useSyncExternalStore(subscribe, () => store.supportsSuspenseTab);
+}
export default function DevTools({
bridge,
browserTheme = 'light',
canViewElementSourceFunction,
componentsPortalContainer,
- profilerPortalContainer,
editorPortalContainer,
+ profilerPortalContainer,
+ suspensePortalContainer,
currentSelectedSource,
defaultTab = 'components',
enabledInspectedElementContextMenu = false,
@@ -155,6 +179,8 @@ export default function DevTools({
LOCAL_STORAGE_DEFAULT_TAB_KEY,
defaultTab,
);
+ const enableSuspenseTab = useIsSuspenseTabEnabled(store);
+ const tabs = enableSuspenseTab ? tabsWithSuspense : defaultTabs;
let tab = currentTab;
@@ -171,6 +197,8 @@ export default function DevTools({
if (showTabBar === true) {
if (tabId === 'components') {
logEvent({event_name: 'selected-components-tab'});
+ } else if (tabId === 'suspense') {
+ logEvent({event_name: 'selected-suspense-tab'});
} else {
logEvent({event_name: 'selected-profiler-tab'});
}
@@ -241,6 +269,13 @@ export default function DevTools({
event.preventDefault();
event.stopPropagation();
break;
+ case '3':
+ if (tabs.length > 2) {
+ selectTab(tabs[2].id);
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ break;
}
}
};
@@ -321,6 +356,13 @@ export default function DevTools({
portalContainer={profilerPortalContainer}
/>
+
+
+
{editorPortalContainer ? (
+ viewBox={viewBox}>
{title && {title}}
@@ -185,4 +191,9 @@ const PATH_STRICT_MODE_NON_COMPLIANT = `
14c-.55 0-1-.45-1-1v-2c0-.55.45-1 1-1s1 .45 1 1v2c0 .55-.45 1-1 1zm1 4h-2v-2h2v2z
`;
+const PATH_SUSPEND = `
+ 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
+ 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
+`;
+
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`;
diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js
index 196ea806f6a..c20249e8994 100644
--- a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js
+++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js
@@ -83,6 +83,7 @@ type Props = {
children: React$Node,
componentsPortalContainer?: Element,
profilerPortalContainer?: Element,
+ suspensePortalContainer?: Element,
};
function SettingsContextController({
@@ -90,6 +91,7 @@ function SettingsContextController({
children,
componentsPortalContainer,
profilerPortalContainer,
+ suspensePortalContainer,
}: Props): React.Node {
const bridge = useContext(BridgeContext);
@@ -128,8 +130,18 @@ function SettingsContextController({
.documentElement: any): HTMLElement),
);
}
+ if (suspensePortalContainer != null) {
+ array.push(
+ ((suspensePortalContainer.ownerDocument
+ .documentElement: any): HTMLElement),
+ );
+ }
return array;
- }, [componentsPortalContainer, profilerPortalContainer]);
+ }, [
+ componentsPortalContainer,
+ profilerPortalContainer,
+ suspensePortalContainer,
+ ]);
useLayoutEffect(() => {
switch (displayDensity) {
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
new file mode 100644
index 00000000000..01d11b3d7e1
--- /dev/null
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
@@ -0,0 +1,8 @@
+import * as React from 'react';
+import portaledContent from '../portaledContent';
+
+function SuspenseTab() {
+ return 'Under construction';
+}
+
+export default (portaledContent(SuspenseTab): React.ComponentType<{}>);