From ba6590dd7c18bb01aa9f4187e56aa0de798218f6 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 4 Sep 2025 10:49:16 +0200 Subject: [PATCH 1/2] [DevTools] Rerender boundaries when they unsuspend when advancing the timeline (#34359) --- .../src/__tests__/store-test.js | 28 ++++++++++++++ .../src/backend/fiber/renderer.js | 3 ++ .../views/SuspenseTab/SuspenseTimeline.js | 38 ++++++++++--------- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 2d092ceb0ff..d4c3fcd9cad 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 7fd7000d440..545725526d0 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 04916f9a364..59666f624ed 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); From 5a31758ed626d1c8aa68b0d2491256020094993a Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 4 Sep 2025 12:21:06 +0200 Subject: [PATCH 2/2] [DevTools] Allow inspection before streaming has finished in Chrome (#34360) --- .eslintrc.js | 1 + .../dynamicallyInjectContentScripts.js | 2 +- .../src/background/executeScript.js | 28 +++++++++++++++++-- .../src/background/messageHandlers.js | 14 +++++++++- .../src/contentScripts/proxy.js | 25 +++++++++++++---- 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 18a3112e738..4f902576ad8 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 f1a3598a519..3e2f6b7c1ea 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 8b80095d33c..a196a7391cd 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 cd07f8afbc5..0152418633f 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 8ffeeffb2af..02253f65d4a 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); }