[DevTools] Name root "Transition" when focusing on Activity (#35108)

This commit is contained in:
Sebastian "Sebbie" Silbermann
2025-11-18 10:16:58 +01:00
committed by GitHub
parent 7f1a085b28
commit 194c12d949
6 changed files with 192 additions and 39 deletions

View File

@@ -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<SuspenseTimelineStep> {
const target: Array<SuspenseTimelineStep> = [];
@@ -990,6 +991,76 @@ export default class Store extends EventEmitter<{
return target;
}
_pushSuspenseChildrenInDocumentOrder(
children: Array<Element['id']>,
target: Array<SuspenseNode['id']>,
): 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<SuspenseNode['id']> {
const transitionChildren: Array<SuspenseNode['id']> = [];
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<SuspenseTimelineStep> {
const target: Array<SuspenseTimelineStep> = [];
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<SuspenseNode['id']>,
target: Array<SuspenseTimelineStep>,
@@ -1045,7 +1116,14 @@ export default class Store extends EventEmitter<{
uniqueSuspendersOnly: boolean,
): $ReadOnlyArray<SuspenseTimelineStep> {
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<number, number> = new Map();
const removedSuspenseIDs: Map<SuspenseNode['id'], SuspenseNode['id']> =
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,

View File

@@ -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 {
<ol className={styles.SuspenseBreadcrumbsList}>
{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 ? (
<li
@@ -51,9 +55,12 @@ export default function SuspenseBreadcrumbs(): React$Node {
aria-current="true">
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={handleClick.bind(null, roots[0])}
onClick={handleClick.bind(
null,
activityID === null ? roots[0] : activityID,
)}
type="button">
Initial Paint
{activityID === null ? 'Initial Paint' : 'Transition'}
</button>
</li>
) : null

View File

@@ -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 <SuspenseRectsRoot key={rootID} rootID={rootID} />;
});
}
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 (
<SuspenseRects
key={suspenseID}
suspenseID={suspenseID}
parentRects={null}
/>
);
});
}
const ViewBox = createContext<Rect>((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<SuspenseNode['id']> | 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({
<div
className={
styles.SuspenseRectsContainer +
(hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '') +
(hasRootSuspenders &&
// We don't want to draw attention to the root if we're looking at a Transition.
// TODO: Draw bounding rect of Transition and check if the Transition
// has unique suspenders.
activityID === null
? ' ' + styles.SuspenseRectsRoot
: '') +
(isRootSelected ? ' ' + styles.SuspenseRectsRootOutline : '') +
' ' +
getClassNameForEnvironment(rootEnvironment)
@@ -548,9 +595,11 @@ function SuspenseRectsContainer({
<div
className={styles.SuspenseRectsViewBox}
style={{aspectRatio, width}}>
{roots.map(rootID => {
return <SuspenseRectsRoot key={rootID} rootID={rootID} />;
})}
{activityID === null ? (
<SuspenseRectsInitialPaint />
) : (
<SuspenseRectsTransition id={activityID} />
)}
{selectedBoundingBox !== null ? (
<ScaledRect
className={

View File

@@ -12,13 +12,15 @@ import type {SuspenseTimelineStep} from 'react-devtools-shared/src/frontend/type
import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
import {useRef} from 'react';
import {useContext, useRef} from 'react';
import {ElementTypeRoot} from 'react-devtools-shared/src/frontend/types';
import styles from './SuspenseScrubber.css';
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
import Tooltip from '../Components/reach-ui/tooltip';
import {StoreContext} from '../context';
export default function SuspenseScrubber({
min,
@@ -43,6 +45,7 @@ export default function SuspenseScrubber({
onHoverSegment: (index: number) => 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'

View File

@@ -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,
};

View File

@@ -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 (
<React.Activity name="root" mode="visible">
<Root>
<React.Activity name="outer" mode="visible">
<OuterSegment>
<React.Activity name="inner" mode="visible">
<InnerSegment>
<React.Activity name="slot" mode="visible">
<Page />
</React.Activity>
</InnerSegment>
</React.Activity>
</OuterSegment>
</React.Activity>
</Root>
</React.Activity>
<>
<p>{dynamicData}</p>
<React.Activity name="root" mode="visible">
<Root>
<React.Activity name="outer" mode="visible">
<OuterSegment>
<React.Activity name="inner" mode="visible">
<InnerSegment>
<React.Activity name="slot" mode="visible">
<Page />
</React.Activity>
</InnerSegment>
</React.Activity>
</OuterSegment>
</React.Activity>
</Root>
</React.Activity>
</>
);
}