From 5ea7cb77f693f3b0b59fd7d658ba4d23a91d88b3 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Mon, 10 Nov 2025 17:40:32 +0100 Subject: [PATCH] [DevTools] Name root "Transition" when focusing on Activity --- .../src/devtools/store.js | 90 ++++++++++++++++++- .../views/SuspenseTab/SuspenseBreadcrumbs.js | 17 ++-- .../views/SuspenseTab/SuspenseRects.js | 69 +++++++++++--- .../views/SuspenseTab/SuspenseScrubber.js | 15 +++- .../src/frontend/types.js | 6 +- .../src/app/Segments/index.js | 34 +++---- 6 files changed, 192 insertions(+), 39 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index b68dbdc952398..6ddaedb7981e3 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -189,6 +189,8 @@ export default class Store extends EventEmitter<{ {errorCount: number, warningCount: number}, > = new Map(); + _focusedTransition: 0 | Element['id'] = 0; + // At least one of the injected renderers contains (DEV only) owner metadata. _hasOwnerMetadata: boolean = false; @@ -935,10 +937,9 @@ export default class Store extends EventEmitter<{ } /** - * @param rootID * @param uniqueSuspendersOnly Filters out boundaries without unique suspenders */ - getSuspendableDocumentOrderSuspense( + getSuspendableDocumentOrderSuspenseInitialPaint( uniqueSuspendersOnly: boolean, ): Array { const target: Array = []; @@ -990,6 +991,76 @@ export default class Store extends EventEmitter<{ return target; } + _pushSuspenseChildrenInDocumentOrder( + children: Array, + target: Array, + ): void { + for (let i = 0; i < children.length; i++) { + const childID = children[i]; + const suspense = this.getSuspenseByID(childID); + if (suspense !== null) { + target.push(suspense.id); + } else { + const childElement = this.getElementByID(childID); + if (childElement !== null) { + this._pushSuspenseChildrenInDocumentOrder( + childElement.children, + target, + ); + } + } + } + } + + getSuspenseChildren(id: Element['id']): Array { + const transitionChildren: Array = []; + + const root = this._idToElement.get(id); + if (root === undefined) { + return transitionChildren; + } + + this._pushSuspenseChildrenInDocumentOrder( + root.children, + transitionChildren, + ); + + return transitionChildren; + } + + /** + * @param uniqueSuspendersOnly Filters out boundaries without unique suspenders + */ + getSuspendableDocumentOrderSuspenseTransition( + uniqueSuspendersOnly: boolean, + ): Array { + const target: Array = []; + const focusedTransitionID = this._focusedTransition; + if (focusedTransitionID === null) { + return target; + } + + target.push({ + id: focusedTransitionID, + // TODO: Get environment for Activity + environment: null, + endTime: 0, + }); + + const transitionChildren = this.getSuspenseChildren(focusedTransitionID); + + this.pushTimelineStepsInDocumentOrder( + transitionChildren, + target, + uniqueSuspendersOnly, + // TODO: Get environment for Activity + [], + 0, // Don't pass a minimum end time at the root. The root is always first so doesn't matter. + ); + + return target; + } + pushTimelineStepsInDocumentOrder( children: Array, target: Array, @@ -1045,7 +1116,14 @@ export default class Store extends EventEmitter<{ uniqueSuspendersOnly: boolean, ): $ReadOnlyArray { const timeline = - this.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly); + this._focusedTransition === 0 + ? this.getSuspendableDocumentOrderSuspenseInitialPaint( + uniqueSuspendersOnly, + ) + : this.getSuspendableDocumentOrderSuspenseTransition( + uniqueSuspendersOnly, + ); + if (timeline.length === 0) { return timeline; } @@ -1271,7 +1349,7 @@ export default class Store extends EventEmitter<{ const removedElementIDs: Map = new Map(); const removedSuspenseIDs: Map = new Map(); - let nextActivitySliceID = null; + let nextActivitySliceID: Element['id'] | null = null; let i = 2; @@ -2146,6 +2224,10 @@ export default class Store extends EventEmitter<{ } } + if (nextActivitySliceID !== null) { + this._focusedTransition = nextActivitySliceID; + } + this.emit('mutated', [ addedElementIDs, removedElementIDs, diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js index b27e0f5987565..2ad235d577783 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js @@ -12,7 +12,10 @@ import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/Syntheti import * as React from 'react'; import {useContext} from 'react'; -import {TreeDispatcherContext} from '../Components/TreeContext'; +import { + TreeDispatcherContext, + TreeStateContext, +} from '../Components/TreeContext'; import {StoreContext} from '../context'; import {useHighlightHostInstance} from '../hooks'; import styles from './SuspenseBreadcrumbs.css'; @@ -23,6 +26,7 @@ import { export default function SuspenseBreadcrumbs(): React$Node { const store = useContext(StoreContext); + const {activityID} = useContext(TreeStateContext); const treeDispatch = useContext(TreeDispatcherContext); const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); const {selectedSuspenseID, lineage, roots} = useContext( @@ -42,8 +46,8 @@ export default function SuspenseBreadcrumbs(): React$Node {
    {lineage === null ? null : lineage.length === 0 ? ( // We selected the root. This means that we're currently viewing the Transition - // that rendered the whole screen. In laymans terms this is really "Initial Paint". - // TODO: Once we add subtree selection, then the equivalent should be called + // that rendered the whole screen. In laymans terms this is really "Initial Paint" . + // When we're looking at a subtree selection, then the equivalent is a // "Transition" since in that case it's really about a Transition within the page. roots.length > 0 ? (
  1. ) : null diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index f9ea6fd1f3279..69d4767a489fe 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -9,6 +9,7 @@ import type Store from 'react-devtools-shared/src/devtools/store'; import type { + Element, SuspenseNode, Rect, } from 'react-devtools-shared/src/frontend/types'; @@ -18,7 +19,7 @@ import typeof { } from 'react-dom-bindings/src/events/SyntheticEvent'; import * as React from 'react'; -import {createContext, useContext, useLayoutEffect} from 'react'; +import {createContext, useContext, useLayoutEffect, useMemo} from 'react'; import { TreeDispatcherContext, TreeStateContext, @@ -426,6 +427,30 @@ function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node { }); } +function SuspenseRectsInitialPaint(): React$Node { + const {roots} = useContext(SuspenseTreeStateContext); + return roots.map(rootID => { + return ; + }); +} + +function SuspenseRectsTransition({id}: {id: Element['id']}): React$Node { + const store = useContext(StoreContext); + const children = useMemo(() => { + return store.getSuspenseChildren(id); + }, [id, store]); + + return children.map(suspenseID => { + return ( + + ); + }); +} + const ViewBox = createContext((null: any)); function SuspenseRectsContainer({ @@ -434,14 +459,25 @@ function SuspenseRectsContainer({ scaleRef: {current: number}, }): React$Node { const store = useContext(StoreContext); - const {inspectedElementID} = useContext(TreeStateContext); + const {activityID, inspectedElementID} = useContext(TreeStateContext); const treeDispatch = useContext(TreeDispatcherContext); const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); // TODO: This relies on a full re-render of all children when the Suspense tree changes. const {roots, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} = useContext(SuspenseTreeStateContext); - // TODO: bbox does not consider uniqueSuspendersOnly filter + const activityChildren: $ReadOnlyArray | null = + useMemo(() => { + if (activityID === null) { + return null; + } + return store.getSuspenseChildren(activityID); + }, [activityID, store]); + const transitionChildren = + activityChildren === null ? roots : activityChildren; + + // We're using the bounding box of the entire document to anchor the Transition + // in the actual document. const boundingBox = getDocumentBoundingRect(store, roots); const boundingBoxWidth = boundingBox.width; @@ -456,14 +492,18 @@ function SuspenseRectsContainer({ // Already clicked on an inner rect return; } - if (roots.length === 0) { + if (transitionChildren.length === 0) { // Nothing to select return; } const arbitraryRootID = roots[0]; + const transitionRoot = activityID === null ? arbitraryRootID : activityID; event.preventDefault(); - treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: arbitraryRootID}); + treeDispatch({ + type: 'SELECT_ELEMENT_BY_ID', + payload: transitionRoot, + }); suspenseTreeDispatch({ type: 'SET_SUSPENSE_LINEAGE', payload: arbitraryRootID, @@ -483,7 +523,8 @@ function SuspenseRectsContainer({ } const isRootSelected = roots.includes(inspectedElementID); - const isRootHovered = hoveredTimelineIndex === 0; + // When we're focusing a Transition, the first timeline step will not be a root. + const isRootHovered = activityID === null && hoveredTimelineIndex === 0; let hasRootSuspenders = false; if (!uniqueSuspendersOnly) { @@ -536,7 +577,13 @@ function SuspenseRectsContainer({
    - {roots.map(rootID => { - return ; - })} + {activityID === null ? ( + + ) : ( + + )} {selectedBoundingBox !== null ? ( void, onHoverLeave: () => void, }): React$Node { + const store = useContext(StoreContext); const inputRef = useRef(); function handleChange(event: SyntheticEvent) { const newValue = +event.currentTarget.value; @@ -60,12 +63,16 @@ export default function SuspenseScrubber({ } const steps = []; for (let index = min; index <= max; index++) { - const environment = timeline[index].environment; + const step = timeline[index]; + const environment = step.environment; + const element = store.getElementByID(step.id); const label = index === min ? // The first step in the timeline is always a Transition (Initial Paint). - 'Initial Paint' + - (environment === null ? '' : ' (' + environment + ')') + element === null || element.type === ElementTypeRoot + ? 'Initial Paint' + : 'Transition' + + (environment === null ? '' : ' (' + environment + ')') : // TODO: Consider adding the name of this specific boundary if this step has only one. environment === null ? 'Suspense' diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index 8719d46e219c8..a78831cf229b8 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -204,7 +204,11 @@ export type Rect = { }; export type SuspenseTimelineStep = { - id: SuspenseNode['id'], // TODO: Will become a group. + /** + * The first step is either a host root (initial paint) or Activity (Transition). + * Subsequent steps are always Suspense nodes. + */ + id: SuspenseNode['id'] | Element['id'], // TODO: Will become a group. environment: null | string, endTime: number, }; diff --git a/packages/react-devtools-shell/src/app/Segments/index.js b/packages/react-devtools-shell/src/app/Segments/index.js index 3b3ab25d4d232..0677cfff777f8 100644 --- a/packages/react-devtools-shell/src/app/Segments/index.js +++ b/packages/react-devtools-shell/src/app/Segments/index.js @@ -75,22 +75,26 @@ function Root({children}: {children: React.Node}): React.Node { ); } +const dynamicData = deferred(10, 'Dynamic Data: 📈📉📊', 'dynamicData'); export default function Segments(): React.Node { return ( - - - - - - - - - - - - - - - + <> +

    {dynamicData}

    + + + + + + + + + + + + + + + + ); }