This commit is contained in:
Ruslan Lesiutin
2026-01-20 21:00:14 +00:00
parent d87298ae16
commit f4a5cb8e84
9 changed files with 607 additions and 0 deletions

View File

@@ -41,6 +41,7 @@
"service_worker": "build/background.js"
},
"permissions": [
"contextMenus",
"scripting",
"storage",
"tabs"

View File

@@ -41,6 +41,7 @@
"service_worker": "build/background.js"
},
"permissions": [
"contextMenus",
"scripting",
"storage",
"tabs"

View File

@@ -48,6 +48,7 @@
]
},
"permissions": [
"contextMenus",
"scripting",
"storage",
"tabs",

View File

@@ -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<void> {
const isActive = inspectionStatePerTab[tabId] || false;
if (isActive) {
await stopInspectionMode(tabId);
} else {
await startInspectionMode(tabId);
}
}
async function startInspectionMode(tabId: number): Promise<void> {
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<void> {
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<void> {
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;
}
}
}

View File

@@ -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;
}
}
});

View File

@@ -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';
}

View File

@@ -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;
}
})();
}

View File

@@ -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,
});
}
});

View File

@@ -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',