Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions packages/react-devtools-extensions/src/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ function createBridgeAndStore() {
supportsClickToInspect: true,
});

store.addListener('enableSuspenseTab', () => {
createSuspensePanel();
});

store.addListener('settingsUpdated', settings => {
chrome.storage.local.set(settings);
});
Expand Down Expand Up @@ -209,6 +213,7 @@ function createBridgeAndStore() {
overrideTab,
showTabBar: false,
store,
suspensePortalContainer,
warnIfUnsupportedVersionDetected: true,
viewAttributeSourceFunction,
// Firefox doesn't support chrome.devtools.panels.openResource yet
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -404,6 +455,7 @@ function performFullCleanup() {

componentsPortalContainer = null;
profilerPortalContainer = null;
suspensePortalContainer = null;
root = null;

mostRecentOverrideTab = null;
Expand Down Expand Up @@ -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;
Expand All @@ -474,6 +528,12 @@ function showNoReactDisclaimer() {
'<h1 class="no-react-disclaimer">Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.</h1>';
delete profilerPortalContainer._hasInitialHTMLBeenCleared;
}

if (suspensePortalContainer) {
suspensePortalContainer.innerHTML =
'<h1 class="no-react-disclaimer">Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.</h1>';
delete suspensePortalContainer._hasInitialHTMLBeenCleared;
}
}

function mountReactDevToolsWhenReactHasLoaded() {
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions packages/react-devtools-shared/src/Logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions packages/react-devtools-shared/src/backend/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/react-devtools-shared/src/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export type BackendEvents = {
backendInitialized: [],
backendVersion: [string],
bridgeProtocol: [BridgeProtocol],
enableSuspenseTab: [],
extensionBackendInitialized: [],
fastRefreshScheduled: [],
getSavedPreferences: [],
Expand Down
13 changes: 13 additions & 0 deletions packages/react-devtools-shared/src/devtools/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export default class Store extends EventEmitter<{
backendVersion: [],
collapseNodesByDefault: [],
componentFilters: [],
enableSuspenseTab: [],
error: [Error],
hookSettings: [$ReadOnly<DevToolsHookSettings>],
hostInstanceSelected: [Element['id']],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 45 additions & 3 deletions packages/react-devtools-shared/src/devtools/views/DevTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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;

Expand All @@ -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'});
}
Expand Down Expand Up @@ -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;
}
}
};
Expand Down Expand Up @@ -321,6 +356,13 @@ export default function DevTools({
portalContainer={profilerPortalContainer}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'suspense'}>
<SuspenseTab
portalContainer={suspensePortalContainer}
/>
</div>
</div>
{editorPortalContainer ? (
<EditorPane
Expand Down
13 changes: 12 additions & 1 deletion packages/react-devtools-shared/src/devtools/views/Icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type IconType =
| 'settings'
| 'store-as-global-variable'
| 'strict-mode-non-compliant'
| 'suspense'
| 'warning';

type Props = {
Expand All @@ -40,6 +41,7 @@ export default function Icon({
type,
}: Props): React.Node {
let pathData = null;
let viewBox = '0 0 24 24';
switch (type) {
case 'arrow':
pathData = PATH_ARROW;
Expand Down Expand Up @@ -86,6 +88,10 @@ export default function Icon({
case 'strict-mode-non-compliant':
pathData = PATH_STRICT_MODE_NON_COMPLIANT;
break;
case 'suspense':
pathData = PATH_SUSPEND;
viewBox = '-2 -2 28 28';
Comment on lines +92 to +93
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path from the suspend button in the Components tab (i.e. the clock). I had to adjust the viewbox to match the sizes of the other icons.

break;
case 'warning':
pathData = PATH_WARNING;
break;
Expand All @@ -100,7 +106,7 @@ export default function Icon({
className={`${styles.Icon} ${className}`}
width="24"
height="24"
viewBox="0 0 24 24">
viewBox={viewBox}>
{title && <title>{title}</title>}
<path d="M0 0h24v24H0z" fill="none" />
<path fill="currentColor" d={pathData} />
Expand Down Expand Up @@ -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`;
Loading
Loading