From f4a5cb8e84eb481afdee5e011b31b50a3d6a90e8 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Tue, 20 Jan 2026 21:00:14 +0000 Subject: [PATCH] wip --- .../chrome/manifest.json | 1 + .../edge/manifest.json | 1 + .../firefox/manifest.json | 1 + .../src/background/contextMenuManager.js | 192 ++++++++++++ .../src/background/index.js | 7 + .../src/contentScripts/inspectionLoading.js | 72 +++++ .../src/contentScripts/standaloneInspector.js | 286 ++++++++++++++++++ .../standaloneInspectorProxy.js | 43 +++ .../webpack.config.js | 4 + 9 files changed, 607 insertions(+) create mode 100644 packages/react-devtools-extensions/src/background/contextMenuManager.js create mode 100644 packages/react-devtools-extensions/src/contentScripts/inspectionLoading.js create mode 100644 packages/react-devtools-extensions/src/contentScripts/standaloneInspector.js create mode 100644 packages/react-devtools-extensions/src/contentScripts/standaloneInspectorProxy.js diff --git a/packages/react-devtools-extensions/chrome/manifest.json b/packages/react-devtools-extensions/chrome/manifest.json index 9bb0ab1177..9b9759d9d5 100644 --- a/packages/react-devtools-extensions/chrome/manifest.json +++ b/packages/react-devtools-extensions/chrome/manifest.json @@ -41,6 +41,7 @@ "service_worker": "build/background.js" }, "permissions": [ + "contextMenus", "scripting", "storage", "tabs" diff --git a/packages/react-devtools-extensions/edge/manifest.json b/packages/react-devtools-extensions/edge/manifest.json index 55b7248f25..03f899135f 100644 --- a/packages/react-devtools-extensions/edge/manifest.json +++ b/packages/react-devtools-extensions/edge/manifest.json @@ -41,6 +41,7 @@ "service_worker": "build/background.js" }, "permissions": [ + "contextMenus", "scripting", "storage", "tabs" diff --git a/packages/react-devtools-extensions/firefox/manifest.json b/packages/react-devtools-extensions/firefox/manifest.json index f401708c21..1c197f9135 100644 --- a/packages/react-devtools-extensions/firefox/manifest.json +++ b/packages/react-devtools-extensions/firefox/manifest.json @@ -48,6 +48,7 @@ ] }, "permissions": [ + "contextMenus", "scripting", "storage", "tabs", diff --git a/packages/react-devtools-extensions/src/background/contextMenuManager.js b/packages/react-devtools-extensions/src/background/contextMenuManager.js new file mode 100644 index 0000000000..ecdcbcaaac --- /dev/null +++ b/packages/react-devtools-extensions/src/background/contextMenuManager.js @@ -0,0 +1,192 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/* global chrome */ + +import { + executeScriptInMainWorld, + executeScriptInIsolatedWorld, +} from './executeScript'; + +const CONTEXT_MENU_ID = 'react-devtools-inspect-element'; + +// Track inspection state per tab +const inspectionStatePerTab: {[tabId: number]: boolean} = {}; + +// Create context menu on extension install/startup +chrome.runtime.onInstalled.addListener(() => { + createContextMenu(); +}); + +// Also create on startup in case extension was already installed +chrome.runtime.onStartup?.addListener(() => { + createContextMenu(); +}); + +function createContextMenu(): void { + // Remove existing menu item first to avoid duplicates + chrome.contextMenus.remove(CONTEXT_MENU_ID, () => { + // Access lastError to suppress "Unchecked runtime.lastError" warnings + void chrome.runtime.lastError; + + chrome.contextMenus.create({ + id: CONTEXT_MENU_ID, + title: 'Inspect React Component', + contexts: ['page', 'frame', 'selection', 'link', 'editable', 'image'], + }); + }); +} + +// Handle context menu click +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId === CONTEXT_MENU_ID && tab?.id != null) { + toggleInspectionMode(tab.id); + } +}); + +async function toggleInspectionMode(tabId: number): Promise { + const isActive = inspectionStatePerTab[tabId] || false; + + if (isActive) { + await stopInspectionMode(tabId); + } else { + await startInspectionMode(tabId); + } +} + +async function startInspectionMode(tabId: number): Promise { + try { + // Phase 1: Inject loading indicator and hook in parallel (no dependencies) + await Promise.all([ + // Show loading indicator for immediate visual feedback + executeScriptInMainWorld({ + target: {tabId}, + files: ['build/inspectionLoading.js'], + injectImmediately: true, + }), + // Inject the hook if it doesn't exist (has guard against double installation) + executeScriptInMainWorld({ + target: {tabId}, + files: ['build/installHook.js'], + injectImmediately: true, + }), + ]); + + // Phase 2: Inject backend and proxy in parallel (backend needs hook, proxy has no deps) + await Promise.all([ + // Backend script registers Agent/Bridge/initBackend in hook.backends + executeScriptInMainWorld({ + target: {tabId}, + files: ['build/react_devtools_backend_compact.js'], + injectImmediately: true, + }), + // Proxy script bridges chrome.runtime messages to postMessage + executeScriptInIsolatedWorld({ + target: {tabId}, + files: ['build/standaloneInspectorProxy.js'], + }), + ]); + + // Phase 3: Inject main inspector script (needs hook + backend) + await executeScriptInMainWorld({ + target: {tabId}, + files: ['build/standaloneInspector.js'], + injectImmediately: true, + }); + + // Send message to start inspection + chrome.tabs.sendMessage(tabId, { + source: 'react-devtools-context-menu', + type: 'startInspection', + }); + + inspectionStatePerTab[tabId] = true; + updateContextMenuTitleForTab(tabId); + } catch (error) { + console.error('Failed to start React DevTools inspection mode:', error); + } +} + +async function stopInspectionMode(tabId: number): Promise { + try { + chrome.tabs.sendMessage(tabId, { + source: 'react-devtools-context-menu', + type: 'stopInspection', + }); + + inspectionStatePerTab[tabId] = false; + updateContextMenuTitleForTab(tabId); + } catch (error) { + console.error('Failed to stop React DevTools inspection mode:', error); + } +} + +function updateContextMenuTitleForTab(tabId: number): void { + const isActive = inspectionStatePerTab[tabId] || false; + chrome.contextMenus.update(CONTEXT_MENU_ID, { + title: isActive ? 'Stop Inspecting React' : 'Inspect React Component', + }); +} + +async function updateContextMenuForActiveTab(): Promise { + try { + const [activeTab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + if (activeTab?.id != null) { + updateContextMenuTitleForTab(activeTab.id); + } + } catch { + // Ignore errors (e.g., no active tab) + } +} + +// Clean up when tab closes +chrome.tabs.onRemoved.addListener(tabId => { + const wasActive = inspectionStatePerTab[tabId]; + delete inspectionStatePerTab[tabId]; + // Update menu title in case the closed tab was active + if (wasActive) { + updateContextMenuForActiveTab(); + } +}); + +// Reset state when tab navigates +chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === 'loading' && inspectionStatePerTab[tabId]) { + // Page is navigating - reset inspection state + inspectionStatePerTab[tabId] = false; + updateContextMenuForActiveTab(); + } +}); + +// Update context menu title when switching tabs +chrome.tabs.onActivated.addListener(activeInfo => { + updateContextMenuTitleForTab(activeInfo.tabId); +}); + +// Handle messages from standalone inspector +export function handleStandaloneInspectorMessage( + message: {type: string, ...}, + sender: {tab?: {id?: number}, ...}, +): void { + const tabId = sender.tab?.id; + if (tabId == null) { + return; + } + + switch (message.type) { + case 'inspectionStopped': { + inspectionStatePerTab[tabId] = false; + updateContextMenuTitleForTab(tabId); + break; + } + } +} diff --git a/packages/react-devtools-extensions/src/background/index.js b/packages/react-devtools-extensions/src/background/index.js index b25eb53033..9072d50268 100644 --- a/packages/react-devtools-extensions/src/background/index.js +++ b/packages/react-devtools-extensions/src/background/index.js @@ -4,6 +4,7 @@ import './dynamicallyInjectContentScripts'; import './tabsManager'; +import './contextMenuManager'; import { handleDevToolsPageMessage, @@ -11,6 +12,7 @@ import { handleReactDevToolsHookMessage, handleFetchResourceContentScriptMessage, } from './messageHandlers'; +import {handleStandaloneInspectorMessage} from './contextMenuManager'; /* { @@ -192,6 +194,11 @@ chrome.runtime.onMessage.addListener((message, sender) => { } case 'react-devtools-hook': { handleReactDevToolsHookMessage(message, sender); + break; + } + case 'react-devtools-standalone-inspector': { + handleStandaloneInspectorMessage(message, sender); + break; } } }); diff --git a/packages/react-devtools-extensions/src/contentScripts/inspectionLoading.js b/packages/react-devtools-extensions/src/contentScripts/inspectionLoading.js new file mode 100644 index 0000000000..1cae767d89 --- /dev/null +++ b/packages/react-devtools-extensions/src/contentScripts/inspectionLoading.js @@ -0,0 +1,72 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Lightweight script that shows loading indicator immediately +// This runs before the heavy backend scripts are injected + +if (!window.__REACT_DEVTOOLS_INSPECTION_LOADING__) { + window.__REACT_DEVTOOLS_INSPECTION_LOADING__ = true; + + const doc = document; + const loadingIndicator = doc.createElement('div'); + loadingIndicator.id = '__react-devtools-loading-indicator__'; + + Object.assign(loadingIndicator.style, { + position: 'fixed', + top: '10px', + left: '50%', + transform: 'translateX(-50%)', + zIndex: '10000002', + backgroundColor: 'rgba(97, 218, 251, 0.95)', + color: '#1a1a2e', + borderRadius: '6px', + padding: '8px 16px', + fontFamily: + '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace', + fontSize: '12px', + fontWeight: 'bold', + whiteSpace: 'nowrap', + pointerEvents: 'none', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', + display: 'flex', + alignItems: 'center', + gap: '8px', + }); + + // Add a simple loading spinner + const spinner = doc.createElement('div'); + Object.assign(spinner.style, { + width: '12px', + height: '12px', + border: '2px solid #1a1a2e', + borderTopColor: 'transparent', + borderRadius: '50%', + animation: 'react-devtools-spin 0.8s linear infinite', + }); + + // Add keyframe animation + const style = doc.createElement('style'); + style.id = '__react-devtools-loading-style__'; + style.textContent = ` + @keyframes react-devtools-spin { + to { transform: rotate(360deg); } + } + `; + doc.head.appendChild(style); + + loadingIndicator.appendChild(spinner); + loadingIndicator.appendChild( + doc.createTextNode('React DevTools: Starting inspection...'), + ); + + doc.body.appendChild(loadingIndicator); + + // Also change cursor to indicate loading + doc.body.style.cursor = 'progress'; +} diff --git a/packages/react-devtools-extensions/src/contentScripts/standaloneInspector.js b/packages/react-devtools-extensions/src/contentScripts/standaloneInspector.js new file mode 100644 index 0000000000..d5325cf6cf --- /dev/null +++ b/packages/react-devtools-extensions/src/contentScripts/standaloneInspector.js @@ -0,0 +1,286 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {DevToolsHook} from 'react-devtools-shared/src/backend/types'; +import type Agent from 'react-devtools-shared/src/backend/agent'; + +import Overlay from 'react-devtools-shared/src/backend/views/Highlighter/Overlay'; +import {COMPACT_VERSION_NAME} from 'react-devtools-extensions/src/utils'; + +// Guard against multiple injections +if (!window.__REACT_DEVTOOLS_STANDALONE_INSPECTOR__) { + window.__REACT_DEVTOOLS_STANDALONE_INSPECTOR__ = true; + + // Use IIFE to create proper scope for function declarations + (function () { + let inspectionActive = false; + let agent: Agent | null = null; + let overlay: Overlay | null = null; + let notReactBubble: HTMLElement | null = null; + let nonReactHoverTimer: TimeoutID | null = null; + let lastHoveredElement: EventTarget | null = null; + + window.addEventListener('message', handleMessage); + + function handleMessage(event: MessageEvent): void { + if (event.source !== window) { + return; + } + + const data = (event.data: any); + if (data?.source !== 'react-devtools-standalone-inspector-proxy') { + return; + } + + if (data.type === 'startInspection') { + startInspection(); + } else if (data.type === 'stopInspection') { + stopInspection(); + } + } + + function hideLoadingIndicator(): void { + const indicator = document.getElementById( + '__react-devtools-loading-indicator__', + ); + indicator?.parentNode?.removeChild(indicator); + + const style = document.getElementById( + '__react-devtools-loading-style__', + ); + style?.parentNode?.removeChild(style); + + delete window.__REACT_DEVTOOLS_INSPECTION_LOADING__; + } + + function showReadyIndicator(): void { + const indicator = document.createElement('div'); + Object.assign(indicator.style, { + position: 'fixed', + top: '10px', + left: '50%', + transform: 'translateX(-50%)', + zIndex: '10000002', + backgroundColor: 'rgba(97, 218, 251, 0.95)', + color: '#1a1a2e', + borderRadius: '6px', + padding: '8px 16px', + fontFamily: + '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace', + fontSize: '12px', + fontWeight: 'bold', + whiteSpace: 'nowrap', + pointerEvents: 'none', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', + transition: 'opacity 0.3s ease-out', + }); + indicator.textContent = '✓ Inspection mode active (Press Esc to exit)'; + document.body.appendChild(indicator); + document.body.style.cursor = 'crosshair'; + + // Fade out and remove after 2 seconds + setTimeout(() => { + indicator.style.opacity = '0'; + setTimeout(() => { + indicator.parentNode?.removeChild(indicator); + }, 300); + }, 2000); + } + + function startInspection(): void { + if (inspectionActive) { + return; + } + + const hook: ?DevToolsHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook) { + hideLoadingIndicator(); + document.body.style.cursor = ''; + console.warn( + 'React DevTools: Global hook not found. Is React DevTools extension installed?', + ); + return; + } + + // Use existing agent if DevTools panel is open, otherwise activate standalone backend + agent = hook.reactDevtoolsAgent ?? activateStandaloneBackend(hook); + + hideLoadingIndicator(); + + if (!agent) { + document.body.style.cursor = ''; + console.warn( + 'React DevTools: Could not activate backend. The page may not have React loaded.', + ); + return; + } + + overlay = new Overlay(agent); + inspectionActive = true; + window.addEventListener('pointermove', onPointerMove, true); + window.addEventListener('pointerdown', onPointerDown, true); + window.addEventListener('keydown', onKeyDown, true); + showReadyIndicator(); + } + + function activateStandaloneBackend(hook: DevToolsHook): Agent | null { + let backend = hook.backends.get(COMPACT_VERSION_NAME); + if (!backend && hook.backends.size > 0) { + backend = hook.backends.values().next().value; + } + + if (!backend) { + console.warn( + 'React DevTools: No backend available. The backend script may not have been injected.', + ); + return null; + } + + const {Agent: AgentClass, Bridge, initBackend} = backend; + + // Create a no-op bridge since we don't communicate with a frontend panel + const bridge = new Bridge({ + listen: () => () => {}, + send: () => {}, + }); + + const newAgent = new AgentClass(bridge, false); + initBackend(hook, newAgent, window, false); + window.__REACT_DEVTOOLS_STANDALONE_BACKEND_ACTIVE__ = true; + + return newAgent; + } + + function stopInspection(): void { + if (!inspectionActive) { + return; + } + + inspectionActive = false; + window.removeEventListener('pointermove', onPointerMove, true); + window.removeEventListener('pointerdown', onPointerDown, true); + window.removeEventListener('keydown', onKeyDown, true); + + if (overlay) { + overlay.remove(); + overlay = null; + } + + hideNotReactBubble(); + hideLoadingIndicator(); + clearNonReactTimer(); + document.body.style.cursor = ''; + agent = null; + lastHoveredElement = null; + + window.postMessage( + { + source: 'react-devtools-standalone-inspector', + type: 'inspectionStopped', + }, + '*', + ); + } + + function onPointerMove(event: PointerEvent): void { + if (!inspectionActive || !agent) { + return; + } + + const target = getEventTarget(event); + if (!target) { + return; + } + + if (target === lastHoveredElement) { + return; + } + lastHoveredElement = target; + + clearNonReactTimer(); + hideNotReactBubble(); + + const componentName = agent.getComponentNameForHostInstance(target); + if (componentName) { + overlay?.inspect([target], componentName); + } else { + if (overlay) { + overlay.remove(); + overlay = new Overlay(agent); + } + nonReactHoverTimer = setTimeout(() => { + showNotReactBubble(target); + }, 3000); + } + } + + function onPointerDown(event: PointerEvent): void { + if (inspectionActive) { + event.preventDefault(); + event.stopPropagation(); + } + } + + function onKeyDown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + stopInspection(); + } + } + + function getEventTarget(event: PointerEvent): HTMLElement | null { + const path = event.composedPath(); + if (path.length > 0 && path[0] instanceof HTMLElement) { + return path[0]; + } + return event.target instanceof HTMLElement ? event.target : null; + } + + function clearNonReactTimer(): void { + if (nonReactHoverTimer) { + clearTimeout(nonReactHoverTimer); + nonReactHoverTimer = null; + } + } + + function showNotReactBubble(target: HTMLElement): void { + hideNotReactBubble(); + + notReactBubble = document.createElement('div'); + Object.assign(notReactBubble.style, { + position: 'fixed', + zIndex: '10000001', + backgroundColor: 'rgba(51, 55, 64, 0.9)', + color: '#d7d7d7', + borderRadius: '4px', + padding: '6px 10px', + fontFamily: + '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace', + fontSize: '11px', + whiteSpace: 'nowrap', + pointerEvents: 'none', + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)', + transform: 'translateX(-50%)', + }); + notReactBubble.textContent = 'Not rendered with React'; + + const rect = target.getBoundingClientRect(); + notReactBubble.style.left = `${Math.max(10, rect.left + rect.width / 2)}px`; + notReactBubble.style.top = `${Math.max(10, rect.top - 30)}px`; + + document.body.appendChild(notReactBubble); + + setTimeout(hideNotReactBubble, 2000); + } + + function hideNotReactBubble(): void { + notReactBubble?.parentNode?.removeChild(notReactBubble); + notReactBubble = null; + } + })(); +} diff --git a/packages/react-devtools-extensions/src/contentScripts/standaloneInspectorProxy.js b/packages/react-devtools-extensions/src/contentScripts/standaloneInspectorProxy.js new file mode 100644 index 0000000000..3b1b1cb0d6 --- /dev/null +++ b/packages/react-devtools-extensions/src/contentScripts/standaloneInspectorProxy.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/* global chrome */ + +// This content script runs in the isolated world and bridges +// chrome.runtime messages to/from the main world standaloneInspector.js + +// Forward messages from background script to main world +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.source === 'react-devtools-context-menu') { + window.postMessage( + { + source: 'react-devtools-standalone-inspector-proxy', + type: message.type, + payload: message.payload, + }, + '*', + ); + } +}); + +// Forward messages from main world back to background script +window.addEventListener('message', event => { + if (event.source !== window) { + return; + } + + const data = event.data; + if (data?.source === 'react-devtools-standalone-inspector') { + chrome.runtime.sendMessage({ + source: 'react-devtools-standalone-inspector', + type: data.type, + payload: data.payload, + }); + } +}); diff --git a/packages/react-devtools-extensions/webpack.config.js b/packages/react-devtools-extensions/webpack.config.js index 7b5acca6cc..7721114405 100644 --- a/packages/react-devtools-extensions/webpack.config.js +++ b/packages/react-devtools-extensions/webpack.config.js @@ -77,6 +77,10 @@ module.exports = { prepareInjection: './src/contentScripts/prepareInjection.js', installHook: './src/contentScripts/installHook.js', hookSettingsInjector: './src/contentScripts/hookSettingsInjector.js', + standaloneInspector: './src/contentScripts/standaloneInspector.js', + standaloneInspectorProxy: + './src/contentScripts/standaloneInspectorProxy.js', + inspectionLoading: './src/contentScripts/inspectionLoading.js', }, output: { path: __dirname + '/build',