Repository: facebook/react
Pull request: [DevTools] Allow inspection before streaming has finished in Chrome

Apps streaming in server-rendered content will have hydrated content before the document is in an idle state. This means content may be interactive before `document_end` is reached or `pageshow` events are fired. However, the React tree may already be interesting to inspect. Especially larger apps in development can easily take multiple seconds before everything has streamed in. 

React DevTools used to not display a Component tree before streaming had finished.

We need to start executing multiple things earlier to be able to inspect the React app as early as possible:
- `proxy.js` which sends setup instructions to the backend manager is now executed at `document_start` (did the same to the file-fetcher for when you start inspecting elements before everything has streamed in)
- we connect to the backend on [`pagereveal`](https://developer.mozilla.org/en-US/docs/Web/API/Window/pagereveal_event) as well as `pageshow`. [`pageshow` only fires when everything has loaded on initial load](https://developer.mozilla.org/en-US/docs/Web/API/Window/pageshow_event#:~:text=Note%3A%20During%20the%20initial%20page%20load%2C%20the%20pageshow%20event%20fires%20after%20the%20load%20event.). `pagereveal` is not implemented in Firefox just yet.
- the backend manager is now executed [immediately instead of when document idle](https://developer.chrome.com/docs/extensions/reference/api/scripting#type-ScriptInjection)

Once Firefox implements `pagereveal`, you'll be able to inspect the tree before loading has finished.

## Test plan

soft navs, hard navs and backwards/forwards cache in Chrome and Firefox

<details>
<summary>Chrome manual testing</summary>


https://github.com/user-attachments/assets/939f9656-1a0f-4d72-8353-18dea53d079a


</details>


<details>
<summary>Firefox manual testing</summary>


https://github.com/user-attachments/assets/414712a1-bb41-4bd6-8059-366987a24a18


</details>





Changed files:
filename: .eslintrc.js
patch: @@ -577,6 +577,7 @@ module.exports = {
     $AsyncIterator: 'readonly',
     Iterator: 'readonly',
     AsyncIterator: 'readonly',
+    IntervalID: 'readonly',
     IteratorResult: 'readonly',
     JSONValue: 'readonly',
     JSResourceReference: 'readonly',

filename: packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js
patch: @@ -6,7 +6,7 @@ const contentScriptsToInject = [
     js: ['build/proxy.js'],
     matches: ['<all_urls>'],
     persistAcrossSessions: true,
-    runAt: 'document_end',
+    runAt: 'document_start',
     world: chrome.scripting.ExecutionWorld.ISOLATED,
   },
   {

filename: packages/react-devtools-extensions/src/background/executeScript.js
patch: @@ -1,17 +1,41 @@
+/**
+ * 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 */
 
-export function executeScriptInIsolatedWorld({target, files}) {
+export function executeScriptInIsolatedWorld({
+  target,
+  files,
+}: {
+  files: any,
+  target: any,
+}): Promise<void> {
   return chrome.scripting.executeScript({
     target,
     files,
     world: chrome.scripting.ExecutionWorld.ISOLATED,
   });
 }
 
-export function executeScriptInMainWorld({target, files}) {
+export function executeScriptInMainWorld({
+  target,
+  files,
+  injectImmediately,
+}: {
+  files: any,
+  target: any,
+  // It's nice to have this required to make active choices.
+  injectImmediately: boolean,
+}): Promise<void> {
   return chrome.scripting.executeScript({
     target,
     files,
+    injectImmediately,
     world: chrome.scripting.ExecutionWorld.MAIN,
   });
 }

filename: packages/react-devtools-extensions/src/background/messageHandlers.js
patch: @@ -1,5 +1,6 @@
 /* global chrome */
 
+import {__DEBUG__} from 'react-devtools-shared/src/constants';
 import setExtensionIconAndPopup from './setExtensionIconAndPopup';
 import {executeScriptInMainWorld} from './executeScript';
 
@@ -25,6 +26,7 @@ export function handleBackendManagerMessage(message, sender) {
       payload.versions.forEach(version => {
         if (EXTENSION_CONTAINED_VERSIONS.includes(version)) {
           executeScriptInMainWorld({
+            injectImmediately: true,
             target: {tabId: sender.tab.id},
             files: [`/build/react_devtools_backend_${version}.js`],
           });
@@ -79,9 +81,19 @@ export function handleDevToolsPageMessage(message) {
       }
 
       executeScriptInMainWorld({
+        injectImmediately: true,
         target: {tabId},
         files: ['/build/backendManager.js'],
-      });
+      }).then(
+        () => {
+          if (__DEBUG__) {
+            console.log('Successfully injected backend manager');
+          }
+        },
+        reason => {
+          console.error('Failed to inject backend manager:', reason);
+        },
+      );
 
       break;
     }

filename: packages/react-devtools-extensions/src/contentScripts/proxy.js
patch: @@ -1,8 +1,16 @@
+/**
+ * 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 */
 
 'use strict';
 
-window.addEventListener('pageshow', function ({target}) {
+function injectProxy({target}: {target: any}) {
   // 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__) {
@@ -14,15 +22,19 @@ window.addEventListener('pageshow', function ({target}) {
     // 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.
-    const intervalID = setInterval(() => {
+    const intervalID: IntervalID = setInterval(() => {
       if (backendInitialized) {
         clearInterval(intervalID);
       } else {
         sayHelloToBackendManager();
       }
     }, 500);
   }
-});
+}
+
+window.addEventListener('pagereveal', injectProxy);
+// For backwards compat with browsers not implementing `pagereveal` which is a fairly new event.
+window.addEventListener('pageshow', injectProxy);
 
 window.addEventListener('pagehide', function ({target}) {
   if (target !== window.document) {
@@ -45,7 +57,7 @@ function sayHelloToBackendManager() {
   );
 }
 
-function handleMessageFromDevtools(message) {
+function handleMessageFromDevtools(message: any) {
   window.postMessage(
     {
       source: 'react-devtools-content-script',
@@ -55,7 +67,7 @@ function handleMessageFromDevtools(message) {
   );
 }
 
-function handleMessageFromPage(event) {
+function handleMessageFromPage(event: any) {
   if (event.source !== window || !event.data) {
     return;
   }
@@ -65,6 +77,7 @@ function handleMessageFromPage(event) {
     case 'react-devtools-bridge': {
       backendInitialized = true;
 
+      // $FlowFixMe[incompatible-use]
       port.postMessage(event.data.payload);
       break;
     }
@@ -99,6 +112,8 @@ function connectPort() {
 
   window.addEventListener('message', handleMessageFromPage);
 
+  // $FlowFixMe[incompatible-use]
   port.onMessage.addListener(handleMessageFromDevtools);
+  // $FlowFixMe[incompatible-use]
   port.onDisconnect.addListener(handleDisconnect);
 }