Skip to content

Commit ab2681a

Browse files
authored
[DevTools] Skeleton for Suspense tab (facebook#34020)
1 parent 101b20b commit ab2681a

File tree

9 files changed

+169
-7
lines changed

9 files changed

+169
-7
lines changed

packages/react-devtools-extensions/src/main/index.js

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ function createBridgeAndStore() {
151151
supportsClickToInspect: true,
152152
});
153153

154+
store.addListener('enableSuspenseTab', () => {
155+
createSuspensePanel();
156+
});
157+
154158
store.addListener('settingsUpdated', settings => {
155159
chrome.storage.local.set(settings);
156160
});
@@ -209,6 +213,7 @@ function createBridgeAndStore() {
209213
overrideTab,
210214
showTabBar: false,
211215
store,
216+
suspensePortalContainer,
212217
warnIfUnsupportedVersionDetected: true,
213218
viewAttributeSourceFunction,
214219
// Firefox doesn't support chrome.devtools.panels.openResource yet
@@ -354,6 +359,42 @@ function createSourcesEditorPanel() {
354359
});
355360
}
356361

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+
357398
function performInTabNavigationCleanup() {
358399
// Potentially, if react hasn't loaded yet and user performs in-tab navigation
359400
clearReactPollingInstance();
@@ -365,7 +406,12 @@ function performInTabNavigationCleanup() {
365406

366407
// If panels were already created, and we have already mounted React root to display
367408
// 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+
) {
369415
// It's easiest to recreate the DevTools panel (to clean up potential stale state).
370416
// We can revisit this in the future as a small optimization.
371417
// This should also emit bridge.shutdown, but only if this root was mounted
@@ -395,7 +441,12 @@ function performFullCleanup() {
395441
// Potentially, if react hasn't loaded yet and user closed the browser DevTools
396442
clearReactPollingInstance();
397443

398-
if ((componentsPortalContainer || profilerPortalContainer) && root) {
444+
if (
445+
(componentsPortalContainer ||
446+
profilerPortalContainer ||
447+
suspensePortalContainer) &&
448+
root
449+
) {
399450
// This should also emit bridge.shutdown, but only if this root was mounted
400451
flushSync(() => root.unmount());
401452
} else {
@@ -404,6 +455,7 @@ function performFullCleanup() {
404455

405456
componentsPortalContainer = null;
406457
profilerPortalContainer = null;
458+
suspensePortalContainer = null;
407459
root = null;
408460

409461
mostRecentOverrideTab = null;
@@ -454,6 +506,8 @@ function mountReactDevTools() {
454506
createComponentsPanel();
455507
createProfilerPanel();
456508
createSourcesEditorPanel();
509+
// Suspense Tab is created via the hook
510+
// TODO(enableSuspenseTab): Create eagerly once Suspense tab is stable
457511
}
458512

459513
let reactPollingInstance = null;
@@ -474,6 +528,12 @@ function showNoReactDisclaimer() {
474528
'<h1 class="no-react-disclaimer">Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.</h1>';
475529
delete profilerPortalContainer._hasInitialHTMLBeenCleared;
476530
}
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+
}
477537
}
478538

479539
function mountReactDevToolsWhenReactHasLoaded() {
@@ -492,9 +552,11 @@ let profilingData = null;
492552

493553
let componentsPanel = null;
494554
let profilerPanel = null;
555+
let suspensePanel = null;
495556
let editorPane = null;
496557
let componentsPortalContainer = null;
497558
let profilerPortalContainer = null;
559+
let suspensePortalContainer = null;
498560
let editorPortalContainer = null;
499561

500562
let mostRecentOverrideTab = null;

packages/react-devtools-shared/src/Logger.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export type LoggerEvent =
2525
| {
2626
+event_name: 'selected-profiler-tab',
2727
}
28+
| {
29+
+event_name: 'selected-suspense-tab',
30+
}
2831
| {
2932
+event_name: 'load-hook-names',
3033
+event_status: 'success' | 'error' | 'timeout' | 'unknown',

packages/react-devtools-shared/src/backend/agent.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,16 @@ export default class Agent extends EventEmitter<{
710710

711711
rendererInterface.setTraceUpdatesEnabled(this._traceUpdatesEnabled);
712712

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+
713723
// When the renderer is attached, we need to tell it whether
714724
// we remember the previous selection that we'd like to restore.
715725
// It'll start tracking mounts for matches to the last selection path.

packages/react-devtools-shared/src/bridge.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ export type BackendEvents = {
178178
backendInitialized: [],
179179
backendVersion: [string],
180180
bridgeProtocol: [BridgeProtocol],
181+
enableSuspenseTab: [],
181182
extensionBackendInitialized: [],
182183
fastRefreshScheduled: [],
183184
getSavedPreferences: [],

packages/react-devtools-shared/src/devtools/store.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export default class Store extends EventEmitter<{
9595
backendVersion: [],
9696
collapseNodesByDefault: [],
9797
componentFilters: [],
98+
enableSuspenseTab: [],
9899
error: [Error],
99100
hookSettings: [$ReadOnly<DevToolsHookSettings>],
100101
hostInstanceSelected: [Element['id']],
@@ -172,6 +173,8 @@ export default class Store extends EventEmitter<{
172173
_supportsClickToInspect: boolean = false;
173174
_supportsTimeline: boolean = false;
174175
_supportsTraceUpdates: boolean = false;
176+
// Dynamically set if the renderer supports the Suspense tab.
177+
_supportsSuspenseTab: boolean = false;
175178

176179
_isReloadAndProfileFrontendSupported: boolean = false;
177180
_isReloadAndProfileBackendSupported: boolean = false;
@@ -275,6 +278,7 @@ export default class Store extends EventEmitter<{
275278
bridge.addListener('hookSettings', this.onHookSettings);
276279
bridge.addListener('backendInitialized', this.onBackendInitialized);
277280
bridge.addListener('selectElement', this.onHostInstanceSelected);
281+
bridge.addListener('enableSuspenseTab', this.onEnableSuspenseTab);
278282
}
279283

280284
// This is only used in tests to avoid memory leaks.
@@ -1624,6 +1628,15 @@ export default class Store extends EventEmitter<{
16241628
}
16251629
}
16261630

1631+
get supportsSuspenseTab(): boolean {
1632+
return this._supportsSuspenseTab;
1633+
}
1634+
1635+
onEnableSuspenseTab = (): void => {
1636+
this._supportsSuspenseTab = true;
1637+
this.emit('enableSuspenseTab');
1638+
};
1639+
16271640
// The Store should never throw an Error without also emitting an event.
16281641
// Otherwise Store errors will be invisible to users,
16291642
// but the downstream errors they cause will be reported as bugs.

packages/react-devtools-shared/src/devtools/views/DevTools.js

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from './context';
2424
import Components from './Components/Components';
2525
import Profiler from './Profiler/Profiler';
26+
import SuspenseTab from './SuspenseTab/SuspenseTab';
2627
import TabBar from './TabBar';
2728
import EditorPane from './Editor/EditorPane';
2829
import {SettingsContextController} from './Settings/SettingsContext';
@@ -54,7 +55,7 @@ import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types';
5455
import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
5556
import type {SourceSelection} from './Editor/EditorPane';
5657

57-
export type TabID = 'components' | 'profiler';
58+
export type TabID = 'components' | 'profiler' | 'suspense';
5859

5960
export type ViewElementSource = (
6061
source: ReactFunctionLocation | ReactCallSite,
@@ -99,7 +100,9 @@ export type Props = {
99100
// but individual tabs (e.g. Components, Profiling) can be rendered into portals within their browser panels.
100101
componentsPortalContainer?: Element,
101102
profilerPortalContainer?: Element,
103+
suspensePortalContainer?: Element,
102104
editorPortalContainer?: Element,
105+
103106
currentSelectedSource?: null | SourceSelection,
104107

105108
// Loads and parses source maps for function components
@@ -122,16 +125,37 @@ const profilerTab = {
122125
label: 'Profiler',
123126
title: 'React Profiler',
124127
};
128+
const suspenseTab = {
129+
id: ('suspense': TabID),
130+
icon: 'suspense',
131+
label: 'Suspense',
132+
title: 'React Suspense',
133+
};
125134

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+
}
127150

128151
export default function DevTools({
129152
bridge,
130153
browserTheme = 'light',
131154
canViewElementSourceFunction,
132155
componentsPortalContainer,
133-
profilerPortalContainer,
134156
editorPortalContainer,
157+
profilerPortalContainer,
158+
suspensePortalContainer,
135159
currentSelectedSource,
136160
defaultTab = 'components',
137161
enabledInspectedElementContextMenu = false,
@@ -155,6 +179,8 @@ export default function DevTools({
155179
LOCAL_STORAGE_DEFAULT_TAB_KEY,
156180
defaultTab,
157181
);
182+
const enableSuspenseTab = useIsSuspenseTabEnabled(store);
183+
const tabs = enableSuspenseTab ? tabsWithSuspense : defaultTabs;
158184

159185
let tab = currentTab;
160186

@@ -171,6 +197,8 @@ export default function DevTools({
171197
if (showTabBar === true) {
172198
if (tabId === 'components') {
173199
logEvent({event_name: 'selected-components-tab'});
200+
} else if (tabId === 'suspense') {
201+
logEvent({event_name: 'selected-suspense-tab'});
174202
} else {
175203
logEvent({event_name: 'selected-profiler-tab'});
176204
}
@@ -241,6 +269,13 @@ export default function DevTools({
241269
event.preventDefault();
242270
event.stopPropagation();
243271
break;
272+
case '3':
273+
if (tabs.length > 2) {
274+
selectTab(tabs[2].id);
275+
event.preventDefault();
276+
event.stopPropagation();
277+
}
278+
break;
244279
}
245280
}
246281
};
@@ -321,6 +356,13 @@ export default function DevTools({
321356
portalContainer={profilerPortalContainer}
322357
/>
323358
</div>
359+
<div
360+
className={styles.TabContent}
361+
hidden={tab !== 'suspense'}>
362+
<SuspenseTab
363+
portalContainer={suspensePortalContainer}
364+
/>
365+
</div>
324366
</div>
325367
{editorPortalContainer ? (
326368
<EditorPane

packages/react-devtools-shared/src/devtools/views/Icon.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type IconType =
2626
| 'settings'
2727
| 'store-as-global-variable'
2828
| 'strict-mode-non-compliant'
29+
| 'suspense'
2930
| 'warning';
3031

3132
type Props = {
@@ -40,6 +41,7 @@ export default function Icon({
4041
type,
4142
}: Props): React.Node {
4243
let pathData = null;
44+
let viewBox = '0 0 24 24';
4345
switch (type) {
4446
case 'arrow':
4547
pathData = PATH_ARROW;
@@ -86,6 +88,10 @@ export default function Icon({
8688
case 'strict-mode-non-compliant':
8789
pathData = PATH_STRICT_MODE_NON_COMPLIANT;
8890
break;
91+
case 'suspense':
92+
pathData = PATH_SUSPEND;
93+
viewBox = '-2 -2 28 28';
94+
break;
8995
case 'warning':
9096
pathData = PATH_WARNING;
9197
break;
@@ -100,7 +106,7 @@ export default function Icon({
100106
className={`${styles.Icon} ${className}`}
101107
width="24"
102108
height="24"
103-
viewBox="0 0 24 24">
109+
viewBox={viewBox}>
104110
{title && <title>{title}</title>}
105111
<path d="M0 0h24v24H0z" fill="none" />
106112
<path fill="currentColor" d={pathData} />
@@ -185,4 +191,9 @@ const PATH_STRICT_MODE_NON_COMPLIANT = `
185191
14c-.55 0-1-.45-1-1v-2c0-.55.45-1 1-1s1 .45 1 1v2c0 .55-.45 1-1 1zm1 4h-2v-2h2v2z
186192
`;
187193

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+
188199
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`;

0 commit comments

Comments
 (0)