diff --git a/.eslintrc.js b/.eslintrc.js index 18a3112e7382b..4f902576ad82c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -577,6 +577,7 @@ module.exports = { $AsyncIterator: 'readonly', Iterator: 'readonly', AsyncIterator: 'readonly', + IntervalID: 'readonly', IteratorResult: 'readonly', JSONValue: 'readonly', JSResourceReference: 'readonly', diff --git a/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js index f1a3598a519ca..3e2f6b7c1ea83 100644 --- a/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js +++ b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js @@ -6,7 +6,7 @@ const contentScriptsToInject = [ js: ['build/proxy.js'], matches: [''], persistAcrossSessions: true, - runAt: 'document_end', + runAt: 'document_start', world: chrome.scripting.ExecutionWorld.ISOLATED, }, { diff --git a/packages/react-devtools-extensions/src/background/executeScript.js b/packages/react-devtools-extensions/src/background/executeScript.js index 8b80095d33c2e..a196a7391cdff 100644 --- a/packages/react-devtools-extensions/src/background/executeScript.js +++ b/packages/react-devtools-extensions/src/background/executeScript.js @@ -1,6 +1,20 @@ +/** + * 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 { return chrome.scripting.executeScript({ target, files, @@ -8,10 +22,20 @@ export function executeScriptInIsolatedWorld({target, files}) { }); } -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 { return chrome.scripting.executeScript({ target, files, + injectImmediately, world: chrome.scripting.ExecutionWorld.MAIN, }); } diff --git a/packages/react-devtools-extensions/src/background/messageHandlers.js b/packages/react-devtools-extensions/src/background/messageHandlers.js index cd07f8afbc5df..0152418633fbb 100644 --- a/packages/react-devtools-extensions/src/background/messageHandlers.js +++ b/packages/react-devtools-extensions/src/background/messageHandlers.js @@ -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; } diff --git a/packages/react-devtools-extensions/src/contentScripts/proxy.js b/packages/react-devtools-extensions/src/contentScripts/proxy.js index 8ffeeffb2af53..02253f65d4a83 100644 --- a/packages/react-devtools-extensions/src/contentScripts/proxy.js +++ b/packages/react-devtools-extensions/src/contentScripts/proxy.js @@ -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,7 +22,7 @@ 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 { @@ -22,7 +30,11 @@ window.addEventListener('pageshow', function ({target}) { } }, 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); } diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 2d092ceb0ff06..d4c3fcd9cad4b 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -987,6 +987,34 @@ describe('Store', () => { `); + + await actAsync(() => { + agent.overrideSuspenseMilestone({ + rendererID, + rootID, + suspendedSet: [], + }); + }); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + + [shell] + + + + + `); }); it('should display a partially rendered SuspenseList', async () => { diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 7fd7000d440cb..545725526d04a 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -7519,6 +7519,9 @@ export function attach( } // TODO: Allow overriding the timeline for the specified root. + forceFallbackForFibers.forEach(fiber => { + scheduleUpdate(fiber); + }); forceFallbackForFibers.clear(); for (let i = 0; i < suspendedSet.length; ++i) { diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index 04916f9a3646a..59666f624edb7 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -126,26 +126,28 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { } function handleChange(event: SyntheticEvent) { - const pendingValue = +event.currentTarget.value; - for (let i = 0; i < timeline.length; i++) { - const forceFallback = i > pendingValue; - const suspense = timeline[i]; - const elementID = suspense.id; - const rendererID = store.getRendererIDForElement(elementID); - if (rendererID === null) { - // TODO: Handle disconnected elements. - console.warn( - `No renderer ID found for element ${elementID} in suspense timeline.`, - ); - } else { - bridge.send('overrideSuspense', { - id: elementID, - rendererID, - forceFallback, - }); - } + if (rootID === undefined) { + return; + } + const rendererID = store.getRendererIDForElement(rootID); + if (rendererID === null) { + console.error( + `No renderer ID found for root element ${rootID} in suspense timeline.`, + ); + return; } + const pendingValue = +event.currentTarget.value; + const suspendedSet = timeline + .slice(pendingValue + 1) + .map(suspense => suspense.id); + + bridge.send('overrideSuspenseMilestone', { + rendererID, + rootID, + suspendedSet, + }); + const suspense = timeline[pendingValue]; const elementID = suspense.id; highlightHostInstance(elementID);