diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 2102dc1926bc3..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. @@ -5733,6 +5734,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) { @@ -5747,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, @@ -5771,13 +5803,113 @@ 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; } + 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( @@ -6291,17 +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(). - fiberInstance.suspendedBy === null - ? [] - : fiberInstance.suspendedBy.map(info => - serializeAsyncInfo(info, 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), ); @@ -6446,7 +6578,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 +6629,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, 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 ( ); 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; } }