From 15f7cd693e102c568df501eeeb9684f507e0ec0b Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 30 Jul 2026 16:15:13 -0400 Subject: [PATCH 1/4] Add ReactDOM `browser()` API (#37143) ## Summary Adds a new API to `react-dom` called `browser()`. `browser()` returns a "usable" that will error during SSR and resolve during rendering in the browser. The purpose is to allow you to express the idea that a component should suspend on the server but not in the browser. The method is not available inside a `react-server` environment. This is a client only feature. This is a `react-dom` API because the concept of browser doesn't apply generally to React itself. This codifies a pattern that is common in some apps where you error during SSR to prevent rendering some component on the server and you end up suppressing the error that is reported in the client to avoid this appearing like a problem rather than intended behavior. Unfortunately this is not an option for many because hacking around to prevent errors from being logged is not practical for many By making this a React API we enabled this common pattern in any React using library or application ```tsx import {use, Suspense} from 'react'; import {browser} from 'react-dom'; function BrowserOnly() { use(browser()); return ; } function App() { return ( }> ); } ``` It is an error to `use(browser())` outside of a Suspense boundary because you cannot recover from the root. this restriction may be lifted in the future but is part of the current limitations of the API ## Implementation Deferring rendering to a downstream system is modeled in React already as recoverable errors. The idea is that in some environments you might not want to report something directly as an error because a later environment has an opportunity to recover from it without alerting the user to the mishap. This concept also shows up in RSC with halted references. They can "recover" in a later render by eventually resolving to some value. To model the idea of "render in the browser" we are really just modeling an intentional recoverable error. However since you don't want to treat this kind of error as exceptional we intentionally suppress logging. Additionally since aborting a server render is semantically equivalent to "erroring" in every unfinished task we also support aborting with a `browser()` so you can describe ending a stream with intentional holes that won't be logged as errors in the browser when hydrating. One interesting thing we do with this particular API is it returns an object that is isomorphic and it's the `use` or `abort` function that handles differing behaviors. This means you can create these objects in module scope and use them even in complex scenarios like server rendering inside the browser while React is rendering. This implementation is flagged so we can disable the feature quickly if we decide to not ship this in a stable. It is going into React unprefixed for now because the semantics are clear and the utility is widely known. ## Alternatives We considered `useBrowser()` or a similar hook however this means you must call it unconditionally. There are use cases where props might influence whether you want to allow something to render during SSR or not. for instance you might have a data fetching library that accepts initial data on the server but if it doesn't receive initial data it falls back to browser only rendering. Another consideration is a throwing function like just calling `browser()` would throw if called during an SSR render. The main reason we do not think this is a good idea is because you can then call this arbitrarily deep and the throw can be caught and might be suppressed accidentally. By making it a usable it can only be done in hooks or hook-like contexts. --- .../react-debug-tools/src/ReactDebugHooks.js | 16 + .../src/server/ReactFizzConfigDOM.js | 31 +- ...tDOMFizzInstructionSetInlineCodeStrings.js | 2 +- .../ReactDOMFizzInstructionSetShared.js | 2 +- packages/react-dom/index.js | 1 + packages/react-dom/src/ReactDOMFB.js | 1 + packages/react-dom/src/ReactDOMFB.modern.js | 1 + .../src/__tests__/ReactDOMBrowser-test.js | 41 ++ .../src/__tests__/ReactDOMFizzServer-test.js | 367 ++++++++++++++++++ .../react-dom/src/client/ReactDOMClientFB.js | 2 + packages/react-dom/src/shared/ReactDOM.js | 2 + .../react-dom/src/shared/ReactDOMBrowser.js | 34 ++ .../src/ReactFiberBeginWork.js | 39 +- .../react-reconciler/src/ReactFiberHooks.js | 5 + packages/react-server/src/ReactFizzHooks.js | 51 +++ packages/react-server/src/ReactFizzServer.js | 112 +++++- packages/react-server/src/ReactFlightHooks.js | 6 +- packages/shared/ReactFeatureFlags.js | 3 + packages/shared/ReactRecoverable.js | 13 + packages/shared/ReactSymbols.js | 2 + packages/shared/ReactTypes.js | 10 +- .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 2 + scripts/error-codes/codes.json | 6 +- 28 files changed, 701 insertions(+), 53 deletions(-) create mode 100644 packages/react-dom/src/__tests__/ReactDOMBrowser-test.js create mode 100644 packages/react-dom/src/shared/ReactDOMBrowser.js create mode 100644 packages/shared/ReactRecoverable.js diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 8e245a6c47eb..ee964c815fc1 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -35,6 +35,7 @@ import { import { REACT_MEMO_CACHE_SENTINEL, REACT_CONTEXT_TYPE, + REACT_RECOVERABLE_TYPE, } from 'shared/ReactSymbols'; import hasOwnProperty from 'shared/hasOwnProperty'; @@ -110,6 +111,11 @@ function getPrimitiveStackCache(): Map> { $$typeof: REACT_CONTEXT_TYPE, _currentValue: null, } as any); + const recoverable = new Error(); + Object.defineProperty(recoverable as any, '$$typeof', { + value: REACT_RECOVERABLE_TYPE, + }); + Dispatcher.use(recoverable as any); Dispatcher.use({ then() {}, status: 'fulfilled', @@ -240,6 +246,16 @@ function use(usable: Usable): T { dispatcherHookName: 'Use', }); throw SuspenseException; + } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) { + hookLog.push({ + displayName: null, + primitive: 'Recoverable', + stackError: new Error(), + value: undefined, + debugInfo: null, + dispatcherHookName: 'Use', + }); + return undefined as any; } else if (usable.$$typeof === REACT_CONTEXT_TYPE) { const context: ReactContext = usable as any; const value = readContext(context); diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 40a7f855a9fa..4e98c81c903a 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -4707,7 +4707,7 @@ export function writeStartClientRenderedSuspenseBoundary( startClientRenderedSuspenseBoundary, ); writeChunk(destination, clientRenderedSuspenseBoundaryError1); - if (errorDigest) { + if (errorDigest != null) { writeChunk(destination, clientRenderedSuspenseBoundaryError1A); writeChunk(destination, stringToChunk(escapeTextForBrowser(errorDigest))); writeChunk( @@ -5131,6 +5131,7 @@ const clientRenderScript1Full = stringToPrecomputedChunk( const clientRenderScript1Partial = stringToPrecomputedChunk('$RX("'); const clientRenderScript1A = stringToPrecomputedChunk('"'); const clientRenderErrorScriptArgInterstitial = stringToPrecomputedChunk(','); +const clientRenderErrorScriptNull = stringToPrecomputedChunk('null'); const clientRenderScriptEnd = stringToPrecomputedChunk(')'); const clientRenderData1 = stringToPrecomputedChunk( @@ -5182,21 +5183,27 @@ export function writeClientRenderBoundaryInstruction( writeChunk(destination, clientRenderScript1A); } - if (errorDigest || errorMessage || errorStack || errorComponentStack) { + if ( + errorDigest != null || + errorMessage || + errorStack || + errorComponentStack + ) { if (scriptFormat) { - // ,"JSONString" + // ,null or ,"JSONString" writeChunk(destination, clientRenderErrorScriptArgInterstitial); - writeChunk( - destination, - stringToChunk(escapeJSStringsForInstructionScripts(errorDigest || '')), - ); - } else { + if (errorDigest == null) { + writeChunk(destination, clientRenderErrorScriptNull); + } else { + writeChunk( + destination, + stringToChunk(escapeJSStringsForInstructionScripts(errorDigest)), + ); + } + } else if (errorDigest != null) { // " data-dgst="HTMLString writeChunk(destination, clientRenderData2); - writeChunk( - destination, - stringToChunk(escapeTextForBrowser(errorDigest || '')), - ); + writeChunk(destination, stringToChunk(escapeTextForBrowser(errorDigest))); } } if (errorMessage || errorStack || errorComponentStack) { diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 62efb120bdc2..930467ddf183 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -4,7 +4,7 @@ export const markShellTime = 'requestAnimationFrame(function(){$RT=performance.now()});'; export const clientRenderBoundary = - '$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};'; + '$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,null!=c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};'; export const completeBoundary = '$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;ba&&2E3 { + beforeEach(() => { + jest.resetModules(); + }); + + // @gate enableBrowserAPI + it('can create browser-only content before the browser renderer is initialized', async () => { + const React = require('react'); + const ReactDOM = require('react-dom'); + const browserOnly = ReactDOM.browser(); + const ReactDOMClient = require('react-dom/client'); + const {act} = require('internal-test-utils'); + + function BrowserOnly() { + React.use(browserOnly); + return Browser; + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render( + Fallback}> + + , + ); + }); + expect(container.innerHTML).toBe('Browser'); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 8ad9df6f78db..e1f3e7adc461 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -406,6 +406,373 @@ describe('ReactDOMFizzServer', () => { ); } + // @gate enableBrowserAPI + it('can opt a component into browser-only rendering', async () => { + let resolveBrowserText; + const browserText = new Promise(resolve => { + resolveBrowserText = resolve; + }); + const browserOnly = ReactDOM.browser(); + + function BrowserOnly() { + use(browserOnly); + const text = use(browserText); + Scheduler.log(text); + return {text}; + } + + function App() { + return ( +
+ Fallback}> + + +
+ ); + } + + const serverErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onError(error) { + serverErrors.push(error); + }, + }); + pipe(writable); + }); + + expect(serverErrors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ Fallback +
, + ); + const recoverableErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + recoverableErrors.push(error); + }, + }); + await waitForAll([]); + + expect(getVisibleChildren(container)).toEqual( +
+ Fallback +
, + ); + + await clientAct(() => { + resolveBrowserText('Browser'); + }); + assertLog(['Browser']); + + expect(recoverableErrors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ Browser +
, + ); + }); + + // @gate enableBrowserAPI + it('can opt a component into browser-only rendering after streaming the fallback', async () => { + let resolveServerReady; + const serverReady = new Promise(resolve => { + resolveServerReady = resolve; + }); + + function BrowserOnly() { + use(serverReady); + use(ReactDOM.browser()); + return Browser; + } + + function App() { + return ( +
+ Fallback}> + + +
+ ); + } + + const serverErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onError(error) { + serverErrors.push(error); + }, + }); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+ Fallback +
, + ); + + await act(() => { + resolveServerReady(); + }); + + expect(serverErrors).toEqual([]); + + const recoverableErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + recoverableErrors.push(error); + }, + }); + await waitForAll([]); + + expect(recoverableErrors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ Browser +
, + ); + }); + + // @gate enableBrowserAPI + it('errors if browser-only content is rendered outside Suspense', async () => { + function createBrowserValue() { + return ReactDOM.browser(); + } + const browserValue = createBrowserValue(); + + function BrowserOnly() { + use(browserValue); + return Browser; + } + + const reportedErrors = []; + let shellReady = false; + let shellError; + await act(() => { + renderToPipeableStream(, { + onError(error) { + reportedErrors.push(error); + }, + onShellReady() { + shellReady = true; + }, + onShellError(error) { + shellError = error; + }, + }); + }); + + expect(shellError).toBeInstanceOf(Error); + expect(shellError.message).toBe( + 'The server render could not complete because client rendering was ' + + "requested outside a Suspense boundary. See this error's cause for " + + 'additional details.', + ); + expect(shellError.stack).toContain('BrowserOnly'); + expect(shellError.cause).toBe(browserValue); + expect(shellError.cause.stack).toContain('createBrowserValue'); + expect(shellError.cause.message).toContain( + '`use(browser())` can only be used inside a `` boundary', + ); + expect(shellReady).toBe(false); + expect(reportedErrors).toEqual([shellError]); + }); + + // @gate enableBrowserAPI + it('can abort all pending boundaries into browser-only rendering', async () => { + const never = new Promise(() => {}); + let isClient = false; + + function Pending({children}) { + if (!isClient) { + use(never); + } + return {children}; + } + + function App() { + return ( +
+ Shell + Loading A}> + A + + Loading B}> + B + +
+ ); + } + + const serverErrors = []; + let abort; + await act(() => { + const controls = renderToPipeableStream(, { + onError(error) { + serverErrors.push(error); + }, + }); + abort = controls.abort; + controls.pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+ Shell + Loading A + Loading B +
, + ); + + await act(() => { + abort(ReactDOM.browser()); + }); + + expect(serverErrors).toEqual([]); + + isClient = true; + const recoverableErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + recoverableErrors.push(error); + }, + }); + await waitForAll([]); + + expect(recoverableErrors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ Shell + A + B +
, + ); + }); + + // @gate enableBrowserAPI + it('errors if aborted with browser() before the shell completes', async () => { + const never = new Promise(() => {}); + const browserValue = ReactDOM.browser(); + + function PendingRoot() { + use(never); + return Root; + } + + const reportedErrors = []; + let shellReady = false; + let shellError; + let abort; + await act(() => { + const controls = renderToPipeableStream(, { + onError(error) { + reportedErrors.push(error); + }, + onShellReady() { + shellReady = true; + }, + onShellError(error) { + shellError = error; + }, + }); + abort = controls.abort; + }); + + await act(() => { + abort(browserValue); + }); + + expect(shellError).toBeInstanceOf(Error); + expect(shellError.message).toBe( + 'The server render could not complete because client rendering was ' + + "requested outside a Suspense boundary. See this error's cause for " + + 'additional details.', + ); + expect(shellError.cause).toBe(browserValue); + expect(shellReady).toBe(false); + expect(reportedErrors).toEqual([shellError]); + }); + + // @gate enableBrowserAPI + it('reports the browser value if it is thrown instead of passed to use', async () => { + const browserValue = ReactDOM.browser(); + + function BrowserOnly() { + throw browserValue; + } + + const reportedErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream( + Fallback}> + + , + { + onError(error) { + reportedErrors.push(error); + }, + }, + ); + pipe(writable); + }); + + expect(reportedErrors).toEqual([browserValue]); + expect(getVisibleChildren(container)).toEqual(Fallback); + }); + + ['', 'BROWSER'].forEach(userDigest => { + it(`does not reserve the ${JSON.stringify( + userDigest, + )} user error digest for browser rendering`, async () => { + let isClient = false; + const serverError = new Error('Server error'); + + function ServerError() { + if (!isClient) { + throw serverError; + } + return Client; + } + + function App() { + return ( + Fallback}> + + + ); + } + + const serverErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onError(error) { + serverErrors.push(error); + return userDigest; + }, + }); + pipe(writable); + }); + + expect(serverErrors).toEqual([serverError]); + expect(getVisibleChildren(container)).toEqual(Fallback); + + isClient = true; + const recoverableErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + recoverableErrors.push(error); + }, + }); + await waitForAll([]); + + expect(recoverableErrors).toHaveLength(1); + expect(recoverableErrors[0].digest).toBe(userDigest || undefined); + expect(getVisibleChildren(container)).toEqual(Client); + }); + }); + it('should asynchronously load a lazy component', async () => { let resolveA; const LazyA = React.lazy(() => { diff --git a/packages/react-dom/src/client/ReactDOMClientFB.js b/packages/react-dom/src/client/ReactDOMClientFB.js index 58254e64ec8a..b6e2b65b2928 100644 --- a/packages/react-dom/src/client/ReactDOMClientFB.js +++ b/packages/react-dom/src/client/ReactDOMClientFB.js @@ -28,6 +28,7 @@ import ReactVersion from 'shared/ReactVersion'; import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; ensureCorrectIsomorphicReactVersion(); +import {browser} from '../shared/ReactDOMBrowser'; import { getInstanceFromNode, getNodeFromInstance, @@ -125,6 +126,7 @@ function unstable_batchedUpdates(fn: (a: A) => R, a: A): R { } export { + browser, createPortal, unstable_batchedUpdates, flushSync, diff --git a/packages/react-dom/src/shared/ReactDOM.js b/packages/react-dom/src/shared/ReactDOM.js index dc4c73316f6b..062da7c019cb 100644 --- a/packages/react-dom/src/shared/ReactDOM.js +++ b/packages/react-dom/src/shared/ReactDOM.js @@ -28,6 +28,7 @@ import { useFormStatus, useFormState, } from 'react-dom-bindings/src/shared/ReactDOMFormActions'; +import {browser} from './ReactDOMBrowser'; if (__DEV__) { if ( @@ -69,6 +70,7 @@ function createPortal( export { ReactVersion as version, + browser, createPortal, flushSync, batchedUpdates as unstable_batchedUpdates, diff --git a/packages/react-dom/src/shared/ReactDOMBrowser.js b/packages/react-dom/src/shared/ReactDOMBrowser.js new file mode 100644 index 000000000000..caabe635494a --- /dev/null +++ b/packages/react-dom/src/shared/ReactDOMBrowser.js @@ -0,0 +1,34 @@ +/** + * 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 {ReactRecoverable} from 'shared/ReactTypes'; + +import {enableBrowserAPI} from 'shared/ReactFeatureFlags'; +import {REACT_RECOVERABLE_TYPE} from 'shared/ReactSymbols'; + +const browserImpl = function browser(): ReactRecoverable { + // Recoverables are Errors so that a renderer can preserve the browser() call + // site as the cause if no downstream renderer can recover the subtree. + const recoverable = new Error( + "Recoverable Exception: This is not a real error! It's an " + + 'implementation detail of `use(browser())` to defer rendering to the ' + + 'browser. `use(browser())` can only be used inside a `` ' + + 'boundary. If a server render errors with this as its cause, the ' + + 'component that called `use(browser())` does not have a `` ' + + 'boundary above it.', + ); + Object.defineProperty(recoverable as any, '$$typeof', { + value: REACT_RECOVERABLE_TYPE, + }); + return recoverable as any; +}; + +export const browser: (() => ReactRecoverable) | void = enableBrowserAPI + ? browserImpl + : undefined; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 29269e329244..29cb4f21baa6 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -131,6 +131,7 @@ import { REACT_MEMO_TYPE, REACT_CONTEXT_TYPE, } from 'shared/ReactSymbols'; +import {REACT_RECOVERABLE_DIGEST} from 'shared/ReactRecoverable'; import {setCurrentFiber} from './ReactCurrentFiber'; import {resolveTypeForHotReloading} from './ReactFiberHotReloading'; @@ -3001,25 +3002,29 @@ function updateDehydratedSuspenseComponent( ({digest} = getSuspenseInstanceFallbackErrorDetails(suspenseInstance)); } - let error: Error; - if (__DEV__ && message) { - // eslint-disable-next-line react-internal/prod-error-codes - error = new Error(message); - } else { - error = new Error( - 'The server could not finish this Suspense boundary, likely ' + - 'due to an error during server rendering. ' + - 'Switched to client rendering.', + // This is unreachable in renderers that do not support hydration. + // $FlowFixMe[invalid-compare] + if (digest !== REACT_RECOVERABLE_DIGEST) { + let error: Error; + if (__DEV__ && message) { + // eslint-disable-next-line react-internal/prod-error-codes + error = new Error(message); + } else { + error = new Error( + 'The server could not finish this Suspense boundary, likely ' + + 'due to an error during server rendering. ' + + 'Switched to client rendering.', + ); + } + // Replace the stack with the server stack + error.stack = (__DEV__ && stack) || ''; + (error as any).digest = digest; + const capturedValue = createCapturedValueFromError( + error, + componentStack === undefined ? null : componentStack, ); + queueHydrationError(capturedValue); } - // Replace the stack with the server stack - error.stack = (__DEV__ && stack) || ''; - (error as any).digest = digest; - const capturedValue = createCapturedValueFromError( - error, - componentStack === undefined ? null : componentStack, - ); - queueHydrationError(capturedValue); return retrySuspenseComponentWithoutHydrating( current, workInProgress, diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index edb5eacc8cc2..2558e9648b7e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -46,6 +46,7 @@ import { } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, + REACT_RECOVERABLE_TYPE, REACT_MEMO_CACHE_SENTINEL, } from 'shared/ReactSymbols'; @@ -1156,6 +1157,10 @@ function use(usable: Usable): T { // This is a thenable. const thenable: Thenable = usable as any; return useThenable(thenable); + } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) { + // Fiber is the final renderer, so there is no downstream host that + // needs to recover this subtree. Continue rendering through it. + return undefined as any; } else if (usable.$$typeof === REACT_CONTEXT_TYPE) { const context: ReactContext = usable as any; return readContext(context); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 7f2e933c9f9a..3a58b5688f99 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -14,6 +14,7 @@ import type { StartTransitionOptions, Thenable, Usable, + ReactRecoverable, ReactCustomFormAction, Awaited, } from 'shared/ReactTypes'; @@ -41,6 +42,7 @@ import {createFastHash} from './ReactServerStreamConfig'; import is from 'shared/objectIs'; import { REACT_CONTEXT_TYPE, + REACT_RECOVERABLE_TYPE, REACT_MEMO_CACHE_SENTINEL, } from 'shared/ReactSymbols'; import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion'; @@ -89,6 +91,30 @@ let actionStateMatchingIndex: number = -1; // Counts the number of use(thenable) calls in this component let thenableIndexCounter: number = 0; let thenableState: ThenableState | null = null; +// An opaque exception that lets the Fizz work loop distinguish a recoverable +// from an Error thrown by application code. The actual errors are stored +// separately so this implementation detail cannot be mistaken for either +// diagnostic if it is caught by userspace. +export const RecoverableException: mixed = new Error( + "Recoverable Exception: This is not a real error! It's an implementation " + + 'detail of `use` to interrupt the current render so a downstream ' + + 'renderer can recover it. You must either rethrow it immediately, or move ' + + 'the `use` call outside of the `try/catch` block. Capturing without ' + + 'rethrowing will lead to unexpected behavior.', +); +let suspendedRecoverableError: Error | null = null; + +export function createFatalRecoverableError( + recoverable: ReactRecoverable, +): Error { + return new Error( + 'The server render could not complete because client rendering was ' + + "requested outside a Suspense boundary. See this error's cause for " + + 'additional details.', + {cause: recoverable}, + ); +} + // Lazily created map of render-phase updates let renderPhaseUpdates: Map, Update> | null = null; // Counter to prevent infinite loops. @@ -276,6 +302,22 @@ export function getThenableStateAfterSuspending(): null | ThenableState { return state; } +export function getSuspendedRecoverableError(): Error { + if (suspendedRecoverableError === null) { + throw new Error( + 'Expected a suspended recoverable. This is a bug in React. Please file ' + + 'an issue.', + ); + } + const error = suspendedRecoverableError; + suspendedRecoverableError = null; + return error; +} + +export function clearSuspendedRecoverableError(): void { + suspendedRecoverableError = null; +} + export function checkDidRenderIdHook(): boolean { // This should be called immediately after every finishHooks call. // Conceptually, it's part of the return value of finishHooks; it's only a @@ -756,6 +798,15 @@ function use(usable: Usable): T { // This is a thenable. const thenable: Thenable = usable as any; return unwrapThenable(thenable); + } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) { + // Fizz can defer this subtree to a downstream renderer. Like a suspended + // thenable, keep the actual value out of userspace and throw an opaque + // sentinel to unwind the stack. Capture the use() call site eagerly so + // that if there is no Suspense boundary, the fatal error points here and + // its cause points to where the recoverable was created. + const recoverable: ReactRecoverable = usable as any; + suspendedRecoverableError = createFatalRecoverableError(recoverable); + throw RecoverableException; } else if (usable.$$typeof === REACT_CONTEXT_TYPE) { const context: ReactContext = usable as any; return readContext(context); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 9a03f2026303..1af0265ea116 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -28,6 +28,7 @@ import type { SuspenseListProps, SuspenseListRevealOrder, ReactKey, + ReactRecoverable, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { @@ -134,6 +135,10 @@ import { readPreviousThenableFromState, getActionStateCount, getActionStateMatchingIndex, + RecoverableException, + createFatalRecoverableError, + getSuspendedRecoverableError, + clearSuspendedRecoverableError, } from './ReactFizzHooks'; import {DefaultAsyncDispatcher} from './ReactFizzAsyncDispatcher'; import { @@ -173,6 +178,7 @@ import { REACT_VIEW_TRANSITION_TYPE, REACT_ACTIVITY_TYPE, REACT_OPTIMISTIC_KEY, + REACT_RECOVERABLE_TYPE, } from 'shared/ReactSymbols'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -191,6 +197,7 @@ import assign from 'shared/assign'; import noop from 'shared/noop'; import getComponentNameFromType from 'shared/getComponentNameFromType'; import isArray from 'shared/isArray'; +import {REACT_RECOVERABLE_DIGEST} from 'shared/ReactRecoverable'; import { SuspenseException, getSuspendedThenable, @@ -1317,6 +1324,14 @@ function encodeErrorForBoundary( ) { boundary.errorDigest = digest; if (__DEV__) { + if (error === RecoverableException) { + boundary.errorMessage = wasAborted + ? 'Switched to client rendering because the server render was aborted ' + + 'with a request to render on the client.' + : 'Switched to client rendering because a component requested it.'; + boundary.errorComponentStack = thrownInfo.componentStack; + return; + } let message, stack; // In dev we additionally encode the error message and component stack on the boundary if (error instanceof Error) { @@ -1347,6 +1362,11 @@ function logRecoverableError( errorInfo: ThrownInfo, debugTask: null | ConsoleTask, ): ?string { + if (error === RecoverableException) { + clearSuspendedRecoverableError(); + return REACT_RECOVERABLE_DIGEST; + } + // If this callback errors, we intentionally let that error bubble up to become a fatal error // so that someone fixes the error reporting instead of hiding it. const onError = request.onError; @@ -1365,7 +1385,10 @@ function logRecoverableError( } return; } - return errorDigest; + // An empty digest is reserved for React's internal client-render signal. + // Historically an empty digest was omitted from the wire format, so + // normalizing it to undefined preserves the existing user-space semantics. + return errorDigest === '' ? undefined : errorDigest; } function fatalError( @@ -4524,19 +4547,34 @@ function erroredTask( request.allPendingTasks--; - // Report the error to a global handler. // We don't handle halts here because we only halt when prerendering and // when prerendering we should be finishing tasks not erroring them when // they halt or postpone - const errorDigest = logRecoverableError(request, error, errorInfo, debugTask); if (boundary === null) { - fatalError(request, error, errorInfo, debugTask); + // Recoverables can remain silent when a Suspense boundary lets us emit a + // shell and defer its content to a downstream renderer. At the root there + // is no shell to stream, so this is a fatal error and must be reported like + // any other root error. + if (error === RecoverableException) { + const useError = getSuspendedRecoverableError(); + logRecoverableError(request, useError, errorInfo, debugTask); + fatalError(request, useError, errorInfo, debugTask); + } else { + logRecoverableError(request, error, errorInfo, debugTask); + fatalError(request, error, errorInfo, debugTask); + } // The shell fatally errored, so the render can never complete. Return before // the completeAll check below so we don't fire onAllReady for a render that // produced nothing. This mirrors finishAbortedTask, which also returns after // a fatalError on the root. return; } else { + const errorDigest = logRecoverableError( + request, + error, + errorInfo, + debugTask, + ); boundary.pendingTasks--; if (boundary.status !== CLIENT_RENDERED) { boundary.status = CLIENT_RENDERED; @@ -4769,19 +4807,43 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void { } const errorInfo = getThrownInfo(task.componentStack); + // Only abort reasons get this interpretation. Throwing a recoverable + // directly is still an application error; it must be passed to use() or + // abort() for a renderer to recover it. + const isRecoverableAbort = + typeof error === 'object' && + error !== null && + // $FlowFixMe[prop-missing] + error.$$typeof === REACT_RECOVERABLE_TYPE; if (boundary === null) { const replay: null | ReplaySet = task.replay; if (replay === null) { // We didn't complete the root so we have nothing to show. We can close // the request; - if (request.trackedPostpones !== null && segment !== null) { + if ( + !isRecoverableAbort && + request.trackedPostpones !== null && + segment !== null + ) { const trackedPostpones = request.trackedPostpones; // We are aborting a prerender and must treat the shell as halted // We log the error but we still resolve the prerender logRecoverableError(request, error, errorInfo, task.debugTask); trackPostpone(request, trackedPostpones, task, segment); finishedTask(request, null, task.row, segment); + } else if (isRecoverableAbort) { + const recoverable: ReactRecoverable = error as any; + const fatalRecoverableError = createFatalRecoverableError(recoverable); + logRecoverableError( + request, + fatalRecoverableError, + errorInfo, + task.debugTask, + ); + if (request.status !== CLOSING && request.status !== CLOSED) { + fatalError(request, fatalRecoverableError, errorInfo, task.debugTask); + } } else { logRecoverableError(request, error, errorInfo, task.debugTask); if (request.status !== CLOSING && request.status !== CLOSED) { @@ -4796,18 +4858,21 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void { // the ReplaySet. replay.pendingTasks--; if (replay.pendingTasks === 0 && replay.nodes.length > 0) { - const errorDigest = logRecoverableError( - request, - error, - errorInfo, - null, - ); + let errorDigest; + let errorForBoundary; + if (isRecoverableAbort) { + errorDigest = REACT_RECOVERABLE_DIGEST; + errorForBoundary = RecoverableException; + } else { + errorDigest = logRecoverableError(request, error, errorInfo, null); + errorForBoundary = error; + } abortRemainingReplayNodes( request, null, replay.nodes, replay.slots, - error, + errorForBoundary, errorDigest, errorInfo, true, @@ -4823,7 +4888,11 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void { // boundary the message is referring to const trackedPostpones = request.trackedPostpones; if (boundary.status !== CLIENT_RENDERED) { - if (trackedPostpones !== null && segment !== null) { + if ( + !isRecoverableAbort && + trackedPostpones !== null && + segment !== null + ) { // We are aborting a prerender and must halt this boundary. // We treat this like other postpones during prerendering logRecoverableError(request, error, errorInfo, task.debugTask); @@ -4839,14 +4908,19 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void { boundary.status = CLIENT_RENDERED; // We are aborting a render or resume which should put boundaries // into an explicitly client rendered state - const errorDigest = logRecoverableError( - request, - error, + const errorDigest = isRecoverableAbort + ? REACT_RECOVERABLE_DIGEST + : logRecoverableError(request, error, errorInfo, task.debugTask); + const errorForBoundary = isRecoverableAbort + ? RecoverableException + : error; + encodeErrorForBoundary( + boundary, + errorDigest, + errorForBoundary, errorInfo, - task.debugTask, + true, ); - boundary.status = CLIENT_RENDERED; - encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, true); untrackBoundary(request, boundary); diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index 23d15618a885..20ba7ab84896 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -151,7 +151,11 @@ function use(usable: Usable): T { } if (isClientReference(usable)) { - if (usable.value != null && usable.value.$$typeof === REACT_CONTEXT_TYPE) { + const clientReference: any = usable; + if ( + clientReference.value != null && + clientReference.value.$$typeof === REACT_CONTEXT_TYPE + ) { // Show a more specific message since it's a common mistake. throw new Error('Cannot read a Client Context from a Server Component.'); } else { diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index e596bcb0cb4b..45ec1737611a 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -22,6 +22,9 @@ // when it rolls out to prod. We should remove these as soon as possible. // ----------------------------------------------------------------------------- +// Enables the browser() API exported from react-dom. +export const enableBrowserAPI: boolean = true; + // ----------------------------------------------------------------------------- // Land or remove (moderate effort) // diff --git a/packages/shared/ReactRecoverable.js b/packages/shared/ReactRecoverable.js new file mode 100644 index 000000000000..428a263e4abd --- /dev/null +++ b/packages/shared/ReactRecoverable.js @@ -0,0 +1,13 @@ +/** + * 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 + */ + +// Empty digests are otherwise treated as if no digest was provided. This lets +// React distinguish an intentional client render without reserving a +// user-space digest value. +export const REACT_RECOVERABLE_DIGEST = ''; diff --git a/packages/shared/ReactSymbols.js b/packages/shared/ReactSymbols.js index 72d8bc020e2c..a4c87538a382 100644 --- a/packages/shared/ReactSymbols.js +++ b/packages/shared/ReactSymbols.js @@ -46,6 +46,8 @@ export const REACT_VIEW_TRANSITION_TYPE: symbol = Symbol.for( 'react.view_transition', ); +export const REACT_RECOVERABLE_TYPE: symbol = Symbol.for('react.recoverable'); + const MAYBE_ITERATOR_SYMBOL = Symbol.iterator; const FAUX_ITERATOR_SYMBOL = '@@iterator'; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 22f6d42421f5..71487a69ebed 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -144,11 +144,19 @@ export type Thenable = | FulfilledThenable | RejectedThenable; +// A recoverable lets an intermediate renderer defer a subtree to a downstream +// renderer. It does not produce a value: a renderer either continues through +// it or interrupts the current render so that a later renderer can recover the +// subtree. +export type ReactRecoverable = Error & { + $$typeof: symbol, +}; + export type StartTransitionOptions = { name?: string, }; -export type Usable = Thenable | ReactContext; +export type Usable = Thenable | ReactContext | ReactRecoverable; export type ReactCustomFormAction = { name?: string, diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index df30e0985eb8..9b5d9497f858 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -42,6 +42,7 @@ export const enableAsyncDebugInfo: boolean = true; export const enableAsyncIterableChildren: boolean = false; export const enableCPUSuspense: boolean = true; export const enableCreateEventHandleAPI: boolean = false; +export const enableBrowserAPI: boolean = true; export const enableEffectEventMutationPhase: boolean = true; export const enableMoveBefore: boolean = true; export const enableFizzExternalRuntime: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 3a4018e3ad63..30ace506bc38 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -28,6 +28,7 @@ export const enableAsyncDebugInfo: boolean = true; export const enableAsyncIterableChildren: boolean = false; export const enableCPUSuspense: boolean = false; export const enableCreateEventHandleAPI: boolean = false; +export const enableBrowserAPI: boolean = true; export const enableMoveBefore: boolean = true; export const enableFizzExternalRuntime: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index d2c644edd1b3..bbd4530b022e 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -25,6 +25,7 @@ export const disableCommentsAsDOMContainers: boolean = true; export const disableInputAttributeSyncing: boolean = false; export const enableScopeAPI: boolean = false; export const enableCreateEventHandleAPI: boolean = false; +export const enableBrowserAPI: boolean = true; export const enableSuspenseCallback: boolean = false; export const enableTrustedTypesIntegration: boolean = true; export const disableTextareaChildren: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 15a0c6dded71..dd45f875a28e 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -23,6 +23,7 @@ export const enableAsyncDebugInfo = true; export const enableAsyncIterableChildren = false; export const enableCPUSuspense = true; export const enableCreateEventHandleAPI = false; +export const enableBrowserAPI = true; export const enableMoveBefore = false; export const enableFizzExternalRuntime = true; export const enableInfiniteRenderLoopDetection = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 5a4b56501898..c7f9c591d69e 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -25,6 +25,7 @@ export const disableCommentsAsDOMContainers: boolean = true; export const disableInputAttributeSyncing: boolean = false; export const enableScopeAPI: boolean = true; export const enableCreateEventHandleAPI: boolean = false; +export const enableBrowserAPI: boolean = true; export const enableSuspenseCallback: boolean = true; export const disableLegacyContext: boolean = false; export const disableLegacyContextForFunctionComponents: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 3bb77341f0af..a600292c9652 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -80,6 +80,8 @@ export const disableCommentsAsDOMContainers: boolean = false; export const enableCreateEventHandleAPI: boolean = true; +export const enableBrowserAPI: boolean = true; + export const enableEffectEventMutationPhase: boolean = true; export const enableScopeAPI: boolean = true; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index c5be606813f5..fb7e296452cf 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -587,5 +587,9 @@ "599": "Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React.", "600": "A rejected Promise was passed to React without a `reason` property. React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`.", "601": "A chunk pair is incomplete. This is a bug in React.", - "602": "Cannot handle action key. This is a bug in React." + "602": "Cannot handle action key. This is a bug in React.", + "603": "Recoverable Exception: This is not a real error! It's an implementation detail of `use(browser())` to defer rendering to the browser. `use(browser())` can only be used inside a `` boundary. If a server render errors with this as its cause, the component that called `use(browser())` does not have a `` boundary above it.", + "604": "The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.", + "605": "Recoverable Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render so a downstream renderer can recover it. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.", + "606": "Expected a suspended recoverable. This is a bug in React. Please file an issue." } From c56a44903f91d41dce51107f34491daabdcb6a70 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:17:48 +0100 Subject: [PATCH 2/4] [DevTools] Remove FlowFixMe from extension lifecycle (#37152) I've been triaging some lifecycle races during sessions with BFCache involved and noticed a few type errors in real world scenario. These objects were not properly typed, fixing in this PR. --- .../src/main/index.js | 133 ++++++++++-------- 1 file changed, 74 insertions(+), 59 deletions(-) diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index d8373090366f..66721258b1bd 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -67,6 +67,13 @@ type PendingBridgeMessage = { transferable?: $ReadOnlyArray, }; +type DevToolsInstance = { + bridge: FrontendBridge, + store: Store, + render: (overrideTab?: TabID) => void, + root: RootType, +}; + function flushPendingBridgeMessages(): void { const currentPort = port; if (!isBridgeConnected || currentPort === null) { @@ -128,8 +135,8 @@ function addBridgePortListener(nextPort: ExtensionRuntimePort): void { bridgePortListener = nextBridgePortListener; } -function createBridge() { - bridge = new Bridge({ +function createBridge(): FrontendBridge { + const bridge: FrontendBridge = new Bridge({ listen(fn) { const currentPort = port; if (currentPort === null) { @@ -199,7 +206,13 @@ function createBridge() { }, }; // Rerender with the new file selection. - render(); + const instance = devToolsInstance; + if (instance === null) { + throw new Error( + 'Cannot sync source selection: DevTools instance is not initialized.', + ); + } + instance.render(); } else { // Update the ref to the latest position without updating the url. No need to rerender. const selectionRef = currentSelectedSource.selectionRef; @@ -229,14 +242,16 @@ function createBridge() { onBrowserSourceSelectionChanged, ); } + + return bridge; } -function createBridgeAndStore() { - createBridge(); +function createDevToolsInstance(): DevToolsInstance { + const bridge = createBridge(); const {isProfiling} = getProfilingFlags(); - store = new Store(bridge, { + const store = new Store(bridge, { isProfiling, supportsReloadAndProfile: __IS_CHROME__ || __IS_EDGE__, // At this time, the timeline can only parse Chrome performance profiles. @@ -285,9 +300,9 @@ function createBridgeAndStore() { ); }; - root = createRoot(document.createElement('div')); + const root = createRoot(document.createElement('div')); - render = (overrideTab: TabID | null = mostRecentOverrideTab) => { + const render = (overrideTab: TabID | null = mostRecentOverrideTab) => { mostRecentOverrideTab = overrideTab; root.render( @@ -314,6 +329,8 @@ function createBridgeAndStore() { }), ); }; + + return {bridge, store, render, root}; } function ensureInitialHTMLIsCleared( @@ -327,11 +344,11 @@ function ensureInitialHTMLIsCleared( container._hasInitialHTMLBeenCleared = true; } -function createComponentsPanel() { +function createComponentsPanel(instance: DevToolsInstance) { if (componentsPortalContainer) { // Panel is created and user opened it at least once ensureInitialHTMLIsCleared(componentsPortalContainer); - render('components'); + instance.render('components'); return; } @@ -350,10 +367,11 @@ function createComponentsPanel() { createdPanel.onShown.addListener(portal => { componentsPortalContainer = portal.container; - if (componentsPortalContainer != null && render) { + const currentInstance = devToolsInstance; + if (componentsPortalContainer != null && currentInstance !== null) { ensureInitialHTMLIsCleared(componentsPortalContainer); - render('components'); + currentInstance.render('components'); portal.injectStyles(cloneStyleTags); logEvent({event_name: 'selected-components-tab'}); @@ -361,20 +379,20 @@ function createComponentsPanel() { }); createdPanel.onShown.addListener(() => { - bridge.emit('extensionComponentsPanelShown'); + devToolsInstance?.bridge.emit('extensionComponentsPanelShown'); }); createdPanel.onHidden.addListener(() => { - bridge.emit('extensionComponentsPanelHidden'); + devToolsInstance?.bridge.emit('extensionComponentsPanelHidden'); }); }, ); } -function createElementsInspectPanel() { +function createElementsInspectPanel(instance: DevToolsInstance) { if (inspectedElementPortalContainer) { // Panel is created and user opened it at least once ensureInitialHTMLIsCleared(inspectedElementPortalContainer); - render(); + instance.render(); return; } @@ -403,11 +421,12 @@ function createElementsInspectPanel() { createdPane.onShown.addListener(portal => { inspectedElementPortalContainer = portal.container; - if (inspectedElementPortalContainer != null && render) { + const currentInstance = devToolsInstance; + if (inspectedElementPortalContainer != null && currentInstance !== null) { ensureInitialHTMLIsCleared(inspectedElementPortalContainer); - bridge.send('syncSelectionFromBuiltinElementsPanel'); + currentInstance.bridge.send('syncSelectionFromBuiltinElementsPanel'); - render(); + currentInstance.render(); portal.injectStyles(cloneStyleTags); logEvent({event_name: 'selected-inspected-element-pane'}); @@ -416,11 +435,11 @@ function createElementsInspectPanel() { }); } -function createProfilerPanel() { +function createProfilerPanel(instance: DevToolsInstance) { if (profilerPortalContainer) { // Panel is created and user opened it at least once ensureInitialHTMLIsCleared(profilerPortalContainer); - render('profiler'); + instance.render('profiler'); return; } @@ -439,10 +458,11 @@ function createProfilerPanel() { createdPanel.onShown.addListener(portal => { profilerPortalContainer = portal.container; - if (profilerPortalContainer != null && render) { + const currentInstance = devToolsInstance; + if (profilerPortalContainer != null && currentInstance !== null) { ensureInitialHTMLIsCleared(profilerPortalContainer); - render('profiler'); + currentInstance.render('profiler'); portal.injectStyles(cloneStyleTags); logEvent({event_name: 'selected-profiler-tab'}); @@ -452,11 +472,11 @@ function createProfilerPanel() { ); } -function createSourcesEditorPanel() { +function createSourcesEditorPanel(instance: DevToolsInstance) { if (editorPortalContainer) { // Panel is created and user opened it at least once ensureInitialHTMLIsCleared(editorPortalContainer); - render(); + instance.render(); return; } @@ -480,10 +500,11 @@ function createSourcesEditorPanel() { createdPane.onShown.addListener(portal => { editorPortalContainer = portal.container; - if (editorPortalContainer != null && render) { + const currentInstance = devToolsInstance; + if (editorPortalContainer != null && currentInstance !== null) { ensureInitialHTMLIsCleared(editorPortalContainer); - render(); + currentInstance.render(); portal.injectStyles(cloneStyleTags); logEvent({event_name: 'selected-editor-pane'}); @@ -492,11 +513,11 @@ function createSourcesEditorPanel() { }); } -function createSuspensePanel() { +function createSuspensePanel(instance: DevToolsInstance) { if (suspensePortalContainer) { // Panel is created and user opened it at least once ensureInitialHTMLIsCleared(suspensePortalContainer); - render('suspense'); + instance.render('suspense'); return; } @@ -515,10 +536,11 @@ function createSuspensePanel() { createdPanel.onShown.addListener(portal => { suspensePortalContainer = portal.container; - if (suspensePortalContainer != null && render) { + const currentInstance = devToolsInstance; + if (suspensePortalContainer != null && currentInstance !== null) { ensureInitialHTMLIsCleared(suspensePortalContainer); - render('suspense'); + currentInstance.render('suspense'); portal.injectStyles(cloneStyleTags); logEvent({event_name: 'selected-suspense-tab'}); @@ -532,10 +554,10 @@ function performInTabNavigationCleanup() { // Potentially, if react hasn't loaded yet and user performs in-tab navigation clearReactPollingInstance(); - // $FlowFixMe[invalid-compare] - if (store !== null) { + const instance = devToolsInstance; + if (instance !== null) { // Store profiling data, so it can be used later - profilingData = store.profilerStore.profilingData; + profilingData = instance.store.profilerStore.profilingData; } // If panels were already created, and we have already mounted React root to display @@ -544,17 +566,17 @@ function performInTabNavigationCleanup() { (componentsPortalContainer || profilerPortalContainer || suspensePortalContainer) && - root + instance !== null ) { // It's easiest to recreate the DevTools panel (to clean up potential stale state). // We can revisit this in the future as a small optimization. // This should also emit bridge.shutdown, but only if this root was mounted - flushSync(() => root.unmount()); + flushSync(() => instance.root.unmount()); } else { // In case Browser DevTools were opened, but user never pressed on extension panels // They were never mounted and there is nothing to unmount, but we need to emit shutdown event // because bridge was already created - bridge?.shutdown(); + instance?.bridge.shutdown(); } // Do not nullify componentsPanelPortal and profilerPanelPortal on purpose, @@ -565,10 +587,7 @@ function performInTabNavigationCleanup() { // Do not clean mostRecentOverrideTab on purpose, so we remember last opened // React DevTools tab, when user does in-tab navigation - store = null as $FlowFixMe; - bridge = null as $FlowFixMe; - render = null as $FlowFixMe; - root = null as $FlowFixMe; + devToolsInstance = null; pendingBridgeMessages.length = 0; } @@ -576,27 +595,25 @@ function performFullCleanup() { // Potentially, if react hasn't loaded yet and user closed the browser DevTools clearReactPollingInstance(); + const instance = devToolsInstance; if ( (componentsPortalContainer || profilerPortalContainer || suspensePortalContainer) && - root + instance !== null ) { // This should also emit bridge.shutdown, but only if this root was mounted - flushSync(() => root.unmount()); + flushSync(() => instance.root.unmount()); } else { - bridge?.shutdown(); + instance?.bridge.shutdown(); } componentsPortalContainer = null; profilerPortalContainer = null; suspensePortalContainer = null; - root = null as $FlowFixMe; mostRecentOverrideTab = null; - store = null as $FlowFixMe; - bridge = null as $FlowFixMe; - render = null as $FlowFixMe; + devToolsInstance = null; pendingBridgeMessages.length = 0; port?.disconnect(); @@ -646,13 +663,14 @@ function mountReactDevTools() { registerEventsLogger(); - createBridgeAndStore(); + const instance = createDevToolsInstance(); + devToolsInstance = instance; - createComponentsPanel(); - createProfilerPanel(); - createSourcesEditorPanel(); - createElementsInspectPanel(); - createSuspensePanel(); + createComponentsPanel(instance); + createProfilerPanel(instance); + createSourcesEditorPanel(instance); + createElementsInspectPanel(instance); + createSuspensePanel(instance); } let reactPollingInstance = null; @@ -689,11 +707,10 @@ function mountReactDevToolsWhenReactHasLoaded() { ); } -let bridge: FrontendBridge = null as $FlowFixMe; +let devToolsInstance: DevToolsInstance | null = null; let lastSubscribedBridgeListener: ((message: mixed) => void) | null = null; let subscribedBridgePort: ExtensionRuntimePort | null = null; let bridgePortListener: ((message: mixed) => void) | null = null; -let store: Store = null as $FlowFixMe; let profilingData = null; @@ -709,8 +726,6 @@ let editorPortalContainer = null; let inspectedElementPortalContainer = null; let mostRecentOverrideTab: null | TabID = null; -let render: (overrideTab?: TabID) => void = null as $FlowFixMe; -let root: RootType = null as $FlowFixMe; let currentSelectedSource: null | SourceSelection = null; @@ -751,7 +766,7 @@ mountReactDevToolsWhenReactHasLoaded(); function onThemeChanged() { // Rerender with the new theme - render(); + devToolsInstance?.render(); } if (chrome.devtools.panels.setThemeChangeHandler) { From 7acf00566e35c40817342654438a5757c6c41d0e Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:17:48 +0100 Subject: [PATCH 3/4] [DevTools] Create extension panels before React detection (#37151) The main reason why I am doing this is because there is no API in the browser to "unmount" the created panel. If you have React DevTools installed, we should always create a panel, but the contents of the panel should be dynamically populated based on the target. If it is not a React app, we will continue showing the stub message. Previously, we wouldn't mount a panel at all, and historically we've received a few reports of this as a bug. --- .../src/main/index.js | 158 +++++++++--------- 1 file changed, 81 insertions(+), 77 deletions(-) diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index 66721258b1bd..f0504cc24c6a 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -344,15 +344,7 @@ function ensureInitialHTMLIsCleared( container._hasInitialHTMLBeenCleared = true; } -function createComponentsPanel(instance: DevToolsInstance) { - if (componentsPortalContainer) { - // Panel is created and user opened it at least once - ensureInitialHTMLIsCleared(componentsPortalContainer); - instance.render('components'); - - return; - } - +function createComponentsPanel() { if (componentsPanel) { // Panel is created, but wasn't opened yet, so no document is present for it return; @@ -368,13 +360,15 @@ function createComponentsPanel(instance: DevToolsInstance) { createdPanel.onShown.addListener(portal => { componentsPortalContainer = portal.container; const currentInstance = devToolsInstance; - if (componentsPortalContainer != null && currentInstance !== null) { - ensureInitialHTMLIsCleared(componentsPortalContainer); + if (componentsPortalContainer != null) { + if (currentInstance !== null) { + ensureInitialHTMLIsCleared(componentsPortalContainer); - currentInstance.render('components'); - portal.injectStyles(cloneStyleTags); + currentInstance.render('components'); - logEvent({event_name: 'selected-components-tab'}); + logEvent({event_name: 'selected-components-tab'}); + } + portal.injectStyles(cloneStyleTags); } }); @@ -388,15 +382,7 @@ function createComponentsPanel(instance: DevToolsInstance) { ); } -function createElementsInspectPanel(instance: DevToolsInstance) { - if (inspectedElementPortalContainer) { - // Panel is created and user opened it at least once - ensureInitialHTMLIsCleared(inspectedElementPortalContainer); - instance.render(); - - return; - } - +function createElementsInspectPanel() { if (inspectedElementPane) { // Panel is created, but wasn't opened yet, so no document is present for it return; @@ -422,28 +408,22 @@ function createElementsInspectPanel(instance: DevToolsInstance) { createdPane.onShown.addListener(portal => { inspectedElementPortalContainer = portal.container; const currentInstance = devToolsInstance; - if (inspectedElementPortalContainer != null && currentInstance !== null) { - ensureInitialHTMLIsCleared(inspectedElementPortalContainer); - currentInstance.bridge.send('syncSelectionFromBuiltinElementsPanel'); + if (inspectedElementPortalContainer != null) { + if (currentInstance !== null) { + ensureInitialHTMLIsCleared(inspectedElementPortalContainer); + currentInstance.bridge.send('syncSelectionFromBuiltinElementsPanel'); - currentInstance.render(); - portal.injectStyles(cloneStyleTags); + currentInstance.render(); - logEvent({event_name: 'selected-inspected-element-pane'}); + logEvent({event_name: 'selected-inspected-element-pane'}); + } + portal.injectStyles(cloneStyleTags); } }); }); } -function createProfilerPanel(instance: DevToolsInstance) { - if (profilerPortalContainer) { - // Panel is created and user opened it at least once - ensureInitialHTMLIsCleared(profilerPortalContainer); - instance.render('profiler'); - - return; - } - +function createProfilerPanel() { if (profilerPanel) { // Panel is created, but wasn't opened yet, so no document is present for it return; @@ -459,28 +439,22 @@ function createProfilerPanel(instance: DevToolsInstance) { createdPanel.onShown.addListener(portal => { profilerPortalContainer = portal.container; const currentInstance = devToolsInstance; - if (profilerPortalContainer != null && currentInstance !== null) { - ensureInitialHTMLIsCleared(profilerPortalContainer); + if (profilerPortalContainer != null) { + if (currentInstance !== null) { + ensureInitialHTMLIsCleared(profilerPortalContainer); - currentInstance.render('profiler'); - portal.injectStyles(cloneStyleTags); + currentInstance.render('profiler'); - logEvent({event_name: 'selected-profiler-tab'}); + logEvent({event_name: 'selected-profiler-tab'}); + } + portal.injectStyles(cloneStyleTags); } }); }, ); } -function createSourcesEditorPanel(instance: DevToolsInstance) { - if (editorPortalContainer) { - // Panel is created and user opened it at least once - ensureInitialHTMLIsCleared(editorPortalContainer); - instance.render(); - - return; - } - +function createSourcesEditorPanel() { if (editorPane) { // Panel is created, but wasn't opened yet, so no document is present for it return; @@ -501,27 +475,21 @@ function createSourcesEditorPanel(instance: DevToolsInstance) { createdPane.onShown.addListener(portal => { editorPortalContainer = portal.container; const currentInstance = devToolsInstance; - if (editorPortalContainer != null && currentInstance !== null) { - ensureInitialHTMLIsCleared(editorPortalContainer); + if (editorPortalContainer != null) { + if (currentInstance !== null) { + ensureInitialHTMLIsCleared(editorPortalContainer); - currentInstance.render(); - portal.injectStyles(cloneStyleTags); + currentInstance.render(); - logEvent({event_name: 'selected-editor-pane'}); + logEvent({event_name: 'selected-editor-pane'}); + } + portal.injectStyles(cloneStyleTags); } }); }); } -function createSuspensePanel(instance: DevToolsInstance) { - if (suspensePortalContainer) { - // Panel is created and user opened it at least once - ensureInitialHTMLIsCleared(suspensePortalContainer); - instance.render('suspense'); - - return; - } - +function createSuspensePanel() { if (suspensePanel) { // Panel is created, but wasn't opened yet, so no document is present for it return; @@ -537,19 +505,57 @@ function createSuspensePanel(instance: DevToolsInstance) { createdPanel.onShown.addListener(portal => { suspensePortalContainer = portal.container; const currentInstance = devToolsInstance; - if (suspensePortalContainer != null && currentInstance !== null) { - ensureInitialHTMLIsCleared(suspensePortalContainer); + if (suspensePortalContainer != null) { + if (currentInstance !== null) { + ensureInitialHTMLIsCleared(suspensePortalContainer); - currentInstance.render('suspense'); - portal.injectStyles(cloneStyleTags); + currentInstance.render('suspense'); - logEvent({event_name: 'selected-suspense-tab'}); + logEvent({event_name: 'selected-suspense-tab'}); + } + portal.injectStyles(cloneStyleTags); } }); }, ); } +function createDevToolsPanels(): void { + createComponentsPanel(); + createProfilerPanel(); + createSourcesEditorPanel(); + createElementsInspectPanel(); + createSuspensePanel(); +} + +function renderOpenedDevToolsPanels(instance: DevToolsInstance): void { + if (componentsPortalContainer) { + ensureInitialHTMLIsCleared(componentsPortalContainer); + instance.render('components'); + } + + if (profilerPortalContainer) { + ensureInitialHTMLIsCleared(profilerPortalContainer); + instance.render('profiler'); + } + + if (editorPortalContainer) { + ensureInitialHTMLIsCleared(editorPortalContainer); + instance.render(); + } + + if (inspectedElementPortalContainer) { + ensureInitialHTMLIsCleared(inspectedElementPortalContainer); + instance.bridge.send('syncSelectionFromBuiltinElementsPanel'); + instance.render(); + } + + if (suspensePortalContainer) { + ensureInitialHTMLIsCleared(suspensePortalContainer); + instance.render('suspense'); + } +} + function performInTabNavigationCleanup() { // Potentially, if react hasn't loaded yet and user performs in-tab navigation clearReactPollingInstance(); @@ -666,11 +672,7 @@ function mountReactDevTools() { const instance = createDevToolsInstance(); devToolsInstance = instance; - createComponentsPanel(instance); - createProfilerPanel(instance); - createSourcesEditorPanel(instance); - createElementsInspectPanel(instance); - createSuspensePanel(instance); + renderOpenedDevToolsPanels(instance); } let reactPollingInstance = null; @@ -743,7 +745,7 @@ const debouncedMountReactDevToolsCallback = debounce( 500, ); -// Clean up everything, but start mounting React DevTools panels if user stays at this page +// Clean up everything, but remount React DevTools if user stays at this page function onNavigatedToOtherPage() { performInTabNavigationCleanup(); debouncedMountReactDevToolsCallback(); @@ -762,6 +764,8 @@ if (__IS_FIREFOX__) { connectExtensionPort(); +createDevToolsPanels(); + mountReactDevToolsWhenReactHasLoaded(); function onThemeChanged() { From 0f42eac25e44090837e92304ac08d8d430ceef0a Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:17:49 +0100 Subject: [PATCH 4/4] [DevTools] Reset extension backend on pagehide (#37155) I have noticed inconsistency errors being thrown during browser navigations that involve entries from BFCache. The main argument on why this could be affecting React DevTools backend lifecycle is the fact that Chrome kills the port manually, while freezing and preserving the JavaScript heap - https://developer.chrome.com/blog/bfcache-extension-messaging-changes. Basically, we could end up in a permutation, where port is dead, but Backend / Agent are alive. Such setup is not expected by React DevTools. On `main`: https://github.com/user-attachments/assets/9ca10286-b545-4384-bd6b-33d9a4ddde3d With these changes: https://github.com/user-attachments/assets/c4639c4a-6385-4248-a5c4-a39895d7fff6 I couldn't come up with a good test for this yet, but I will try to add something. I am not convinced yet that emulating `pagehide` / `pageshow` would be sufficient to reproduce browser environment during BFCache entries. --- .../src/contentScripts/backendManager.js | 81 +++++++++++++++++-- .../src/contentScripts/proxy.js | 29 ++++++- 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/packages/react-devtools-extensions/src/contentScripts/backendManager.js b/packages/react-devtools-extensions/src/contentScripts/backendManager.js index 5589bcc1cb61..3d8fcc21dd37 100644 --- a/packages/react-devtools-extensions/src/contentScripts/backendManager.js +++ b/packages/react-devtools-extensions/src/contentScripts/backendManager.js @@ -22,6 +22,37 @@ import { let welcomeHasInitialized = false; const requiredBackends = new Set(); +const activeBackendsShutdownCallbacks = new Set<() => void>(); +let cleanupBackendManagerSetup: (() => void) | null = null; +let hasShutdownBackendManager = false; + +function finishBackendManagerShutdown() { + if (hasShutdownBackendManager) { + return; + } + hasShutdownBackendManager = true; + + window.removeEventListener('message', welcome); + window.removeEventListener('pagehide', handlePageHide); + + const cleanup = cleanupBackendManagerSetup; + cleanupBackendManagerSetup = null; + cleanup?.(); + + delete window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__; +} + +function handlePageHide() { + // A document in the back-forward cache keeps its JavaScript heap but loses + // its extension messaging port. Shut down locally while the document is + // still active so a restored page can attach a new Agent and replay its tree. + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const shutdownBackend of activeBackendsShutdownCallbacks) { + shutdownBackend(); + } + + finishBackendManagerShutdown(); +} function welcome(event: $FlowFixMe) { if ( @@ -89,11 +120,26 @@ function setup(hook: ?DevToolsHook) { }, ); - const unsubscribeShutdownListener: () => void = hook.sub('shutdown', () => { + let didCleanup = false; + let unsubscribeShutdownListener: (() => void) | null = null; + const cleanup = () => { + if (didCleanup) { + return; + } + didCleanup = true; + unsubscribeRendererListener(); unsubscribeBackendInstallationListener(); - unsubscribeShutdownListener(); - }); + unsubscribeShutdownListener?.(); + unsubscribeShutdownListener = null; + + if (cleanupBackendManagerSetup === cleanup) { + cleanupBackendManagerSetup = null; + } + }; + + unsubscribeShutdownListener = hook.sub('shutdown', cleanup); + cleanupBackendManagerSetup = cleanup; } function registerRenderer(renderer: ReactRenderer, hook: DevToolsHook) { @@ -115,6 +161,7 @@ function activateBackend(version: string, hook: DevToolsHook) { } const {Agent, Bridge, initBackend, setupNativeStyleEditor} = backend; + let shouldSendMessages = true; const bridge = new Bridge({ listen(fn) { const listener = (event: $FlowFixMe) => { @@ -134,6 +181,10 @@ function activateBackend(version: string, hook: DevToolsHook) { }; }, send(event: string, payload: mixed, transferable?: $ReadOnlyArray) { + if (!shouldSendMessages) { + return; + } + window.postMessage( { source: 'react-devtools-bridge', @@ -154,11 +205,28 @@ function activateBackend(version: string, hook: DevToolsHook) { // Clean up flags, so that next reload won't start profiling onReloadAndProfileFlagsReset(); + let hasShutdownBackend = false; + const shutdownBackend = () => { + if (hasShutdownBackend) { + return; + } + hasShutdownBackend = true; + shouldSendMessages = false; + + bridge.shutdown(); + }; + activeBackendsShutdownCallbacks.add(shutdownBackend); + agent.addListener('shutdown', () => { - // If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down, - // and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge.shutdown()` here. + hasShutdownBackend = true; + shouldSendMessages = false; + activeBackendsShutdownCallbacks.delete(shutdownBackend); + hook.emit('shutdown'); - delete window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__; + + if (activeBackendsShutdownCallbacks.size === 0) { + finishBackendManagerShutdown(); + } }); initBackend(hook, agent, window, getIsReloadAndProfileSupported()); @@ -207,4 +275,5 @@ if (!window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__) { window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__ = true; window.addEventListener('message', welcome); + window.addEventListener('pagehide', handlePageHide); } diff --git a/packages/react-devtools-extensions/src/contentScripts/proxy.js b/packages/react-devtools-extensions/src/contentScripts/proxy.js index 99e4080a3445..0469023089bb 100644 --- a/packages/react-devtools-extensions/src/contentScripts/proxy.js +++ b/packages/react-devtools-extensions/src/contentScripts/proxy.js @@ -17,6 +17,8 @@ import { } 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__) { @@ -29,9 +31,9 @@ function injectProxy() { // 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: IntervalID = setInterval(() => { + backendManagerHelloIntervalID = setInterval(() => { if (backendInitialized) { - clearInterval(intervalID); + stopPollingForBackendManager(); } else { sayHelloToBackendManager(); } @@ -62,14 +64,29 @@ window.addEventListener('pagehide', function ({target}) { 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) { @@ -116,7 +133,7 @@ function handleMessageFromDevtools( sourcePort: ExtensionRuntimePort, message: mixed, ) { - if (port !== sourcePort) { + if (!isTransportActive || port !== sourcePort) { return; } @@ -153,7 +170,7 @@ function handleMessageFromDevtools( } function handleMessageFromPage(event: any) { - if (event.source !== window || !event.data) { + if (!isTransportActive || event.source !== window || !event.data) { return; } @@ -206,6 +223,10 @@ function handleDisconnect(disconnectedPort: ExtensionRuntimePort) { // 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',