Files
react/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
Sebastian Markbåge d15d7fd79e [DevTools] Double click a Suspense Rect to jump to its position in the timeline (#34642)
When you double click it will hide or show by jumping to the selected
index or one step before the selected.

Let's you go from a suspense boundary into the timeline to find its
position. I also highlight the step in the timeline when you hover the
rect.

This only works if it's in the selected root but all of those should be
merged into one single timeline.

One thing that's weird about the SuspenseNodes now is that they
sometimes gets deleted but not always when they're resupended. Nested
ones maybe? This means that if you double click to hide it, you can't
double click again to show it. This seems like an unrelated bug that we
should fix.

We could potentially repurpose the existing "Suspend" button in the
toolbar to do this too, or maybe add another icon there.
2025-09-29 10:43:01 -04:00

510 lines
18 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {ReactContext} from 'shared/ReactTypes';
import type {
Element,
SuspenseNode,
} from 'react-devtools-shared/src/frontend/types';
import type Store from '../../store';
import * as React from 'react';
import {
createContext,
startTransition,
useContext,
useEffect,
useMemo,
useReducer,
} from 'react';
import {StoreContext} from '../context';
export type SuspenseTreeState = {
lineage: $ReadOnlyArray<SuspenseNode['id']> | null,
roots: $ReadOnlyArray<SuspenseNode['id']>,
selectedRootID: SuspenseNode['id'] | null,
selectedSuspenseID: SuspenseNode['id'] | null,
timeline: $ReadOnlyArray<SuspenseNode['id']>,
timelineIndex: number | -1,
hoveredTimelineIndex: number | -1,
uniqueSuspendersOnly: boolean,
playing: boolean,
};
type ACTION_SUSPENSE_TREE_MUTATION = {
type: 'HANDLE_SUSPENSE_TREE_MUTATION',
payload: [Map<SuspenseNode['id'], SuspenseNode['id']>],
};
type ACTION_SET_SUSPENSE_LINEAGE = {
type: 'SET_SUSPENSE_LINEAGE',
payload: SuspenseNode['id'],
};
type ACTION_SELECT_SUSPENSE_BY_ID = {
type: 'SELECT_SUSPENSE_BY_ID',
payload: SuspenseNode['id'],
};
type ACTION_SET_SUSPENSE_TIMELINE = {
type: 'SET_SUSPENSE_TIMELINE',
payload: [
$ReadOnlyArray<SuspenseNode['id']>,
// The next Suspense ID to select in the timeline
SuspenseNode['id'] | null,
// Whether this timeline includes only unique suspenders
boolean,
],
};
type ACTION_SUSPENSE_SET_TIMELINE_INDEX = {
type: 'SUSPENSE_SET_TIMELINE_INDEX',
payload: number,
};
type ACTION_SUSPENSE_SKIP_TIMELINE_INDEX = {
type: 'SUSPENSE_SKIP_TIMELINE_INDEX',
payload: boolean,
};
type ACTION_SUSPENSE_PLAY_PAUSE = {
type: 'SUSPENSE_PLAY_PAUSE',
payload: 'toggle' | 'play' | 'pause',
};
type ACTION_SUSPENSE_PLAY_TICK = {
type: 'SUSPENSE_PLAY_TICK',
};
type ACTION_TOGGLE_TIMELINE_FOR_ID = {
type: 'TOGGLE_TIMELINE_FOR_ID',
payload: SuspenseNode['id'],
};
type ACTION_HOVER_TIMELINE_FOR_ID = {
type: 'HOVER_TIMELINE_FOR_ID',
payload: SuspenseNode['id'],
};
export type SuspenseTreeAction =
| ACTION_SUSPENSE_TREE_MUTATION
| ACTION_SET_SUSPENSE_LINEAGE
| ACTION_SELECT_SUSPENSE_BY_ID
| ACTION_SET_SUSPENSE_TIMELINE
| ACTION_SUSPENSE_SET_TIMELINE_INDEX
| ACTION_SUSPENSE_SKIP_TIMELINE_INDEX
| ACTION_SUSPENSE_PLAY_PAUSE
| ACTION_SUSPENSE_PLAY_TICK
| ACTION_TOGGLE_TIMELINE_FOR_ID
| ACTION_HOVER_TIMELINE_FOR_ID;
export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void;
const SuspenseTreeStateContext: ReactContext<SuspenseTreeState> =
createContext<SuspenseTreeState>(((null: any): SuspenseTreeState));
SuspenseTreeStateContext.displayName = 'SuspenseTreeStateContext';
const SuspenseTreeDispatcherContext: ReactContext<SuspenseTreeDispatch> =
createContext<SuspenseTreeDispatch>(((null: any): SuspenseTreeDispatch));
SuspenseTreeDispatcherContext.displayName = 'SuspenseTreeDispatcherContext';
type Props = {
children: React$Node,
};
function getDefaultRootID(store: Store): Element['id'] | null {
const designatedRootID = store.roots.find(rootID => {
const suspense = store.getSuspenseByID(rootID);
return (
store.supportsTogglingSuspense(rootID) &&
suspense !== null &&
suspense.children.length > 1
);
});
return designatedRootID === undefined ? null : designatedRootID;
}
function getInitialState(store: Store): SuspenseTreeState {
let initialState: SuspenseTreeState;
const uniqueSuspendersOnly = true;
const selectedRootID = getDefaultRootID(store);
// TODO: Default to nearest from inspected
if (selectedRootID === null) {
initialState = {
selectedSuspenseID: null,
lineage: null,
roots: store.roots,
selectedRootID,
timeline: [],
timelineIndex: -1,
hoveredTimelineIndex: -1,
uniqueSuspendersOnly,
playing: false,
};
} else {
const timeline = store.getSuspendableDocumentOrderSuspense(
selectedRootID,
uniqueSuspendersOnly,
);
const timelineIndex = timeline.length - 1;
const selectedSuspenseID =
timelineIndex === -1 ? null : timeline[timelineIndex];
const lineage =
selectedSuspenseID !== null
? store.getSuspenseLineage(selectedSuspenseID)
: [];
initialState = {
selectedSuspenseID,
lineage,
roots: store.roots,
selectedRootID,
timeline,
timelineIndex,
hoveredTimelineIndex: -1,
uniqueSuspendersOnly,
playing: false,
};
}
return initialState;
}
function SuspenseTreeContextController({children}: Props): React.Node {
const store = useContext(StoreContext);
// This reducer is created inline because it needs access to the Store.
// The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
// so it's okay for the reducer to have an empty dependencies array.
const reducer = useMemo(
() =>
(
state: SuspenseTreeState,
action: SuspenseTreeAction,
): SuspenseTreeState => {
switch (action.type) {
case 'HANDLE_SUSPENSE_TREE_MUTATION': {
let {selectedSuspenseID} = state;
// If the currently-selected Element has been removed from the tree, update selection state.
const removedIDs = action.payload[0];
// Find the closest parent that wasn't removed during this batch.
// We deduce the parent-child mapping from removedIDs (id -> parentID)
// because by now it's too late to read them from the store.
while (
selectedSuspenseID !== null &&
removedIDs.has(selectedSuspenseID)
) {
// $FlowExpectedError[incompatible-type]
selectedSuspenseID = removedIDs.get(selectedSuspenseID);
}
if (selectedSuspenseID === 0) {
// The whole root was removed.
selectedSuspenseID = null;
}
let selectedTimelineID =
state.timeline === null
? null
: state.timeline[state.timelineIndex];
while (
selectedTimelineID !== null &&
removedIDs.has(selectedTimelineID)
) {
// $FlowExpectedError[incompatible-type]
selectedTimelineID = removedIDs.get(selectedTimelineID);
}
let nextRootID = state.selectedRootID;
if (selectedTimelineID !== null && selectedTimelineID !== 0) {
nextRootID =
store.getSuspenseRootIDForSuspense(selectedTimelineID);
}
if (nextRootID === null) {
nextRootID = getDefaultRootID(store);
}
const nextTimeline =
nextRootID === null
? []
: // TODO: Handle different timeline modes (e.g. random order)
store.getSuspendableDocumentOrderSuspense(
nextRootID,
state.uniqueSuspendersOnly,
);
let nextTimelineIndex =
selectedTimelineID === null || nextTimeline.length === 0
? -1
: nextTimeline.indexOf(selectedTimelineID);
if (nextTimeline.length > 0 && nextTimelineIndex === -1) {
nextTimelineIndex = nextTimeline.length - 1;
selectedSuspenseID = nextTimeline[nextTimelineIndex];
}
if (selectedSuspenseID === null && nextTimeline.length > 0) {
selectedSuspenseID = nextTimeline[nextTimeline.length - 1];
}
const nextLineage =
selectedSuspenseID !== null &&
state.selectedSuspenseID !== selectedSuspenseID
? store.getSuspenseLineage(selectedSuspenseID)
: state.lineage;
return {
...state,
lineage: nextLineage,
roots: store.roots,
selectedRootID: nextRootID,
selectedSuspenseID,
timeline: nextTimeline,
timelineIndex: nextTimelineIndex,
};
}
case 'SELECT_SUSPENSE_BY_ID': {
const selectedSuspenseID = action.payload;
const selectedRootID =
store.getSuspenseRootIDForSuspense(selectedSuspenseID);
return {
...state,
selectedSuspenseID,
selectedRootID,
playing: false, // pause
};
}
case 'SET_SUSPENSE_LINEAGE': {
const suspenseID = action.payload;
const lineage = store.getSuspenseLineage(suspenseID);
const selectedRootID =
store.getSuspenseRootIDForSuspense(suspenseID);
return {
...state,
lineage,
selectedSuspenseID: suspenseID,
selectedRootID,
playing: false, // pause
};
}
case 'SET_SUSPENSE_TIMELINE': {
const previousMilestoneIndex = state.timelineIndex;
const previousTimeline = state.timeline;
const nextTimeline = action.payload[0];
const nextRootID: SuspenseNode['id'] | null = action.payload[1];
const nextUniqueSuspendersOnly = action.payload[2];
let nextLineage = state.lineage;
let nextMilestoneIndex: number | -1 = -1;
let nextSelectedSuspenseID = state.selectedSuspenseID;
// Action has indicated it has no preference for the selected Node.
// Try to reconcile the new timeline with the previous index.
if (
nextRootID === null &&
previousTimeline !== null &&
previousMilestoneIndex !== null
) {
const previousMilestoneID =
previousTimeline[previousMilestoneIndex];
nextMilestoneIndex = nextTimeline.indexOf(previousMilestoneID);
if (nextMilestoneIndex === -1 && nextTimeline.length > 0) {
nextMilestoneIndex = nextTimeline.length - 1;
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
} else if (nextRootID !== null) {
nextMilestoneIndex = nextTimeline.length - 1;
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
return {
...state,
selectedSuspenseID: nextSelectedSuspenseID,
lineage: nextLineage,
selectedRootID:
nextRootID === null ? state.selectedRootID : nextRootID,
timeline: nextTimeline,
timelineIndex: nextMilestoneIndex,
uniqueSuspendersOnly: nextUniqueSuspendersOnly,
};
}
case 'SUSPENSE_SET_TIMELINE_INDEX': {
const nextTimelineIndex = action.payload;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
return {
...state,
lineage: nextLineage,
selectedSuspenseID: nextSelectedSuspenseID,
timelineIndex: nextTimelineIndex,
playing: false, // pause
};
}
case 'SUSPENSE_SKIP_TIMELINE_INDEX': {
const direction = action.payload;
const nextTimelineIndex =
state.timelineIndex + (direction ? 1 : -1);
if (
nextTimelineIndex < 0 ||
nextTimelineIndex > state.timeline.length - 1
) {
return state;
}
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
return {
...state,
lineage: nextLineage,
selectedSuspenseID: nextSelectedSuspenseID,
timelineIndex: nextTimelineIndex,
playing: false, // pause
};
}
case 'SUSPENSE_PLAY_PAUSE': {
const mode = action.payload;
let nextTimelineIndex = state.timelineIndex;
let nextSelectedSuspenseID = state.selectedSuspenseID;
let nextLineage = state.lineage;
if (
!state.playing &&
mode !== 'pause' &&
nextTimelineIndex === state.timeline.length - 1
) {
// If we're restarting at the end. Then loop around and start again from the beginning.
nextTimelineIndex = 0;
nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
return {
...state,
lineage: nextLineage,
selectedSuspenseID: nextSelectedSuspenseID,
timelineIndex: nextTimelineIndex,
playing: mode === 'toggle' ? !state.playing : mode === 'play',
};
}
case 'SUSPENSE_PLAY_TICK': {
if (!state.playing) {
// We stopped but haven't yet cleaned up the callback. Noop.
return state;
}
// Advance time
const nextTimelineIndex = state.timelineIndex + 1;
if (nextTimelineIndex > state.timeline.length - 1) {
return state;
}
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
// Stop once we reach the end.
const nextPlaying = nextTimelineIndex < state.timeline.length - 1;
return {
...state,
lineage: nextLineage,
selectedSuspenseID: nextSelectedSuspenseID,
timelineIndex: nextTimelineIndex,
playing: nextPlaying,
};
}
case 'TOGGLE_TIMELINE_FOR_ID': {
const suspenseID = action.payload;
const timelineIndexForSuspenseID =
state.timeline.indexOf(suspenseID);
if (timelineIndexForSuspenseID === -1) {
// This boundary is no longer in the timeline.
return state;
}
const nextTimelineIndex =
timelineIndexForSuspenseID === 0
? // For roots, there's no toggling. It's always just jump to beginning.
0
: // For boundaries, we'll either jump to before or after its reveal depending
// on if we're currently displaying it or not according to the timeline.
state.timelineIndex < timelineIndexForSuspenseID
? // We're currently before this suspense boundary has been revealed so we
// should jump ahead to reveal it.
timelineIndexForSuspenseID
: // Otherwise, if we're currently showing it, jump to right before to hide it.
timelineIndexForSuspenseID - 1;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
return {
...state,
lineage: nextLineage,
selectedSuspenseID: nextSelectedSuspenseID,
timelineIndex: nextTimelineIndex,
playing: false, // pause
};
}
case 'HOVER_TIMELINE_FOR_ID': {
const suspenseID = action.payload;
const timelineIndexForSuspenseID =
state.timeline.indexOf(suspenseID);
return {
...state,
hoveredTimelineIndex: timelineIndexForSuspenseID,
};
}
default:
throw new Error(`Unrecognized action "${action.type}"`);
}
},
[],
);
const [state, dispatch] = useReducer(reducer, store, getInitialState);
const initialRevision = useMemo(() => store.revisionSuspense, [store]);
// We're currently storing everything Suspense related in the same Store as
// Components. However, most reads are currently stateless. This ensures
// the latest state is always read from the Store.
useEffect(() => {
const handleSuspenseTreeMutated = ([removedElementIDs]: [
Map<number, number>,
]) => {
dispatch({
type: 'HANDLE_SUSPENSE_TREE_MUTATION',
payload: [removedElementIDs],
});
};
// Since this is a passive effect, the tree may have been mutated before our initial subscription.
if (store.revisionSuspense !== initialRevision) {
// At the moment, we can treat this as a mutation.
handleSuspenseTreeMutated([new Map()]);
}
store.addListener('suspenseTreeMutated', handleSuspenseTreeMutated);
return () =>
store.removeListener('suspenseTreeMutated', handleSuspenseTreeMutated);
}, [initialRevision, store]);
const transitionDispatch = useMemo(
() => (action: SuspenseTreeAction) =>
startTransition(() => {
dispatch(action);
}),
[dispatch],
);
return (
<SuspenseTreeStateContext.Provider value={state}>
<SuspenseTreeDispatcherContext.Provider value={transitionDispatch}>
{children}
</SuspenseTreeDispatcherContext.Provider>
</SuspenseTreeStateContext.Provider>
);
}
export {
SuspenseTreeDispatcherContext,
SuspenseTreeStateContext,
SuspenseTreeContextController,
};