diff --git a/packages/react-devtools-extensions/build.js b/packages/react-devtools-extensions/build.js index eb39056eaa606..4701bbdcba9b1 100644 --- a/packages/react-devtools-extensions/build.js +++ b/packages/react-devtools-extensions/build.js @@ -102,6 +102,17 @@ const build = async (tempPath, manifestPath) => { } manifest.description += `\n\nCreated from revision ${commit} on ${dateString}.`; + if (process.env.NODE_ENV === 'development') { + // When building the local development version of the + // extension we want to be able to have a stable extension ID + // for the local build (in order to be able to reliably detect + // duplicate installations of DevTools). + // By specifying a key in the built manifest.json file, + // we can make it so the generated extension ID is stable. + // For more details see the docs here: https://developer.chrome.com/docs/extensions/mv2/manifest/key/ + manifest.key = 'reactdevtoolslocalbuilduniquekey'; + } + writeFileSync(copiedManifestPath, JSON.stringify(manifest, null, 2)); // Pack the extension diff --git a/packages/react-devtools-extensions/package.json b/packages/react-devtools-extensions/package.json index 7d1f018a6bb2b..3a6bdfa7c044c 100644 --- a/packages/react-devtools-extensions/package.json +++ b/packages/react-devtools-extensions/package.json @@ -4,15 +4,15 @@ "private": true, "scripts": { "build": "cross-env NODE_ENV=production yarn run build:chrome && yarn run build:firefox && yarn run build:edge", - "build:dev": "cross-env NODE_ENV=development yarn run build:chrome:dev && yarn run build:firefox:dev && yarn run build:edge:dev", + "build:local": "cross-env NODE_ENV=development yarn run build:chrome:local && yarn run build:firefox:local && yarn run build:edge:local", "build:chrome": "cross-env NODE_ENV=production node ./chrome/build", "build:chrome:fb": "cross-env NODE_ENV=production FEATURE_FLAG_TARGET=extension-fb node ./chrome/build --crx", - "build:chrome:dev": "cross-env NODE_ENV=development node ./chrome/build", + "build:chrome:local": "cross-env NODE_ENV=development node ./chrome/build", "build:firefox": "cross-env NODE_ENV=production node ./firefox/build", - "build:firefox:dev": "cross-env NODE_ENV=development node ./firefox/build", + "build:firefox:local": "cross-env NODE_ENV=development node ./firefox/build", "build:edge": "cross-env NODE_ENV=production node ./edge/build", "build:edge:fb": "cross-env NODE_ENV=production FEATURE_FLAG_TARGET=extension-fb node ./edge/build --crx", - "build:edge:dev": "cross-env NODE_ENV=development node ./edge/build", + "build:edge:local": "cross-env NODE_ENV=development node ./edge/build", "test:chrome": "node ./chrome/test", "test:firefox": "node ./firefox/test", "test:edge": "node ./edge/test", diff --git a/packages/react-devtools-extensions/src/background.js b/packages/react-devtools-extensions/src/background.js index 9e09513b78fb4..85595d26010bf 100644 --- a/packages/react-devtools-extensions/src/background.js +++ b/packages/react-devtools-extensions/src/background.js @@ -6,6 +6,8 @@ const ports = {}; const IS_FIREFOX = navigator.userAgent.indexOf('Firefox') >= 0; +import {EXTENSION_INSTALL_CHECK_MESSAGE} from './constants'; + chrome.runtime.onConnect.addListener(function(port) { let tab = null; let name = null; @@ -116,6 +118,14 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { } }); +chrome.runtime.onMessageExternal.addListener( + (request, sender, sendResponse) => { + if (request === EXTENSION_INSTALL_CHECK_MESSAGE) { + sendResponse(true); + } + }, +); + chrome.runtime.onMessage.addListener((request, sender) => { const tab = sender.tab; if (tab) { diff --git a/packages/react-devtools-extensions/src/checkForDuplicateInstallations.js b/packages/react-devtools-extensions/src/checkForDuplicateInstallations.js new file mode 100644 index 0000000000000..e54476283cc61 --- /dev/null +++ b/packages/react-devtools-extensions/src/checkForDuplicateInstallations.js @@ -0,0 +1,129 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + */ + +declare var chrome: any; + +import {__DEBUG__} from 'react-devtools-shared/src/constants'; +import { + EXTENSION_INSTALL_CHECK_MESSAGE, + EXTENSION_INSTALLATION_TYPE, + INTERNAL_EXTENSION_ID, + LOCAL_EXTENSION_ID, +} from './constants'; + +const UNRECOGNIZED_EXTENSION_WARNING = + 'React Developer Tools: You are running an unrecognized installation of the React Developer Tools extension, which might conflict with other versions of the extension installed in your browser. ' + + 'Please make sure you only have a single version of the extension installed or enabled. ' + + 'If you are developing this extension locally, make sure to build the extension using the `yarn build::local` command.'; + +export function checkForDuplicateInstallations(callback: boolean => void) { + switch (EXTENSION_INSTALLATION_TYPE) { + case 'public': { + // If this is the public extension (e.g. from Chrome Web Store), check if an internal + // or local build of the extension is also installed, and if so, disable this extension. + // TODO show warning if other installations are present. + checkForInstalledExtensions([ + INTERNAL_EXTENSION_ID, + LOCAL_EXTENSION_ID, + ]).then(areExtensionsInstalled => { + if (areExtensionsInstalled.some(isInstalled => isInstalled)) { + callback(true); + } else { + callback(false); + } + }); + break; + } + case 'internal': { + // If this is the internal extension, check if a local build of the extension + // is also installed, and if so, disable this extension. + // If the public version of the extension is also installed, that extension + // will disable itself. + // TODO show warning if other installations are present. + checkForInstalledExtension(LOCAL_EXTENSION_ID).then(isInstalled => { + if (isInstalled) { + callback(true); + } else { + callback(false); + } + }); + break; + } + case 'local': { + if (__DEV__) { + // If this is the local extension (i.e. built locally during development), + // always keep this one enabled. Other installations disable themselves if + // they detect the local build is installed. + callback(false); + break; + } + + // If this extension wasn't built locally during development, we can't reliably + // detect if there are other installations of DevTools present. + // In this case, assume there are no duplicate exensions and show a warning about + // potential conflicts. + console.error(UNRECOGNIZED_EXTENSION_WARNING); + chrome.devtools.inspectedWindow.eval( + `console.error("${UNRECOGNIZED_EXTENSION_WARNING}")`, + ); + callback(false); + break; + } + case 'unknown': { + // If we don't know how this extension was built, we can't reliably detect if there + // are other installations of DevTools present. + // In this case, assume there are no duplicate exensions and show a warning about + // potential conflicts. + console.error(UNRECOGNIZED_EXTENSION_WARNING); + chrome.devtools.inspectedWindow.eval( + `console.error("${UNRECOGNIZED_EXTENSION_WARNING}")`, + ); + callback(false); + break; + } + default: { + (EXTENSION_INSTALLATION_TYPE: empty); + } + } +} + +function checkForInstalledExtensions( + extensionIds: string[], +): Promise { + return Promise.all( + extensionIds.map(extensionId => checkForInstalledExtension(extensionId)), + ); +} + +function checkForInstalledExtension(extensionId: string): Promise { + return new Promise(resolve => { + chrome.runtime.sendMessage( + extensionId, + EXTENSION_INSTALL_CHECK_MESSAGE, + response => { + if (__DEBUG__) { + console.log( + 'checkForDuplicateInstallations: Duplicate installation check responded with', + { + response, + error: chrome.runtime.lastError?.message, + currentExtension: EXTENSION_INSTALLATION_TYPE, + checkingExtension: extensionId, + }, + ); + } + if (chrome.runtime.lastError != null) { + resolve(false); + } else { + resolve(true); + } + }, + ); + }); +} diff --git a/packages/react-devtools-extensions/src/constants.js b/packages/react-devtools-extensions/src/constants.js new file mode 100644 index 0000000000000..5e85e4aec40e7 --- /dev/null +++ b/packages/react-devtools-extensions/src/constants.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + */ + +declare var chrome: any; + +export const CURRENT_EXTENSION_ID = chrome.runtime.id; + +export const EXTENSION_INSTALL_CHECK_MESSAGE = 'extension-install-check'; + +export const CHROME_WEBSTORE_EXTENSION_ID = 'fmkadmapgofadopljbjfkapdkoienihi'; +export const INTERNAL_EXTENSION_ID = 'dnjnjgbfilfphmojnmhliehogmojhclc'; +export const LOCAL_EXTENSION_ID = 'ikiahnapldjmdmpkmfhjdjilojjhgcbf'; + +export const EXTENSION_INSTALLATION_TYPE: + | 'public' + | 'internal' + | 'local' + | 'unknown' = + CURRENT_EXTENSION_ID === CHROME_WEBSTORE_EXTENSION_ID + ? 'public' + : CURRENT_EXTENSION_ID === INTERNAL_EXTENSION_ID + ? 'internal' + : CURRENT_EXTENSION_ID === LOCAL_EXTENSION_ID + ? 'local' + : 'unknown'; diff --git a/packages/react-devtools-extensions/src/injectGlobalHook.js b/packages/react-devtools-extensions/src/injectGlobalHook.js index 701d4927487d9..02d5109e291eb 100644 --- a/packages/react-devtools-extensions/src/injectGlobalHook.js +++ b/packages/react-devtools-extensions/src/injectGlobalHook.js @@ -2,7 +2,11 @@ import nullthrows from 'nullthrows'; import {installHook} from 'react-devtools-shared/src/hook'; -import {SESSION_STORAGE_RELOAD_AND_PROFILE_KEY} from 'react-devtools-shared/src/constants'; +import { + __DEBUG__, + SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, +} from 'react-devtools-shared/src/constants'; +import {CURRENT_EXTENSION_ID, EXTENSION_INSTALLATION_TYPE} from './constants'; import {sessionStorageGetItem} from 'react-devtools-shared/src/storage'; function injectCode(code) { @@ -27,7 +31,17 @@ window.addEventListener('message', function onMessage({data, source}) { if (source !== window || !data) { return; } - + if (data.extensionId !== CURRENT_EXTENSION_ID) { + if (__DEBUG__) { + console.log( + `[injectGlobalHook] Received message '${data.source}' from different extension instance. Skipping message.`, + { + currentExtension: EXTENSION_INSTALLATION_TYPE, + }, + ); + } + return; + } switch (data.source) { case 'react-devtools-detector': lastDetectionResult = { @@ -102,6 +116,7 @@ window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function({reactBuildType}) window.postMessage({ source: 'react-devtools-detector', reactBuildType, + extensionId: "${CURRENT_EXTENSION_ID}", }, '*'); }); `; diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index 6a3836839a4ea..8280158e1db70 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -22,6 +22,8 @@ import { import DevTools from 'react-devtools-shared/src/devtools/views/DevTools'; import {__DEBUG__} from 'react-devtools-shared/src/constants'; import {logEvent} from 'react-devtools-shared/src/Logger'; +import {CURRENT_EXTENSION_ID, EXTENSION_INSTALLATION_TYPE} from './constants'; +import {checkForDuplicateInstallations} from './checkForDuplicateInstallations'; const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY = 'React::DevTools::supportsProfiling'; @@ -70,135 +72,158 @@ function createPanelIfReactLoaded() { return; } - panelCreated = true; + checkForDuplicateInstallations(hasDuplicateInstallation => { + if (hasDuplicateInstallation) { + if (__DEBUG__) { + console.log( + '[main] createPanelIfReactLoaded: Duplicate installation detected, skipping initialization of extension.', + {currentExtension: EXTENSION_INSTALLATION_TYPE}, + ); + } + panelCreated = true; + clearInterval(loadCheckInterval); + return; + } - clearInterval(loadCheckInterval); + if (__DEBUG__) { + console.log( + '[main] createPanelIfReactLoaded: No duplicate installations detected, continuing with initialization.', + {currentExtension: EXTENSION_INSTALLATION_TYPE}, + ); + } - let bridge = null; - let store = null; + panelCreated = true; - let profilingData = null; + clearInterval(loadCheckInterval); - let componentsPortalContainer = null; - let profilerPortalContainer = null; + let bridge = null; + let store = null; - let cloneStyleTags = null; - let mostRecentOverrideTab = null; - let render = null; - let root = null; + let profilingData = null; - const tabId = chrome.devtools.inspectedWindow.tabId; + let componentsPortalContainer = null; + let profilerPortalContainer = null; - registerDevToolsEventLogger('extension'); + let cloneStyleTags = null; + let mostRecentOverrideTab = null; + let render = null; + let root = null; - function initBridgeAndStore() { - const port = chrome.runtime.connect({ - name: String(tabId), - }); - // Looks like `port.onDisconnect` does not trigger on in-tab navigation like new URL or back/forward navigation, - // so it makes no sense to handle it here. - - bridge = new Bridge({ - listen(fn) { - const listener = message => fn(message); - // Store the reference so that we unsubscribe from the same object. - const portOnMessage = port.onMessage; - portOnMessage.addListener(listener); - return () => { - portOnMessage.removeListener(listener); - }; - }, - send(event: string, payload: any, transferable?: Array) { - port.postMessage({event, payload}, transferable); - }, - }); - bridge.addListener('reloadAppForProfiling', () => { - localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true'); - chrome.devtools.inspectedWindow.eval('window.location.reload();'); - }); - bridge.addListener('syncSelectionToNativeElementsPanel', () => { - setBrowserSelectionFromReact(); - }); + const tabId = chrome.devtools.inspectedWindow.tabId; - // This flag lets us tip the Store off early that we expect to be profiling. - // This avoids flashing a temporary "Profiling not supported" message in the Profiler tab, - // after a user has clicked the "reload and profile" button. - let isProfiling = false; - let supportsProfiling = false; - if ( - localStorageGetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY) === 'true' - ) { - supportsProfiling = true; - isProfiling = true; - localStorageRemoveItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY); - } + registerDevToolsEventLogger('extension'); - if (store !== null) { - profilingData = store.profilerStore.profilingData; - } + function initBridgeAndStore() { + const port = chrome.runtime.connect({ + name: String(tabId), + }); + // Looks like `port.onDisconnect` does not trigger on in-tab navigation like new URL or back/forward navigation, + // so it makes no sense to handle it here. + + bridge = new Bridge({ + listen(fn) { + const listener = message => fn(message); + // Store the reference so that we unsubscribe from the same object. + const portOnMessage = port.onMessage; + portOnMessage.addListener(listener); + return () => { + portOnMessage.removeListener(listener); + }; + }, + send(event: string, payload: any, transferable?: Array) { + port.postMessage({event, payload}, transferable); + }, + }); + bridge.addListener('reloadAppForProfiling', () => { + localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true'); + chrome.devtools.inspectedWindow.eval('window.location.reload();'); + }); + bridge.addListener('syncSelectionToNativeElementsPanel', () => { + 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', - ); - }); + // This flag lets us tip the Store off early that we expect to be profiling. + // This avoids flashing a temporary "Profiling not supported" message in the Profiler tab, + // after a user has clicked the "reload and profile" button. + let isProfiling = false; + let supportsProfiling = false; + if ( + localStorageGetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY) === 'true' + ) { + supportsProfiling = true; + isProfiling = true; + localStorageRemoveItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY); + } - store = new Store(bridge, { - isProfiling, - supportsReloadAndProfile: isChrome, - supportsProfiling, - // At this time, the scheduling profiler can only parse Chrome performance profiles. - supportsSchedulingProfiler: isChrome, - supportsTraceUpdates: true, - }); - store.profilerStore.profilingData = profilingData; - - // Initialize the backend only once the Store has been initialized. - // Otherwise the Store may miss important initial tree op codes. - chrome.devtools.inspectedWindow.eval( - `window.postMessage({ source: 'react-devtools-inject-backend' }, '*');`, - function(response, evalError) { - if (evalError) { - console.error(evalError); - } - }, - ); + if (store !== null) { + profilingData = store.profilerStore.profilingData; + } - const viewAttributeSourceFunction = (id, path) => { - const rendererID = store.getRendererIDForElement(id); - if (rendererID != null) { - // Ask the renderer interface to find the specified attribute, - // and store it as a global variable on the window. - bridge.send('viewAttributeSource', {id, path, rendererID}); + 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', + ); + }); - setTimeout(() => { - // Ask Chrome to display the location of the attribute, - // assuming the renderer found a match. - chrome.devtools.inspectedWindow.eval(` + store = new Store(bridge, { + isProfiling, + supportsReloadAndProfile: isChrome, + supportsProfiling, + // At this time, the scheduling profiler can only parse Chrome performance profiles. + supportsSchedulingProfiler: isChrome, + supportsTraceUpdates: true, + }); + store.profilerStore.profilingData = profilingData; + + // Initialize the backend only once the Store has been initialized. + // Otherwise the Store may miss important initial tree op codes. + chrome.devtools.inspectedWindow.eval( + `window.postMessage({ + source: 'react-devtools-inject-backend', + extensionId: "${CURRENT_EXTENSION_ID}" + }, '*');`, + function(response, evalError) { + if (evalError) { + console.error(evalError); + } + }, + ); + + const viewAttributeSourceFunction = (id, path) => { + const rendererID = store.getRendererIDForElement(id); + if (rendererID != null) { + // Ask the renderer interface to find the specified attribute, + // and store it as a global variable on the window. + bridge.send('viewAttributeSource', {id, path, rendererID}); + + setTimeout(() => { + // Ask Chrome to display the location of the attribute, + // assuming the renderer found a match. + chrome.devtools.inspectedWindow.eval(` if (window.$attribute != null) { inspect(window.$attribute); } `); - }, 100); - } - }; + }, 100); + } + }; - const viewElementSourceFunction = id => { - const rendererID = store.getRendererIDForElement(id); - if (rendererID != null) { - // Ask the renderer interface to determine the component function, - // and store it as a global variable on the window - bridge.send('viewElementSource', {id, rendererID}); - - setTimeout(() => { - // Ask Chrome to display the location of the component function, - // or a render method if it is a Class (ideally Class instance, not type) - // assuming the renderer found one. - chrome.devtools.inspectedWindow.eval(` + const viewElementSourceFunction = id => { + const rendererID = store.getRendererIDForElement(id); + if (rendererID != null) { + // Ask the renderer interface to determine the component function, + // and store it as a global variable on the window + bridge.send('viewElementSource', {id, rendererID}); + + setTimeout(() => { + // Ask Chrome to display the location of the component function, + // or a render method if it is a Class (ideally Class instance, not type) + // assuming the renderer found one. + chrome.devtools.inspectedWindow.eval(` if (window.$type != null) { if ( window.$type && @@ -213,288 +238,293 @@ function createPanelIfReactLoaded() { } } `); - }, 100); - } - }; - - let debugIDCounter = 0; + }, 100); + } + }; - // For some reason in Firefox, chrome.runtime.sendMessage() from a content script - // never reaches the chrome.runtime.onMessage event listener. - let fetchFileWithCaching = null; - if (isChrome) { - const fetchFromNetworkCache = (url, resolve, reject) => { - // Debug ID allows us to avoid re-logging (potentially long) URL strings below, - // while also still associating (potentially) interleaved logs with the original request. - let debugID = null; + let debugIDCounter = 0; - if (__DEBUG__) { - debugID = debugIDCounter++; - console.log(`[main] fetchFromNetworkCache(${debugID})`, url); - } + // For some reason in Firefox, chrome.runtime.sendMessage() from a content script + // never reaches the chrome.runtime.onMessage event listener. + let fetchFileWithCaching = null; + if (isChrome) { + const fetchFromNetworkCache = (url, resolve, reject) => { + // Debug ID allows us to avoid re-logging (potentially long) URL strings below, + // while also still associating (potentially) interleaved logs with the original request. + let debugID = null; - chrome.devtools.network.getHAR(harLog => { - for (let i = 0; i < harLog.entries.length; i++) { - const entry = harLog.entries[i]; - if (url === entry.request.url) { - if (__DEBUG__) { - console.log( - `[main] fetchFromNetworkCache(${debugID}) Found matching URL in HAR`, - url, - ); - } + if (__DEBUG__) { + debugID = debugIDCounter++; + console.log(`[main] fetchFromNetworkCache(${debugID})`, url); + } - entry.getContent(content => { - if (content) { - if (__DEBUG__) { - console.log( - `[main] fetchFromNetworkCache(${debugID}) Content retrieved`, - ); - } + chrome.devtools.network.getHAR(harLog => { + for (let i = 0; i < harLog.entries.length; i++) { + const entry = harLog.entries[i]; + if (url === entry.request.url) { + if (__DEBUG__) { + console.log( + `[main] fetchFromNetworkCache(${debugID}) Found matching URL in HAR`, + url, + ); + } - resolve(content); - } else { - if (__DEBUG__) { - console.log( - `[main] fetchFromNetworkCache(${debugID}) Invalid content returned by getContent()`, - content, - ); + entry.getContent(content => { + if (content) { + if (__DEBUG__) { + console.log( + `[main] fetchFromNetworkCache(${debugID}) Content retrieved`, + ); + } + + resolve(content); + } else { + if (__DEBUG__) { + console.log( + `[main] fetchFromNetworkCache(${debugID}) Invalid content returned by getContent()`, + content, + ); + } + + // Edge case where getContent() returned null; fall back to fetch. + fetchFromPage(url, resolve, reject); } + }); - // Edge case where getContent() returned null; fall back to fetch. - fetchFromPage(url, resolve, reject); - } - }); + return; + } + } - return; + if (__DEBUG__) { + console.log( + `[main] fetchFromNetworkCache(${debugID}) No cached request found in getHAR()`, + ); } - } + // No matching URL found; fall back to fetch. + fetchFromPage(url, resolve, reject); + }); + }; + + const fetchFromPage = (url, resolve, reject) => { if (__DEBUG__) { - console.log( - `[main] fetchFromNetworkCache(${debugID}) No cached request found in getHAR()`, - ); + console.log('[main] fetchFromPage()', url); } - // No matching URL found; fall back to fetch. - fetchFromPage(url, resolve, reject); - }); - }; - - const fetchFromPage = (url, resolve, reject) => { - if (__DEBUG__) { - console.log('[main] fetchFromPage()', url); - } - - function onPortMessage({payload, source}) { - if (source === 'react-devtools-content-script') { - switch (payload?.type) { - case 'fetch-file-with-cache-complete': - chrome.runtime.onMessage.removeListener(onPortMessage); - resolve(payload.value); - break; - case 'fetch-file-with-cache-error': - chrome.runtime.onMessage.removeListener(onPortMessage); - reject(payload.value); - break; + function onPortMessage({payload, source}) { + if (source === 'react-devtools-content-script') { + switch (payload?.type) { + case 'fetch-file-with-cache-complete': + chrome.runtime.onMessage.removeListener(onPortMessage); + resolve(payload.value); + break; + case 'fetch-file-with-cache-error': + chrome.runtime.onMessage.removeListener(onPortMessage); + reject(payload.value); + break; + } } } - } - chrome.runtime.onMessage.addListener(onPortMessage); + chrome.runtime.onMessage.addListener(onPortMessage); - chrome.devtools.inspectedWindow.eval(` + chrome.devtools.inspectedWindow.eval(` window.postMessage({ source: 'react-devtools-extension', + extensionId: "${CURRENT_EXTENSION_ID}" payload: { type: 'fetch-file-with-cache', url: "${url}", }, }); `); - }; + }; - // Fetching files from the extension won't make use of the network cache - // for resources that have already been loaded by the page. - // This helper function allows the extension to request files to be fetched - // by the content script (running in the page) to increase the likelihood of a cache hit. - fetchFileWithCaching = url => { - return new Promise((resolve, reject) => { - // Try fetching from the Network cache first. - // If DevTools was opened after the page started loading, we may have missed some requests. - // So fall back to a fetch() from the page and hope we get a cached response that way. - fetchFromNetworkCache(url, resolve, reject); - }); + // Fetching files from the extension won't make use of the network cache + // for resources that have already been loaded by the page. + // This helper function allows the extension to request files to be fetched + // by the content script (running in the page) to increase the likelihood of a cache hit. + fetchFileWithCaching = url => { + return new Promise((resolve, reject) => { + // Try fetching from the Network cache first. + // If DevTools was opened after the page started loading, we may have missed some requests. + // So fall back to a fetch() from the page and hope we get a cached response that way. + fetchFromNetworkCache(url, resolve, reject); + }); + }; + } + + // 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, + enabledInspectedElementContextMenu: true, + fetchFileWithCaching, + hookNamesModuleLoaderFunction, + overrideTab, + profilerPortalContainer, + showTabBar: false, + store, + warnIfUnsupportedVersionDetected: true, + viewAttributeSourceFunction, + viewElementSourceFunction, + }), + ); }; - } - // 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' - ); + render(); + } - root = createRoot(document.createElement('div')); - - render = (overrideTab = mostRecentOverrideTab) => { - mostRecentOverrideTab = overrideTab; - root.render( - createElement(DevTools, { - bridge, - browserTheme: getBrowserTheme(), - componentsPortalContainer, - enabledInspectedElementContextMenu: true, - fetchFileWithCaching, - hookNamesModuleLoaderFunction, - overrideTab, - profilerPortalContainer, - showTabBar: false, - store, - warnIfUnsupportedVersionDetected: true, - viewAttributeSourceFunction, - viewElementSourceFunction, - }), - ); + cloneStyleTags = () => { + const linkTags = []; + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const linkTag of document.getElementsByTagName('link')) { + if (linkTag.rel === 'stylesheet') { + const newLinkTag = document.createElement('link'); + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const attribute of linkTag.attributes) { + newLinkTag.setAttribute( + attribute.nodeName, + attribute.nodeValue, + ); + } + linkTags.push(newLinkTag); + } + } + return linkTags; }; - render(); - } + initBridgeAndStore(); - cloneStyleTags = () => { - const linkTags = []; - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const linkTag of document.getElementsByTagName('link')) { - if (linkTag.rel === 'stylesheet') { - const newLinkTag = document.createElement('link'); - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const attribute of linkTag.attributes) { - newLinkTag.setAttribute(attribute.nodeName, attribute.nodeValue); - } - linkTags.push(newLinkTag); + function ensureInitialHTMLIsCleared(container) { + if (container._hasInitialHTMLBeenCleared) { + return; } + container.innerHTML = ''; + container._hasInitialHTMLBeenCleared = true; } - return linkTags; - }; - initBridgeAndStore(); - - function ensureInitialHTMLIsCleared(container) { - if (container._hasInitialHTMLBeenCleared) { - return; + function setBrowserSelectionFromReact() { + // This is currently only called on demand when you press "view DOM". + // In the future, if Chrome adds an inspect() that doesn't switch tabs, + // we could make this happen automatically when you select another component. + chrome.devtools.inspectedWindow.eval( + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' + + '(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' + + 'false', + (didSelectionChange, evalError) => { + if (evalError) { + console.error(evalError); + } + }, + ); } - container.innerHTML = ''; - container._hasInitialHTMLBeenCleared = true; - } - function setBrowserSelectionFromReact() { - // This is currently only called on demand when you press "view DOM". - // In the future, if Chrome adds an inspect() that doesn't switch tabs, - // we could make this happen automatically when you select another component. - chrome.devtools.inspectedWindow.eval( - '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' + - '(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' + - 'false', - (didSelectionChange, evalError) => { - if (evalError) { - console.error(evalError); - } - }, - ); - } - - function setReactSelectionFromBrowser() { - // When the user chooses a different node in the browser Elements tab, - // copy it over to the hook object so that we can sync the selection. - chrome.devtools.inspectedWindow.eval( - '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' + - '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' + - 'false', - (didSelectionChange, evalError) => { - if (evalError) { - console.error(evalError); - } else if (didSelectionChange) { - // Remember to sync the selection next time we show Components tab. - needsToSyncElementSelection = true; - } - }, - ); - } + function setReactSelectionFromBrowser() { + // When the user chooses a different node in the browser Elements tab, + // copy it over to the hook object so that we can sync the selection. + chrome.devtools.inspectedWindow.eval( + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' + + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' + + 'false', + (didSelectionChange, evalError) => { + if (evalError) { + console.error(evalError); + } else if (didSelectionChange) { + // Remember to sync the selection next time we show Components tab. + needsToSyncElementSelection = true; + } + }, + ); + } - setReactSelectionFromBrowser(); - chrome.devtools.panels.elements.onSelectionChanged.addListener(() => { setReactSelectionFromBrowser(); - }); + chrome.devtools.panels.elements.onSelectionChanged.addListener(() => { + setReactSelectionFromBrowser(); + }); - let currentPanel = null; - let needsToSyncElementSelection = false; - - chrome.devtools.panels.create( - isChrome ? '⚛️ Components' : 'Components', - '', - 'panel.html', - extensionPanel => { - extensionPanel.onShown.addListener(panel => { - if (needsToSyncElementSelection) { - needsToSyncElementSelection = false; - bridge.send('syncSelectionFromNativeElementsPanel'); - } + let currentPanel = null; + let needsToSyncElementSelection = false; + + chrome.devtools.panels.create( + isChrome ? '⚛️ Components' : 'Components', + '', + 'panel.html', + extensionPanel => { + extensionPanel.onShown.addListener(panel => { + if (needsToSyncElementSelection) { + needsToSyncElementSelection = false; + bridge.send('syncSelectionFromNativeElementsPanel'); + } - if (currentPanel === panel) { - return; - } + if (currentPanel === panel) { + return; + } - currentPanel = panel; - componentsPortalContainer = panel.container; + currentPanel = panel; + componentsPortalContainer = panel.container; - if (componentsPortalContainer != null) { - ensureInitialHTMLIsCleared(componentsPortalContainer); - render('components'); - panel.injectStyles(cloneStyleTags); - logEvent({event_name: 'selected-components-tab'}); - } - }); - extensionPanel.onHidden.addListener(panel => { - // TODO: Stop highlighting and stuff. - }); - }, - ); - - chrome.devtools.panels.create( - isChrome ? '⚛️ Profiler' : 'Profiler', - '', - 'panel.html', - extensionPanel => { - extensionPanel.onShown.addListener(panel => { - if (currentPanel === panel) { - return; - } + if (componentsPortalContainer != null) { + ensureInitialHTMLIsCleared(componentsPortalContainer); + render('components'); + panel.injectStyles(cloneStyleTags); + logEvent({event_name: 'selected-components-tab'}); + } + }); + extensionPanel.onHidden.addListener(panel => { + // TODO: Stop highlighting and stuff. + }); + }, + ); - currentPanel = panel; - profilerPortalContainer = panel.container; + chrome.devtools.panels.create( + isChrome ? '⚛️ Profiler' : 'Profiler', + '', + 'panel.html', + extensionPanel => { + extensionPanel.onShown.addListener(panel => { + if (currentPanel === panel) { + return; + } - if (profilerPortalContainer != null) { - ensureInitialHTMLIsCleared(profilerPortalContainer); - render('profiler'); - panel.injectStyles(cloneStyleTags); - logEvent({event_name: 'selected-profiler-tab'}); - } - }); - }, - ); + currentPanel = panel; + profilerPortalContainer = panel.container; - chrome.devtools.network.onNavigated.removeListener(checkPageForReact); + if (profilerPortalContainer != null) { + ensureInitialHTMLIsCleared(profilerPortalContainer); + render('profiler'); + panel.injectStyles(cloneStyleTags); + logEvent({event_name: 'selected-profiler-tab'}); + } + }); + }, + ); - // Re-initialize DevTools panel when a new page is loaded. - chrome.devtools.network.onNavigated.addListener(function onNavigated() { - // Re-initialize saved filters on navigation, - // since global values stored on window get reset in this case. - syncSavedPreferences(); + chrome.devtools.network.onNavigated.removeListener(checkPageForReact); - // 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. - flushSync(() => root.unmount()); + // Re-initialize DevTools panel when a new page is loaded. + chrome.devtools.network.onNavigated.addListener(function onNavigated() { + // Re-initialize saved filters on navigation, + // since global values stored on window get reset in this case. + syncSavedPreferences(); - initBridgeAndStore(); + // 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. + flushSync(() => root.unmount()); + + initBridgeAndStore(); + }); }); }, ); diff --git a/packages/react-devtools/CONTRIBUTING.md b/packages/react-devtools/CONTRIBUTING.md index 3b93022009617..06816532838fd 100644 --- a/packages/react-devtools/CONTRIBUTING.md +++ b/packages/react-devtools/CONTRIBUTING.md @@ -57,7 +57,7 @@ Some changes requiring testing in the browser extension (e.g. like "named hooks" ```sh cd cd packages/react-devtools-extensions -yarn build:chrome && yarn test:chrome +yarn build:chrome:local && yarn test:chrome ``` This will launch a standalone version of Chrome with the locally built React DevTools pre-installed. If you are testing a specific URL, you can make your testing even faster by passing the `--url` argument to the test script: ```sh