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