mirror of
https://github.com/facebook/react.git
synced 2026-02-24 04:33:04 +00:00
I broke Firefox DevTools extension in #33968. It turns out the Firefox has a placeholder object for the sources panel which is empty. We need to detect the actual event handler.
533 lines
16 KiB
JavaScript
533 lines
16 KiB
JavaScript
/* global chrome */
|
|
|
|
import type {SourceSelection} from 'react-devtools-shared/src/devtools/views/Editor/EditorPane';
|
|
|
|
import {createElement} from 'react';
|
|
import {flushSync} from 'react-dom';
|
|
import {createRoot} from 'react-dom/client';
|
|
import Bridge from 'react-devtools-shared/src/bridge';
|
|
import Store from 'react-devtools-shared/src/devtools/store';
|
|
import {getBrowserTheme} from '../utils';
|
|
import {
|
|
localStorageGetItem,
|
|
localStorageSetItem,
|
|
} from 'react-devtools-shared/src/storage';
|
|
import DevTools from 'react-devtools-shared/src/devtools/views/DevTools';
|
|
import {
|
|
LOCAL_STORAGE_SUPPORTS_PROFILING_KEY,
|
|
LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY,
|
|
} from 'react-devtools-shared/src/constants';
|
|
import {logEvent} from 'react-devtools-shared/src/Logger';
|
|
import {normalizeUrlIfValid} from 'react-devtools-shared/src/utils';
|
|
|
|
import {
|
|
setBrowserSelectionFromReact,
|
|
setReactSelectionFromBrowser,
|
|
} from './elementSelection';
|
|
import {viewAttributeSource} from './sourceSelection';
|
|
|
|
import {startReactPolling} from './reactPolling';
|
|
import cloneStyleTags from './cloneStyleTags';
|
|
import fetchFileWithCaching from './fetchFileWithCaching';
|
|
import injectBackendManager from './injectBackendManager';
|
|
import registerEventsLogger from './registerEventsLogger';
|
|
import getProfilingFlags from './getProfilingFlags';
|
|
import debounce from './debounce';
|
|
import './requestAnimationFramePolyfill';
|
|
|
|
function createBridge() {
|
|
bridge = new Bridge({
|
|
listen(fn) {
|
|
const bridgeListener = message => fn(message);
|
|
// Store the reference so that we unsubscribe from the same object.
|
|
const portOnMessage = port.onMessage;
|
|
portOnMessage.addListener(bridgeListener);
|
|
|
|
lastSubscribedBridgeListener = bridgeListener;
|
|
|
|
return () => {
|
|
port?.onMessage.removeListener(bridgeListener);
|
|
lastSubscribedBridgeListener = null;
|
|
};
|
|
},
|
|
|
|
send(event: string, payload: any, transferable?: Array<any>) {
|
|
port?.postMessage({event, payload}, transferable);
|
|
},
|
|
});
|
|
|
|
bridge.addListener('reloadAppForProfiling', () => {
|
|
localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true');
|
|
chrome.devtools.inspectedWindow.eval('window.location.reload();');
|
|
});
|
|
|
|
bridge.addListener(
|
|
'syncSelectionToBuiltinElementsPanel',
|
|
setBrowserSelectionFromReact,
|
|
);
|
|
|
|
bridge.addListener('extensionBackendInitialized', () => {
|
|
// Initialize the renderer's trace-updates setting.
|
|
// This handles the case of navigating to a new page after the DevTools have already been shown.
|
|
bridge.send(
|
|
'setTraceUpdatesEnabled',
|
|
localStorageGetItem(LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY) === 'true',
|
|
);
|
|
});
|
|
|
|
const sourcesPanel = chrome.devtools.panels.sources;
|
|
|
|
const onBrowserElementSelectionChanged = () =>
|
|
setReactSelectionFromBrowser(bridge);
|
|
const onBrowserSourceSelectionChanged = (location: {
|
|
url: string,
|
|
startLine: number,
|
|
startColumn: number,
|
|
endLine: number,
|
|
endColumn: number,
|
|
}) => {
|
|
if (
|
|
currentSelectedSource === null ||
|
|
currentSelectedSource.url !== location.url
|
|
) {
|
|
currentSelectedSource = {
|
|
url: location.url,
|
|
selectionRef: {
|
|
// We use 1-based line and column, Chrome provides them 0-based.
|
|
line: location.startLine + 1,
|
|
column: location.startColumn + 1,
|
|
},
|
|
};
|
|
// Rerender with the new file selection.
|
|
render();
|
|
} else {
|
|
// Update the ref to the latest position without updating the url. No need to rerender.
|
|
const selectionRef = currentSelectedSource.selectionRef;
|
|
selectionRef.line = location.startLine + 1;
|
|
selectionRef.column = location.startColumn + 1;
|
|
}
|
|
};
|
|
const onBridgeShutdown = () => {
|
|
chrome.devtools.panels.elements.onSelectionChanged.removeListener(
|
|
onBrowserElementSelectionChanged,
|
|
);
|
|
if (sourcesPanel && sourcesPanel.onSelectionChanged) {
|
|
currentSelectedSource = null;
|
|
sourcesPanel.onSelectionChanged.removeListener(
|
|
onBrowserSourceSelectionChanged,
|
|
);
|
|
}
|
|
};
|
|
|
|
bridge.addListener('shutdown', onBridgeShutdown);
|
|
|
|
chrome.devtools.panels.elements.onSelectionChanged.addListener(
|
|
onBrowserElementSelectionChanged,
|
|
);
|
|
if (sourcesPanel && sourcesPanel.onSelectionChanged) {
|
|
sourcesPanel.onSelectionChanged.addListener(
|
|
onBrowserSourceSelectionChanged,
|
|
);
|
|
}
|
|
}
|
|
|
|
function createBridgeAndStore() {
|
|
createBridge();
|
|
|
|
const {isProfiling} = getProfilingFlags();
|
|
|
|
store = new Store(bridge, {
|
|
isProfiling,
|
|
supportsReloadAndProfile: __IS_CHROME__ || __IS_EDGE__,
|
|
// At this time, the timeline can only parse Chrome performance profiles.
|
|
supportsTimeline: __IS_CHROME__,
|
|
supportsTraceUpdates: true,
|
|
supportsInspectMatchingDOMElement: true,
|
|
supportsClickToInspect: true,
|
|
});
|
|
|
|
store.addListener('settingsUpdated', settings => {
|
|
chrome.storage.local.set(settings);
|
|
});
|
|
|
|
if (!isProfiling) {
|
|
// We previously stored this in performCleanup function
|
|
store.profilerStore.profilingData = profilingData;
|
|
}
|
|
|
|
// Initialize the backend only once the Store has been initialized.
|
|
// Otherwise, the Store may miss important initial tree op codes.
|
|
injectBackendManager(chrome.devtools.inspectedWindow.tabId);
|
|
|
|
const viewAttributeSourceFunction = (id, path) => {
|
|
const rendererID = store.getRendererIDForElement(id);
|
|
if (rendererID != null) {
|
|
viewAttributeSource(rendererID, id, path);
|
|
}
|
|
};
|
|
|
|
const viewElementSourceFunction = (source, symbolicatedSource) => {
|
|
const [, sourceURL, line, column] = symbolicatedSource
|
|
? symbolicatedSource
|
|
: source;
|
|
|
|
// We use 1-based line and column, Chrome expects them 0-based.
|
|
chrome.devtools.panels.openResource(
|
|
normalizeUrlIfValid(sourceURL),
|
|
line - 1,
|
|
column - 1,
|
|
);
|
|
};
|
|
|
|
// TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration.
|
|
const hookNamesModuleLoaderFunction = () =>
|
|
import(
|
|
/* webpackChunkName: 'parseHookNames' */ 'react-devtools-shared/src/hooks/parseHookNames'
|
|
);
|
|
|
|
root = createRoot(document.createElement('div'));
|
|
|
|
render = (overrideTab = mostRecentOverrideTab) => {
|
|
mostRecentOverrideTab = overrideTab;
|
|
|
|
root.render(
|
|
createElement(DevTools, {
|
|
bridge,
|
|
browserTheme: getBrowserTheme(),
|
|
componentsPortalContainer,
|
|
profilerPortalContainer,
|
|
editorPortalContainer,
|
|
currentSelectedSource,
|
|
enabledInspectedElementContextMenu: true,
|
|
fetchFileWithCaching,
|
|
hookNamesModuleLoaderFunction,
|
|
overrideTab,
|
|
showTabBar: false,
|
|
store,
|
|
warnIfUnsupportedVersionDetected: true,
|
|
viewAttributeSourceFunction,
|
|
// Firefox doesn't support chrome.devtools.panels.openResource yet
|
|
canViewElementSourceFunction: () => __IS_CHROME__ || __IS_EDGE__,
|
|
viewElementSourceFunction,
|
|
}),
|
|
);
|
|
};
|
|
}
|
|
|
|
function ensureInitialHTMLIsCleared(container) {
|
|
if (container._hasInitialHTMLBeenCleared) {
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '';
|
|
container._hasInitialHTMLBeenCleared = true;
|
|
}
|
|
|
|
function createComponentsPanel() {
|
|
if (componentsPortalContainer) {
|
|
// Panel is created and user opened it at least once
|
|
ensureInitialHTMLIsCleared(componentsPortalContainer);
|
|
render('components');
|
|
|
|
return;
|
|
}
|
|
|
|
if (componentsPanel) {
|
|
// Panel is created, but wasn't opened yet, so no document is present for it
|
|
return;
|
|
}
|
|
|
|
chrome.devtools.panels.create(
|
|
__IS_CHROME__ || __IS_EDGE__ ? 'Components ⚛' : 'Components',
|
|
__IS_EDGE__ ? 'icons/production.svg' : '',
|
|
'panel.html',
|
|
createdPanel => {
|
|
componentsPanel = createdPanel;
|
|
|
|
createdPanel.onShown.addListener(portal => {
|
|
componentsPortalContainer = portal.container;
|
|
if (componentsPortalContainer != null && render) {
|
|
ensureInitialHTMLIsCleared(componentsPortalContainer);
|
|
|
|
render('components');
|
|
portal.injectStyles(cloneStyleTags);
|
|
|
|
logEvent({event_name: 'selected-components-tab'});
|
|
}
|
|
});
|
|
|
|
createdPanel.onShown.addListener(() => {
|
|
bridge.emit('extensionComponentsPanelShown');
|
|
});
|
|
createdPanel.onHidden.addListener(() => {
|
|
bridge.emit('extensionComponentsPanelHidden');
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function createProfilerPanel() {
|
|
if (profilerPortalContainer) {
|
|
// Panel is created and user opened it at least once
|
|
ensureInitialHTMLIsCleared(profilerPortalContainer);
|
|
render('profiler');
|
|
|
|
return;
|
|
}
|
|
|
|
if (profilerPanel) {
|
|
// Panel is created, but wasn't opened yet, so no document is present for it
|
|
return;
|
|
}
|
|
|
|
chrome.devtools.panels.create(
|
|
__IS_CHROME__ || __IS_EDGE__ ? 'Profiler ⚛' : 'Profiler',
|
|
__IS_EDGE__ ? 'icons/production.svg' : '',
|
|
'panel.html',
|
|
createdPanel => {
|
|
profilerPanel = createdPanel;
|
|
|
|
createdPanel.onShown.addListener(portal => {
|
|
profilerPortalContainer = portal.container;
|
|
if (profilerPortalContainer != null && render) {
|
|
ensureInitialHTMLIsCleared(profilerPortalContainer);
|
|
|
|
render('profiler');
|
|
portal.injectStyles(cloneStyleTags);
|
|
|
|
logEvent({event_name: 'selected-profiler-tab'});
|
|
}
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function createSourcesEditorPanel() {
|
|
if (editorPortalContainer) {
|
|
// Panel is created and user opened it at least once
|
|
ensureInitialHTMLIsCleared(editorPortalContainer);
|
|
render();
|
|
|
|
return;
|
|
}
|
|
|
|
if (editorPane) {
|
|
// Panel is created, but wasn't opened yet, so no document is present for it
|
|
return;
|
|
}
|
|
|
|
const sourcesPanel = chrome.devtools.panels.sources;
|
|
if (!sourcesPanel) {
|
|
// Firefox doesn't currently support extending the source panel.
|
|
return;
|
|
}
|
|
|
|
sourcesPanel.createSidebarPane('Code Editor ⚛', createdPane => {
|
|
editorPane = createdPane;
|
|
|
|
createdPane.setPage('panel.html');
|
|
createdPane.setHeight('42px');
|
|
|
|
createdPane.onShown.addListener(portal => {
|
|
editorPortalContainer = portal.container;
|
|
if (editorPortalContainer != null && render) {
|
|
ensureInitialHTMLIsCleared(editorPortalContainer);
|
|
|
|
render();
|
|
portal.injectStyles(cloneStyleTags);
|
|
|
|
logEvent({event_name: 'selected-editor-pane'});
|
|
}
|
|
});
|
|
|
|
createdPane.onShown.addListener(() => {
|
|
bridge.emit('extensionEditorPaneShown');
|
|
});
|
|
createdPane.onHidden.addListener(() => {
|
|
bridge.emit('extensionEditorPaneHidden');
|
|
});
|
|
});
|
|
}
|
|
|
|
function performInTabNavigationCleanup() {
|
|
// Potentially, if react hasn't loaded yet and user performs in-tab navigation
|
|
clearReactPollingInstance();
|
|
|
|
if (store !== null) {
|
|
// Store profiling data, so it can be used later
|
|
profilingData = store.profilerStore.profilingData;
|
|
}
|
|
|
|
// 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) {
|
|
// 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
|
|
flushSync(() => root.unmount());
|
|
} else {
|
|
// In case Browser DevTools were opened, but user never pressed on extension panels
|
|
// They were never mounted and there is nothing to unmount, but we need to emit shutdown event
|
|
// because bridge was already created
|
|
bridge?.shutdown();
|
|
}
|
|
|
|
// Do not nullify componentsPanelPortal and profilerPanelPortal on purpose,
|
|
// They are not recreated when user does in-tab navigation, and they can only be accessed via
|
|
// callback in onShown listener, which is called only when panel has been shown
|
|
// This event won't be emitted again after in-tab navigation, if DevTools panel keeps being opened
|
|
|
|
// Do not clean mostRecentOverrideTab on purpose, so we remember last opened
|
|
// React DevTools tab, when user does in-tab navigation
|
|
|
|
store = null;
|
|
bridge = null;
|
|
render = null;
|
|
root = null;
|
|
}
|
|
|
|
function performFullCleanup() {
|
|
// Potentially, if react hasn't loaded yet and user closed the browser DevTools
|
|
clearReactPollingInstance();
|
|
|
|
if ((componentsPortalContainer || profilerPortalContainer) && root) {
|
|
// This should also emit bridge.shutdown, but only if this root was mounted
|
|
flushSync(() => root.unmount());
|
|
} else {
|
|
bridge?.shutdown();
|
|
}
|
|
|
|
componentsPortalContainer = null;
|
|
profilerPortalContainer = null;
|
|
root = null;
|
|
|
|
mostRecentOverrideTab = null;
|
|
store = null;
|
|
bridge = null;
|
|
render = null;
|
|
|
|
port?.disconnect();
|
|
port = null;
|
|
}
|
|
|
|
function connectExtensionPort() {
|
|
if (port) {
|
|
throw new Error('DevTools port was already connected');
|
|
}
|
|
|
|
const tabId = chrome.devtools.inspectedWindow.tabId;
|
|
port = chrome.runtime.connect({
|
|
name: String(tabId),
|
|
});
|
|
|
|
// If DevTools port was reconnected and Bridge was already created
|
|
// We should subscribe bridge to this port events
|
|
// This could happen if service worker dies and all ports are disconnected,
|
|
// but later user continues the session and Chrome reconnects all ports
|
|
// Bridge object is still in-memory, though
|
|
if (lastSubscribedBridgeListener) {
|
|
port.onMessage.addListener(lastSubscribedBridgeListener);
|
|
}
|
|
|
|
// This port may be disconnected by Chrome at some point, this callback
|
|
// will be executed only if this port was disconnected from the other end
|
|
// so, when we call `port.disconnect()` from this script,
|
|
// this should not trigger this callback and port reconnection
|
|
port.onDisconnect.addListener(() => {
|
|
port = null;
|
|
connectExtensionPort();
|
|
});
|
|
}
|
|
|
|
function mountReactDevTools() {
|
|
reactPollingInstance = null;
|
|
|
|
registerEventsLogger();
|
|
|
|
createBridgeAndStore();
|
|
|
|
createComponentsPanel();
|
|
createProfilerPanel();
|
|
createSourcesEditorPanel();
|
|
}
|
|
|
|
let reactPollingInstance = null;
|
|
function clearReactPollingInstance() {
|
|
reactPollingInstance?.abort();
|
|
reactPollingInstance = null;
|
|
}
|
|
|
|
function showNoReactDisclaimer() {
|
|
if (componentsPortalContainer) {
|
|
componentsPortalContainer.innerHTML =
|
|
'<h1 class="no-react-disclaimer">Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.</h1>';
|
|
delete componentsPortalContainer._hasInitialHTMLBeenCleared;
|
|
}
|
|
|
|
if (profilerPortalContainer) {
|
|
profilerPortalContainer.innerHTML =
|
|
'<h1 class="no-react-disclaimer">Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.</h1>';
|
|
delete profilerPortalContainer._hasInitialHTMLBeenCleared;
|
|
}
|
|
}
|
|
|
|
function mountReactDevToolsWhenReactHasLoaded() {
|
|
reactPollingInstance = startReactPolling(
|
|
mountReactDevTools,
|
|
5, // ~5 seconds
|
|
showNoReactDisclaimer,
|
|
);
|
|
}
|
|
|
|
let bridge = null;
|
|
let lastSubscribedBridgeListener = null;
|
|
let store = null;
|
|
|
|
let profilingData = null;
|
|
|
|
let componentsPanel = null;
|
|
let profilerPanel = null;
|
|
let editorPane = null;
|
|
let componentsPortalContainer = null;
|
|
let profilerPortalContainer = null;
|
|
let editorPortalContainer = null;
|
|
|
|
let mostRecentOverrideTab = null;
|
|
let render = null;
|
|
let root = null;
|
|
|
|
let currentSelectedSource: null | SourceSelection = null;
|
|
|
|
let port = null;
|
|
|
|
// In case when multiple navigation events emitted in a short period of time
|
|
// This debounced callback primarily used to avoid mounting React DevTools multiple times, which results
|
|
// into subscribing to the same events from Bridge and window multiple times
|
|
// In this case, we will handle `operations` event twice or more and user will see
|
|
// `Cannot add node "1" because a node with that id is already in the Store.`
|
|
const debouncedMountReactDevToolsCallback = debounce(
|
|
mountReactDevToolsWhenReactHasLoaded,
|
|
500,
|
|
);
|
|
|
|
// Clean up everything, but start mounting React DevTools panels if user stays at this page
|
|
function onNavigatedToOtherPage() {
|
|
performInTabNavigationCleanup();
|
|
debouncedMountReactDevToolsCallback();
|
|
}
|
|
|
|
// Cleanup previous page state and remount everything
|
|
chrome.devtools.network.onNavigated.addListener(onNavigatedToOtherPage);
|
|
|
|
// Should be emitted when browser DevTools are closed
|
|
if (__IS_FIREFOX__) {
|
|
// For some reason Firefox doesn't emit onBeforeUnload event
|
|
window.addEventListener('unload', performFullCleanup);
|
|
} else {
|
|
window.addEventListener('beforeunload', performFullCleanup);
|
|
}
|
|
|
|
connectExtensionPort();
|
|
|
|
mountReactDevToolsWhenReactHasLoaded();
|