/** * 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, ExtensionRuntimePort */ 'use strict'; import { EXTENSION_BRIDGE_CONNECTION_DISCONNECTED, EXTENSION_BRIDGE_CONNECTION_READY, getExtensionBridgeConnectionType, } from '../constants'; function injectProxy() { isTransportActive = true; // Firefox's behaviour for injecting this content script can be unpredictable // While navigating the history, some content scripts might not be re-injected and still be alive if (!window.__REACT_DEVTOOLS_PROXY_INJECTED__) { window.__REACT_DEVTOOLS_PROXY_INJECTED__ = true; listenToMessagesFromBackend(); connectPort(); sayHelloToBackendManager(); // The backend waits to install the global hook until notified by the content script. // In the event of a page reload, the content script might be loaded before the backend manager is injected. // Because of this we need to poll the backend manager until it has been initialized. backendManagerHelloIntervalID = setInterval(() => { if (backendInitialized) { stopPollingForBackendManager(); } else { sayHelloToBackendManager(); } }, 500); } } function handlePageShow() { if (document.prerendering) { // React DevTools can't handle multiple documents being connected to the same extension port. // However, browsers are firing pageshow events while prerendering (https://issues.chromium.org/issues/489633225). // We need to wait until prerendering is finished before injecting the proxy. // In browsers with pagereveal support, listening to pagereveal would be sufficient. // Waiting for prerenderingchange is a workaround to support browsers that // have speculationrules but not pagereveal. document.addEventListener('prerenderingchange', injectProxy, {once: true}); } else { injectProxy(); } } window.addEventListener('pagereveal', injectProxy); // For backwards compat with browsers not implementing `pagereveal` which is a fairly new event. window.addEventListener('pageshow', handlePageShow); window.addEventListener('pagehide', function ({target}) { if (target !== window.document) { return; } isTransportActive = false; backendInitialized = false; isBridgeConnected = false; pendingMessages.length = 0; stopPollingForBackendManager(); delete window.__REACT_DEVTOOLS_PROXY_INJECTED__; }); let port: ExtensionRuntimePort | null = null; let isTransportActive: boolean = true; let backendInitialized: boolean = false; let isBridgeConnected: boolean = false; let isListeningToMessagesFromBackend: boolean = false; const pendingMessages: Array = []; let backendManagerHelloIntervalID: IntervalID | null = null; function stopPollingForBackendManager() { if (backendManagerHelloIntervalID !== null) { clearInterval(backendManagerHelloIntervalID); backendManagerHelloIntervalID = null; } } function listenToMessagesFromBackend() { if (!isListeningToMessagesFromBackend) { window.addEventListener('message', handleMessageFromPage); isListeningToMessagesFromBackend = true; } } function flushPendingMessages(): boolean { const currentPort = port; if (!isBridgeConnected || currentPort === null) { return false; } let sentCount = 0; while (sentCount < pendingMessages.length) { try { currentPort.postMessage(pendingMessages[sentCount]); sentCount++; } catch (error) { isBridgeConnected = false; break; } } if (sentCount > 0) { pendingMessages.splice(0, sentCount); } return isBridgeConnected && pendingMessages.length === 0; } function sayHelloToBackendManager() { window.postMessage( { source: 'react-devtools-content-script', hello: true, }, '*', ); } function handleMessageFromDevtools( sourcePort: ExtensionRuntimePort, message: mixed, ) { if (!isTransportActive || port !== sourcePort) { return; } switch (getExtensionBridgeConnectionType(message)) { case EXTENSION_BRIDGE_CONNECTION_READY: isBridgeConnected = true; if (flushPendingMessages()) { const currentPort = port; if (currentPort === null) { // The port may disconnect synchronously while its queue is flushed. return; } try { // This travels through the forwarding pipe after all queued backend // messages, so the frontend can safely flush its command queue. currentPort.postMessage(message); } catch (error) { isBridgeConnected = false; } } return; case EXTENSION_BRIDGE_CONNECTION_DISCONNECTED: isBridgeConnected = false; return; } window.postMessage( { source: 'react-devtools-content-script', payload: message, }, '*', ); } function handleMessageFromPage(event: any) { if (!isTransportActive || event.source !== window || !event.data) { return; } switch (event.data.source) { // This is a message from a bridge (initialized by a devtools backend) case 'react-devtools-bridge': { backendInitialized = true; pendingMessages.push(event.data.payload); flushPendingMessages(); break; } // This is a message from the backend manager, which runs in ExecutionWorld.MAIN // and can't use `chrome.runtime.sendMessage` case 'react-devtools-backend-manager': { const {source, payload} = event.data; chrome.runtime.sendMessage({ source, payload, }); break; } } } function handleDisconnect(disconnectedPort: ExtensionRuntimePort) { if (port !== disconnectedPort) { return; } isBridgeConnected = false; port = null; // Mirrors the guard in handlePageShow(): the background script can evict/ // replace a tab's proxy port (see registerProxyPort in background/index.js), // which disconnects us while still prerendering. Reconnecting immediately // in that case causes an unbounded connect/disconnect cycle for as long as // the document stays in the prerendering state (https://crbug.com/478909972). if (document.prerendering) { document.addEventListener('prerenderingchange', connectPort, { once: true, }); } else { connectPort(); } } // Creates port from application page to the React DevTools' service worker // Which then connects it with extension port function connectPort() { if (!isTransportActive) { return; } isBridgeConnected = false; const nextPort = chrome.runtime.connect({ name: 'proxy', }); port = nextPort; listenToMessagesFromBackend(); nextPort.onMessage.addListener(message => handleMessageFromDevtools(nextPort, message), ); nextPort.onDisconnect.addListener(() => handleDisconnect(nextPort)); } let evalRequestId = 0; const evalRequestCallbacks = new Map(); chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { switch (msg?.source) { case 'devtools-page-eval': { const {scriptId, args} = msg.payload; const requestId = evalRequestId++; window.postMessage( { source: 'react-devtools-content-script-eval', payload: { requestId, scriptId, args, }, }, '*', ); evalRequestCallbacks.set(requestId, sendResponse); return true; // Indicate we will respond asynchronously } } }); window.addEventListener('message', event => { if (event.data?.source === 'react-devtools-content-script-eval-response') { const {requestId, response} = event.data.payload; const callback = evalRequestCallbacks.get(requestId); try { if (!callback) throw new Error( `No eval request callback for id "${requestId}" exists.`, ); callback(response); } catch (e) { console.warn( 'React DevTools Content Script eval response error occurred:', e, ); } finally { evalRequestCallbacks.delete(requestId); } } });