[DevTools] Batch Suspense toggles when advancing the Suspense timeline (#34251)

This commit is contained in:
Sebastian "Sebbie" Silbermann
2025-08-26 17:22:30 +02:00
committed by GitHub
parent cacc20e37c
commit cb1e73be04
13 changed files with 342 additions and 43 deletions

View File

@@ -901,6 +901,94 @@ describe('Store', () => {
`);
});
// @reactVersion >= 18.0
it('can override multiple Suspense simultaneously', async () => {
const Component = () => {
return <div>Hello</div>;
};
const App = () => (
<React.Fragment>
<Component key="Outside" />
<React.Suspense
name="parent"
fallback={<Component key="Parent Fallback" />}>
<Component key="Unrelated at Start" />
<React.Suspense
name="one"
fallback={<Component key="Suspense 1 Fallback" />}>
<Component key="Suspense 1 Content" />
</React.Suspense>
<React.Suspense
name="two"
fallback={<Component key="Suspense 2 Fallback" />}>
<Component key="Suspense 2 Content" />
</React.Suspense>
<React.Suspense
name="three"
fallback={<Component key="Suspense 3 Fallback" />}>
<Component key="Suspense 3 Content" />
</React.Suspense>
<Component key="Unrelated at End" />
</React.Suspense>
</React.Fragment>
);
await actAsync(() => render(<App />));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<Component key="Outside">
▾ <Suspense name="parent">
<Component key="Unrelated at Start">
▾ <Suspense name="one">
<Component key="Suspense 1 Content">
▾ <Suspense name="two">
<Component key="Suspense 2 Content">
▾ <Suspense name="three">
<Component key="Suspense 3 Content">
<Component key="Unrelated at End">
[shell]
<Suspense name="parent" rects={[{x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}]}>
<Suspense name="one" rects={[{x:1,y:2,width:5,height:1}]}>
<Suspense name="two" rects={[{x:1,y:2,width:5,height:1}]}>
<Suspense name="three" rects={[{x:1,y:2,width:5,height:1}]}>
`);
const rendererID = getRendererID();
const rootID = store.getRootIDForElement(store.getElementIDAtIndex(0));
await actAsync(() => {
agent.overrideSuspenseMilestone({
rendererID,
rootID,
suspendedSet: [
store.getElementIDAtIndex(4),
store.getElementIDAtIndex(8),
],
});
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<Component key="Outside">
▾ <Suspense name="parent">
<Component key="Unrelated at Start">
▾ <Suspense name="one">
<Component key="Suspense 1 Fallback">
▾ <Suspense name="two">
<Component key="Suspense 2 Content">
▾ <Suspense name="three">
<Component key="Suspense 3 Fallback">
<Component key="Unrelated at End">
[shell]
<Suspense name="parent" rects={[{x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}]}>
<Suspense name="one" rects={[{x:1,y:2,width:5,height:1}]}>
<Suspense name="two" rects={[{x:1,y:2,width:5,height:1}]}>
<Suspense name="three" rects={[{x:1,y:2,width:5,height:1}]}>
`);
});
it('should display a partially rendered SuspenseList', async () => {
const Loading = () => <div>Loading...</div>;
const SuspendingComponent = () => {

View File

@@ -130,6 +130,12 @@ type OverrideSuspenseParams = {
forceFallback: boolean,
};
type OverrideSuspenseMilestoneParams = {
rendererID: number,
rootID: number,
suspendedSet: Array<number>,
};
type PersistedSelection = {
rendererID: number,
path: Array<PathFrame>,
@@ -198,6 +204,10 @@ export default class Agent extends EventEmitter<{
bridge.addListener('logElementToConsole', this.logElementToConsole);
bridge.addListener('overrideError', this.overrideError);
bridge.addListener('overrideSuspense', this.overrideSuspense);
bridge.addListener(
'overrideSuspenseMilestone',
this.overrideSuspenseMilestone,
);
bridge.addListener('overrideValueAtPath', this.overrideValueAtPath);
bridge.addListener('reloadAndProfile', this.reloadAndProfile);
bridge.addListener('renamePath', this.renamePath);
@@ -556,6 +566,21 @@ export default class Agent extends EventEmitter<{
}
};
overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({
rendererID,
rootID,
suspendedSet,
}) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(
`Invalid renderer id "${rendererID}" to override suspense milestone`,
);
} else {
renderer.overrideSuspenseMilestone(rootID, suspendedSet);
}
};
overrideValueAtPath: OverrideValueAtPathParams => void = ({
hookID,
id,

View File

@@ -2366,6 +2366,7 @@ export function attach(
!isProductionBuildOfRenderer && StrictModeBits !== 0 ? 1 : 0,
);
pushOperation(hasOwnerMetadata ? 1 : 0);
pushOperation(supportsTogglingSuspense ? 1 : 0);
if (isProfiling) {
if (displayNamesByRootID !== null) {
@@ -7455,13 +7456,6 @@ export function attach(
}
function overrideSuspense(id: number, forceFallback: boolean) {
if (!supportsTogglingSuspense) {
// TODO:: Add getter to decide if overrideSuspense is available.
// Currently only available on inspectElement.
// Probably need a different affordance to batch since the timeline
// fallback is not the same as resuspending.
return;
}
if (
typeof setSuspenseHandler !== 'function' ||
typeof scheduleUpdate !== 'function'
@@ -7506,6 +7500,58 @@ export function attach(
scheduleUpdate(fiber);
}
/**
* Resets the all other roots of this renderer.
* @param rootID The root that contains this milestone
* @param suspendedSet List of IDs of SuspenseComponent Fibers
*/
function overrideSuspenseMilestone(
rootID: FiberInstance['id'],
suspendedSet: Array<FiberInstance['id']>,
) {
if (
typeof setSuspenseHandler !== 'function' ||
typeof scheduleUpdate !== 'function'
) {
throw new Error(
'Expected overrideSuspenseMilestone() to not get called for earlier React versions.',
);
}
// TODO: Allow overriding the timeline for the specified root.
forceFallbackForFibers.clear();
for (let i = 0; i < suspendedSet.length; ++i) {
const instance = idToDevToolsInstanceMap.get(suspendedSet[i]);
if (instance === undefined) {
console.warn(
`Could not suspend ID '${suspendedSet[i]}' since the instance can't be found.`,
);
continue;
}
if (instance.kind === FIBER_INSTANCE) {
const fiber = instance.data;
forceFallbackForFibers.add(fiber);
// We could find a minimal set that covers all the Fibers in this suspended set.
// For now we rely on React's batching of updates.
scheduleUpdate(fiber);
} else {
console.warn(`Cannot not suspend ID '${suspendedSet[i]}'.`);
}
}
if (forceFallbackForFibers.size > 0) {
// First override is added. Switch React to slower path.
// TODO: Semantics for suspending a timeline are different. We want a suspended
// timeline to act like a first reveal which is relevant for SuspenseList.
// Resuspending would not affect rows in SuspenseList
setSuspenseHandler(shouldSuspendFiberAccordingToSet);
} else {
setSuspenseHandler(shouldSuspendFiberAlwaysFalse);
}
}
// Remember if we're trying to restore the selection after reload.
// In that case, we'll do some extra checks for matching mounts.
let trackedPath: Array<PathFrame> | null = null;
@@ -8006,6 +8052,7 @@ export function attach(
onErrorOrWarning,
overrideError,
overrideSuspense,
overrideSuspenseMilestone,
overrideValueAtPath,
renamePath,
renderer,
@@ -8014,6 +8061,7 @@ export function attach(
startProfiling,
stopProfiling,
storeAsGlobal,
supportsTogglingSuspense,
updateComponentFilters,
getEnvironmentNames,
...internalMcpFunctions,

View File

@@ -140,6 +140,8 @@ export function attach(
// The changes will be flushed later when we commit this tree to Fiber.
}
const supportsTogglingSuspense = false;
return {
cleanup() {},
clearErrorsAndWarnings() {},
@@ -202,6 +204,7 @@ export function attach(
onErrorOrWarning,
overrideError() {},
overrideSuspense() {},
overrideSuspenseMilestone() {},
overrideValueAtPath() {},
renamePath() {},
renderer,
@@ -210,6 +213,7 @@ export function attach(
startProfiling() {},
stopProfiling() {},
storeAsGlobal() {},
supportsTogglingSuspense,
updateComponentFilters() {},
getEnvironmentNames() {
return [];

View File

@@ -180,6 +180,8 @@ export function attach(
};
}
const supportsTogglingSuspense = false;
function getDisplayNameForElementID(id: number): string | null {
const internalInstance = idToInternalInstanceMap.get(id);
return internalInstance ? getData(internalInstance).displayName : null;
@@ -408,6 +410,7 @@ export function attach(
pushOperation(0); // Profiling flag
pushOperation(0); // StrictMode supported?
pushOperation(hasOwnerMetadata ? 1 : 0);
pushOperation(supportsTogglingSuspense ? 1 : 0);
} else {
const type = getElementType(internalInstance);
const {displayName, key} = getData(internalInstance);
@@ -1070,6 +1073,9 @@ export function attach(
const overrideSuspense = () => {
throw new Error('overrideSuspense not supported by this renderer');
};
const overrideSuspenseMilestone = () => {
throw new Error('overrideSuspenseMilestone not supported by this renderer');
};
const startProfiling = () => {
// Do not throw, since this would break a multi-root scenario where v15 and v16 were both present.
};
@@ -1153,6 +1159,7 @@ export function attach(
logElementToConsole,
overrideError,
overrideSuspense,
overrideSuspenseMilestone,
overrideValueAtPath,
renamePath,
getElementAttributeByPath,
@@ -1163,6 +1170,7 @@ export function attach(
startProfiling,
stopProfiling,
storeAsGlobal,
supportsTogglingSuspense,
updateComponentFilters,
getEnvironmentNames,
};

View File

@@ -437,6 +437,10 @@ export type RendererInterface = {
onErrorOrWarning?: OnErrorOrWarning,
overrideError: (id: number, forceError: boolean) => void,
overrideSuspense: (id: number, forceFallback: boolean) => void,
overrideSuspenseMilestone: (
rootID: number,
suspendedSet: Array<number>,
) => void,
overrideValueAtPath: (
type: Type,
id: number,
@@ -469,6 +473,7 @@ export type RendererInterface = {
path: Array<string | number>,
count: number,
) => void,
supportsTogglingSuspense: boolean,
updateComponentFilters: (componentFilters: Array<ComponentFilter>) => void,
getEnvironmentNames: () => Array<string>,

View File

@@ -27,7 +27,7 @@ export type BridgeProtocol = {
// Version supported by the current frontend/backend.
version: number,
// NPM version range that also supports this version.
// NPM version range of `react-devtools-inline` that also supports this version.
// Note that 'maxNpmVersion' is only set when the version is bumped.
minNpmVersion: string,
maxNpmVersion: string | null,
@@ -65,6 +65,12 @@ export const BRIDGE_PROTOCOL: Array<BridgeProtocol> = [
{
version: 2,
minNpmVersion: '4.22.0',
maxNpmVersion: '6.2.0',
},
// Version 3 adds supports-toggling-suspense bit to add-root
{
version: 3,
minNpmVersion: '6.2.0',
maxNpmVersion: null,
},
];
@@ -134,6 +140,12 @@ type OverrideSuspense = {
forceFallback: boolean,
};
type OverrideSuspenseMilestone = {
rendererID: number,
rootID: number,
suspendedSet: Array<number>,
};
type CopyElementPathParams = {
...ElementAndRendererID,
path: Array<string | number>,
@@ -231,6 +243,7 @@ type FrontendEvents = {
logElementToConsole: [ElementAndRendererID],
overrideError: [OverrideError],
overrideSuspense: [OverrideSuspense],
overrideSuspenseMilestone: [OverrideSuspenseMilestone],
overrideValueAtPath: [OverrideValueAtPath],
profilingData: [ProfilingDataBackend],
reloadAndProfile: [ReloadAndProfilingParams],

View File

@@ -89,6 +89,7 @@ export type Capabilities = {
supportsBasicProfiling: boolean,
hasOwnerMetadata: boolean,
supportsStrictMode: boolean,
supportsTogglingSuspense: boolean,
supportsTimeline: boolean,
};
@@ -491,6 +492,14 @@ export default class Store extends EventEmitter<{
);
}
supportsTogglingSuspense(rootID: Element['id']): boolean {
const capabilities = this._rootIDToCapabilities.get(rootID);
if (capabilities === undefined) {
throw new Error(`No capabilities registered for root ${rootID}`);
}
return capabilities.supportsTogglingSuspense;
}
// This build of DevTools supports the Timeline profiler.
// This is a static flag, controlled by the Store config.
get supportsTimeline(): boolean {
@@ -1080,6 +1089,7 @@ export default class Store extends EventEmitter<{
let supportsStrictMode = false;
let hasOwnerMetadata = false;
let supportsTogglingSuspense = false;
// If we don't know the bridge protocol, guess that we're dealing with the latest.
// If we do know it, we can take it into consideration when parsing operations.
@@ -1092,6 +1102,9 @@ export default class Store extends EventEmitter<{
hasOwnerMetadata = operations[i] > 0;
i++;
supportsTogglingSuspense = operations[i] > 0;
i++;
}
this._roots = this._roots.concat(id);
@@ -1100,6 +1113,7 @@ export default class Store extends EventEmitter<{
supportsBasicProfiling,
hasOwnerMetadata,
supportsStrictMode,
supportsTogglingSuspense,
supportsTimeline,
});

View File

@@ -208,6 +208,7 @@ function updateTree(
i++; // Profiling flag
i++; // supportsStrictMode flag
i++; // hasOwnerMetadata flag
i++; // supportsTogglingSuspense flag
if (__DEBUG__) {
debug('Add', `new root fiber ${id}`);

View File

@@ -115,4 +115,5 @@
.Timeline {
flex-grow: 1;
align-self: anchor-center;
}

View File

@@ -1,5 +1,18 @@
.SuspenseTimelineSlider {
.SuspenseTimelineContainer {
width: 100%;
display: flex;
flex-direction: row;
}
.SuspenseTimelineInput {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.SuspenseTimelineRootSwitcher {
height: fit-content;
max-width: 3rem;
}
.SuspenseTimelineMarkers {
@@ -18,3 +31,4 @@
.SuspenseTimelineActiveMarker {
visibility: visible;
}

View File

@@ -29,34 +29,38 @@ import typeof {
SyntheticPointerEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
// TODO: This returns the roots which would mean we attempt to suspend the shell.
// Suspending the shell is currently not supported and we don't have a good view
// for inspecting the root. But we probably should?
function getDocumentOrderSuspense(
function getSuspendableDocumentOrderSuspense(
store: Store,
roots: $ReadOnlyArray<Element['id']>,
rootID: Element['id'] | void,
): Array<SuspenseNode> {
if (rootID === undefined) {
return [];
}
const root = store.getElementByID(rootID);
if (root === null) {
return [];
}
if (!store.supportsTogglingSuspense(root.id)) {
return [];
}
const suspenseTreeList: SuspenseNode[] = [];
for (let i = 0; i < roots.length; i++) {
const root = store.getElementByID(roots[i]);
if (root === null) {
continue;
}
const suspense = store.getSuspenseByID(root.id);
if (suspense !== null) {
const stack = [suspense];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) {
continue;
}
const suspense = store.getSuspenseByID(root.id);
if (suspense !== null) {
const stack = [suspense];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) {
continue;
}
// Don't include the root. It's currently not supported to suspend the shell.
if (current !== suspense) {
suspenseTreeList.push(current);
// Add children in reverse order to maintain document order
for (let j = current.children.length - 1; j >= 0; j--) {
const childSuspense = store.getSuspenseByID(current.children[j]);
if (childSuspense !== null) {
stack.push(childSuspense);
}
}
// Add children in reverse order to maintain document order
for (let j = current.children.length - 1; j >= 0; j--) {
const childSuspense = store.getSuspenseByID(current.children[j]);
if (childSuspense !== null) {
stack.push(childSuspense);
}
}
}
@@ -65,22 +69,24 @@ function getDocumentOrderSuspense(
return suspenseTreeList;
}
export default function SuspenseTimeline(): React$Node {
function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const dispatch = useContext(TreeDispatcherContext);
const {shells} = useContext(SuspenseTreeStateContext);
const timeline = useMemo(() => {
return getDocumentOrderSuspense(store, shells);
}, [store, shells]);
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
const timeline = useMemo(() => {
return getSuspendableDocumentOrderSuspense(store, rootID);
}, [store, rootID]);
const inputRef = useRef<HTMLElement | null>(null);
const inputBBox = useRef<ClientRect | null>(null);
useLayoutEffect(() => {
if (timeline.length === 0) {
return;
}
const input = inputRef.current;
if (input === null) {
throw new Error('Expected an input HTML element to be present.');
@@ -95,12 +101,12 @@ export default function SuspenseTimeline(): React$Node {
inputBBox.current = null;
observer.disconnect();
};
}, []);
}, [timeline.length]);
const min = 0;
const max = timeline.length > 0 ? timeline.length - 1 : 0;
const [value, setValue] = useState(max);
if (value > max) {
// TODO: Handle timeline changes
setValue(max);
@@ -130,6 +136,26 @@ export default function SuspenseTimeline(): React$Node {
});
}, [timeline, value]);
if (rootID === undefined) {
return <div className={styles.SuspenseTimelineInput}>Root not found.</div>;
}
if (!store.supportsTogglingSuspense(rootID)) {
return (
<div className={styles.SuspenseTimelineInput}>
Can't step through Suspense in production apps.
</div>
);
}
if (timeline.length === 0) {
return (
<div className={styles.SuspenseTimelineInput}>
Root contains no Suspense nodes.
</div>
);
}
function handleChange(event: SyntheticEvent) {
const pendingValue = +event.currentTarget.value;
for (let i = 0; i < timeline.length; i++) {
@@ -193,7 +219,7 @@ export default function SuspenseTimeline(): React$Node {
}
return (
<div>
<div className={styles.SuspenseTimelineInput}>
<input
className={styles.SuspenseTimelineSlider}
type="range"
@@ -214,3 +240,54 @@ export default function SuspenseTimeline(): React$Node {
</div>
);
}
export default function SuspenseTimeline(): React$Node {
const store = useContext(StoreContext);
const {shells} = useContext(SuspenseTreeStateContext);
const defaultSelectedRootID = shells.find(rootID => {
const suspense = store.getSuspenseByID(rootID);
return (
store.supportsTogglingSuspense(rootID) &&
suspense !== null &&
suspense.children.length > 1
);
});
const [selectedRootID, setSelectedRootID] = useState(defaultSelectedRootID);
if (selectedRootID === undefined && defaultSelectedRootID !== undefined) {
setSelectedRootID(defaultSelectedRootID);
}
function handleChange(event: SyntheticEvent) {
const newRootID = +event.currentTarget.value;
// TODO: scrollIntoView both suspense rects and host instance.
setSelectedRootID(newRootID);
}
return (
<div className={styles.SuspenseTimelineContainer}>
<SuspenseTimelineInput key={selectedRootID} rootID={selectedRootID} />
{shells.length > 0 && (
<select
aria-label="Select Suspense Root"
className={styles.SuspenseTimelineRootSwitcher}
onChange={handleChange}>
{shells.map(rootID => {
// TODO: Use name
const name = '#' + rootID;
// TODO: Highlight host on hover
return (
<option
key={rootID}
selected={rootID === selectedRootID}
value={rootID}>
{name}
</option>
);
})}
</select>
)}
</div>
);
}

View File

@@ -261,6 +261,7 @@ export function printOperationsArray(operations: Array<number>) {
i++; // supportsProfiling
i++; // supportsStrictMode
i++; // hasOwnerMetadata
i++; // supportsTogglingSuspense
} else {
const parentID = ((operations[i]: any): number);
i++;