From a34c5dff159a5b546a5b24a93e11102713a7d0ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 10 Sep 2025 09:07:11 -0400 Subject: [PATCH 1/4] Ignore generic InvalidStateError in View Transitions (#34450) Fixes #34098. There's an issue in Chrome where the `InvalidStateError` always has the same error message. The spec doesn't specify the error message to use but it's more useful to have a specific one for each case like Safari does. One reason it's better to have a specific error message is because the browser console is not the main surface that people look for errors. Chrome relies on a separate log also in the console. Frameworks has built-in error dialogs that pop up first and that's where you see the error and that dialog can't show something specific. Additionally, these errors can't log something specific to servers in production logging. So this is a bad strategy. It's not good to have those error dialogs pop up for non-actionable errors like when it doesn't start because the document was hidden. Since we don't have more specific information we have no choice but to hide all of them. This includes actionable things like duplicate names (although we also have a React specific warning for that in the common case). --- .../src/client/ReactFiberConfigDOM.js | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 74b03d8fe0577..53fc89305245d 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2046,24 +2046,17 @@ function customizeViewTransitionError( error.message === 'Skipping view transition because document visibility state has become hidden.' || error.message === - 'Skipping view transition because viewport size changed.' + 'Skipping view transition because viewport size changed.' || + // Chrome uses a generic error message instead of specific reasons. It will log a + // more specific reason in the console but the user might not look there. + // Some of these errors are important to surface like duplicate name errors but + // it's too noisy for unactionable cases like the document was hidden. Therefore, + // we hide all of them and hopefully it surfaces in another browser. + error.message === 'Transition was aborted because of invalid state' ) { // Skip logging this. This is not considered an error. return null; } - if (__DEV__) { - if ( - error.message === 'Transition was aborted because of invalid state' - ) { - // Chrome doesn't include the reason in the message but logs it in the console.. - // Redirect the user to look there. - // eslint-disable-next-line react-internal/prod-error-codes - return new Error( - 'A ViewTransition could not start. See the console for more details.', - {cause: error}, - ); - } - } break; } } From 288d428af1bcce6b89d004aed9f5c9d24c4bbd33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 10 Sep 2025 09:08:36 -0400 Subject: [PATCH 2/4] [DevTools] Only show the highest end/byteSize I/O of RSC streams (#34435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #34425. RSC stream info is split into one I/O entry per chunk. This means that when a single instance or boundary depends on multiple chunks, it'll show the same stream multiple times. This makes it so just the last one is shown. This is a special case for the name "RSC stream" but ideally we'd more explicitly model the concept of awaiting only part of a stream. Screenshot 2025-09-09 at 2 09 43 PM Another remaining issue is that it's possible for an intermediate chunk to be depended on by just a child boundary. In that case that can be considered a "unique suspender" even though the parent depends on a later one. Ideally it would dedupe on everything below. Could also model it as every Promise depends on its chunk and every previous chunk. --- .../src/backend/fiber/renderer.js | 109 +++++++++++++++--- 1 file changed, 96 insertions(+), 13 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 2102dc1926bc3..536df1f6b4521 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -5733,6 +5733,15 @@ export function attach( // to a specific instance will have those appear in order of when that instance was discovered. let hooksCacheKey: null | DevToolsInstance = null; let hooksCache: null | HooksTree = null; + // Collect the stream entries with the highest byte offset and end time. + const streamEntries: Map< + Promise, + { + asyncInfo: ReactAsyncInfo, + instance: DevToolsInstance, + hooks: null | HooksTree, + }, + > = new Map(); suspenseNode.suspendedBy.forEach((set, ioInfo) => { let parentNode = suspenseNode.parent; while (parentNode !== null) { @@ -5771,9 +5780,92 @@ export function attach( } } } - result.push(serializeAsyncInfo(asyncInfo, firstInstance, hooks)); + const newIO = asyncInfo.awaited; + if (newIO.name === 'RSC stream' && newIO.value != null) { + const streamPromise = newIO.value; + // Special case RSC stream entries to pick the last entry keyed by the stream. + const existingEntry = streamEntries.get(streamPromise); + if (existingEntry === undefined) { + streamEntries.set(streamPromise, { + asyncInfo, + instance: firstInstance, + hooks, + }); + } else { + const existingIO = existingEntry.asyncInfo.awaited; + if ( + newIO !== existingIO && + ((newIO.byteSize !== undefined && + existingIO.byteSize !== undefined && + newIO.byteSize > existingIO.byteSize) || + newIO.end > existingIO.end) + ) { + // The new entry is later in the stream that the old entry. Replace it. + existingEntry.asyncInfo = asyncInfo; + existingEntry.instance = firstInstance; + existingEntry.hooks = hooks; + } + } + } else { + result.push(serializeAsyncInfo(asyncInfo, firstInstance, hooks)); + } + } + } + }); + // Add any deduped stream entries. + streamEntries.forEach(({asyncInfo, instance, hooks}) => { + result.push(serializeAsyncInfo(asyncInfo, instance, hooks)); + }); + return result; + } + + function getSuspendedByOfInstance( + devtoolsInstance: DevToolsInstance, + hooks: null | HooksTree, + ): Array { + const suspendedBy = devtoolsInstance.suspendedBy; + if (suspendedBy === null) { + return []; + } + + const foundIOEntries: Set = new Set(); + const streamEntries: Map, ReactAsyncInfo> = new Map(); + const result: Array = []; + for (let i = 0; i < suspendedBy.length; i++) { + const asyncInfo = suspendedBy[i]; + const ioInfo = asyncInfo.awaited; + if (foundIOEntries.has(ioInfo)) { + // We have already added this I/O entry to the result. We can dedupe it. + // This can happen when an instance depends on the same data in mutliple places. + continue; + } + foundIOEntries.add(ioInfo); + if (ioInfo.name === 'RSC stream' && ioInfo.value != null) { + const streamPromise = ioInfo.value; + // Special case RSC stream entries to pick the last entry keyed by the stream. + const existingEntry = streamEntries.get(streamPromise); + if (existingEntry === undefined) { + streamEntries.set(streamPromise, asyncInfo); + } else { + const existingIO = existingEntry.awaited; + if ( + ioInfo !== existingIO && + ((ioInfo.byteSize !== undefined && + existingIO.byteSize !== undefined && + ioInfo.byteSize > existingIO.byteSize) || + ioInfo.end > existingIO.end) + ) { + // The new entry is later in the stream that the old entry. Replace it. + streamEntries.set(streamPromise, asyncInfo); + } } + } else { + result.push(serializeAsyncInfo(asyncInfo, devtoolsInstance, hooks)); } + } + // Add any deduped stream entries. + streamEntries.forEach(asyncInfo => { + result.push(serializeAsyncInfo(asyncInfo, devtoolsInstance, hooks)); }); return result; } @@ -6297,11 +6389,7 @@ export function attach( // In this case, this becomes associated with the Client/Host Component where as normally // you'd expect these to be associated with the Server Component that awaited the data. // TODO: Prepend other suspense sources like css, images and use(). - fiberInstance.suspendedBy === null - ? [] - : fiberInstance.suspendedBy.map(info => - serializeAsyncInfo(info, fiberInstance, hooks), - ); + getSuspendedByOfInstance(fiberInstance, hooks); const suspendedByRange = getSuspendedByRange( getNearestSuspenseNode(fiberInstance), ); @@ -6446,7 +6534,7 @@ export function attach( const isSuspended = null; // Things that Suspended this Server Component (use(), awaits and direct child promises) - const suspendedBy = virtualInstance.suspendedBy; + const suspendedBy = getSuspendedByOfInstance(virtualInstance, null); const suspendedByRange = getSuspendedByRange( getNearestSuspenseNode(virtualInstance), ); @@ -6497,12 +6585,7 @@ export function attach( ? [] : Array.from(componentLogsEntry.warnings.entries()), - suspendedBy: - suspendedBy === null - ? [] - : suspendedBy.map(info => - serializeAsyncInfo(info, virtualInstance, null), - ), + suspendedBy: suspendedBy, suspendedByRange: suspendedByRange, unknownSuspenders: UNKNOWN_SUSPENDERS_NONE, From 886b3d36d7994259df2c3ab1983f425a4b718615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 10 Sep 2025 09:44:51 -0400 Subject: [PATCH 3/4] [DevTools] Show suspended by subtree from Activity to next Suspense boundary (#34438) Stacked on #34435. This adds a method to get all suspended by filtered by a specific Instance. The purpose of this is to power the feature when you filter by Activity. This would show you the "root" within that Activity boundary. This works by selecting the nearest Suspense boundary parent and then filtering its data based on if all the instances for a given I/O info is within the Activity instance. If something suspended within the Suspense boundary but outside the Activity it's not included even if it's also suspending inside the Activity since we assume it would've already been loaded then. Right now I wire this up to be a special case when you select an Activity boundary same as when you select a Suspense boundary in the Components tab but we could also only use this when you select the root in the Suspense tab for example. --- .../src/backend/fiber/renderer.js | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 536df1f6b4521..f190f2704d77e 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -5721,6 +5721,7 @@ export function attach( function getSuspendedByOfSuspenseNode( suspenseNode: SuspenseNode, + filterByChildInstance: null | DevToolsInstance, // only include suspended by instances in this subtree ): Array { // Collect all ReactAsyncInfo that was suspending this SuspenseNode but // isn't also in any parent set. @@ -5756,8 +5757,30 @@ export function attach( if (set.size === 0) { return; } - const firstInstance: DevToolsInstance = (set.values().next().value: any); - if (firstInstance.suspendedBy !== null) { + let firstInstance: null | DevToolsInstance = null; + if (filterByChildInstance === null) { + firstInstance = (set.values().next().value: any); + } else { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const childInstance of set.values()) { + if (firstInstance === null) { + firstInstance = childInstance; + } + if ( + childInstance !== filterByChildInstance && + !isChildOf( + filterByChildInstance, + childInstance, + suspenseNode.instance, + ) + ) { + // Something suspended on this outside the filtered instance. That means that + // it is not unique to just this filtered instance so we skip including it. + return; + } + } + } + if (firstInstance !== null && firstInstance.suspendedBy !== null) { const asyncInfo = getAwaitInSuspendedByFromIO( firstInstance.suspendedBy, ioInfo, @@ -5870,6 +5893,23 @@ export function attach( return result; } + function getSuspendedByOfInstanceSubtree( + devtoolsInstance: DevToolsInstance, + ): Array { + // Get everything suspending below this instance down to the next Suspense node. + // First find the parent Suspense boundary which will have accumulated everything + let suspenseParentInstance = devtoolsInstance; + while (suspenseParentInstance.suspenseNode === null) { + if (suspenseParentInstance.parent === null) { + // We don't expect to hit this. We should always find the root. + return []; + } + suspenseParentInstance = suspenseParentInstance.parent; + } + const suspenseNode: SuspenseNode = suspenseParentInstance.suspenseNode; + return getSuspendedByOfSuspenseNode(suspenseNode, devtoolsInstance); + } + const FALLBACK_THROTTLE_MS: number = 300; function getSuspendedByRange( @@ -6383,13 +6423,17 @@ export function attach( fiberInstance.suspenseNode !== null ? // If this is a Suspense boundary, then we include everything in the subtree that might suspend // this boundary down to the next Suspense boundary. - getSuspendedByOfSuspenseNode(fiberInstance.suspenseNode) - : // This set is an edge case where if you pass a promise to a Client Component into a children - // position without a Server Component as the direct parent. E.g.
{promise}
- // In this case, this becomes associated with the Client/Host Component where as normally - // you'd expect these to be associated with the Server Component that awaited the data. - // TODO: Prepend other suspense sources like css, images and use(). - getSuspendedByOfInstance(fiberInstance, hooks); + getSuspendedByOfSuspenseNode(fiberInstance.suspenseNode, null) + : tag === ActivityComponent + ? // For Activity components we show everything that suspends the subtree down to the next boundary + // so that you can see what suspends a Transition at that level. + getSuspendedByOfInstanceSubtree(fiberInstance) + : // This set is an edge case where if you pass a promise to a Client Component into a children + // position without a Server Component as the direct parent. E.g.
{promise}
+ // In this case, this becomes associated with the Client/Host Component where as normally + // you'd expect these to be associated with the Server Component that awaited the data. + // TODO: Prepend other suspense sources like css, images and use(). + getSuspendedByOfInstance(fiberInstance, hooks); const suspendedByRange = getSuspendedByRange( getNearestSuspenseNode(fiberInstance), ); From e2ba45bb39bd744454ee599bdc2df497c79d9707 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Wed, 10 Sep 2025 18:38:47 +0100 Subject: [PATCH 4/4] [DevTools] fix: keep search query in a local sync state (#34423) When the search query changes, we kick off a transition that updates the search query in a reducer for TreeContext. The search input is also using this value for an `input` HTML element. For a larger applications, sometimes there is a noticeable delay in displaying the updated search query. This changes the approach to also keep a local synchronous state that is being updated on a change callback. --- .../views/Components/ComponentSearchInput.js | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js b/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js index f84e29bf7302f..654fe76918371 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js @@ -8,22 +8,34 @@ */ import * as React from 'react'; -import {useContext} from 'react'; -import {TreeDispatcherContext, TreeStateContext} from './TreeContext'; +import {useState, useContext, useCallback} from 'react'; -import SearchInput from '../SearchInput'; +import SearchInput from 'react-devtools-shared/src/devtools/views/SearchInput'; +import { + TreeDispatcherContext, + TreeStateContext, +} from 'react-devtools-shared/src/devtools/views/Components/TreeContext'; -type Props = {}; +export default function ComponentSearchInput(): React.Node { + const [localSearchQuery, setLocalSearchQuery] = useState(''); + const {searchIndex, searchResults} = useContext(TreeStateContext); + const transitionDispatch = useContext(TreeDispatcherContext); -export default function ComponentSearchInput(props: Props): React.Node { - const {searchIndex, searchResults, searchText} = useContext(TreeStateContext); - const dispatch = useContext(TreeDispatcherContext); - - const search = (text: string) => - dispatch({type: 'SET_SEARCH_TEXT', payload: text}); - const goToNextResult = () => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}); - const goToPreviousResult = () => - dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}); + const search = useCallback( + (text: string) => { + setLocalSearchQuery(text); + transitionDispatch({type: 'SET_SEARCH_TEXT', payload: text}); + }, + [setLocalSearchQuery, transitionDispatch], + ); + const goToNextResult = useCallback( + () => transitionDispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}), + [transitionDispatch], + ); + const goToPreviousResult = useCallback( + () => transitionDispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}), + [transitionDispatch], + ); return ( );