import type {ReactContext} from 'shared/ReactTypes';
import type {
SuspenseNode,
SuspenseTimelineStep,
} 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']>,
selectedSuspenseID: SuspenseNode['id'] | null,
timeline: $ReadOnlyArray<SuspenseTimelineStep>,
timelineIndex: number | -1,
hoveredTimelineIndex: number | -1,
uniqueSuspendersOnly: boolean,
playing: boolean,
autoSelect: boolean,
autoScroll: {id: number},
};
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<SuspenseTimelineStep>,
SuspenseNode['id'] | null,
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 getInitialState(store: Store): SuspenseTreeState {
const uniqueSuspendersOnly = true;
const timeline =
store.getEndTimeOrDocumentOrderSuspense(uniqueSuspendersOnly);
const timelineIndex = timeline.length - 1;
const selectedSuspenseID =
timelineIndex === -1 ? null : timeline[timelineIndex].id;
const lineage =
selectedSuspenseID !== null
? store.getSuspenseLineage(selectedSuspenseID)
: [];
const initialState: SuspenseTreeState = {
selectedSuspenseID,
lineage,
roots: store.roots,
timeline,
timelineIndex,
hoveredTimelineIndex: -1,
uniqueSuspendersOnly,
playing: false,
autoSelect: true,
autoScroll: {id: 0},
};
return initialState;
}
function SuspenseTreeContextController({children}: Props): React.Node {
const store = useContext(StoreContext);
const reducer = useMemo(
() =>
(
state: SuspenseTreeState,
action: SuspenseTreeAction,
): SuspenseTreeState => {
switch (action.type) {
case 'HANDLE_SUSPENSE_TREE_MUTATION': {
let {selectedSuspenseID} = state;
const removedIDs = action.payload[0];
while (
selectedSuspenseID !== null &&
removedIDs.has(selectedSuspenseID)
) {
selectedSuspenseID = removedIDs.get(selectedSuspenseID);
}
if (selectedSuspenseID === 0) {
selectedSuspenseID = null;
}
const selectedTimelineStep =
state.timeline === null || state.timelineIndex === -1
? null
: state.timeline[state.timelineIndex];
let selectedTimelineID: null | number = null;
if (selectedTimelineStep !== null) {
selectedTimelineID = selectedTimelineStep.id;
while (removedIDs.has(selectedTimelineID)) {
selectedTimelineID = removedIDs.get(selectedTimelineID);
}
}
const nextTimeline = store.getEndTimeOrDocumentOrderSuspense(
state.uniqueSuspendersOnly,
);
let nextTimelineIndex = -1;
if (selectedTimelineID !== null && nextTimeline.length !== 0) {
for (let i = 0; i < nextTimeline.length; i++) {
if (nextTimeline[i].id === selectedTimelineID) {
nextTimelineIndex = i;
break;
}
}
}
if (
nextTimeline.length > 0 &&
(nextTimelineIndex === -1 || state.autoSelect)
) {
nextTimelineIndex = nextTimeline.length - 1;
selectedSuspenseID = nextTimeline[nextTimelineIndex].id;
}
if (selectedSuspenseID === null && nextTimeline.length > 0) {
selectedSuspenseID = nextTimeline[nextTimeline.length - 1].id;
}
const nextLineage =
selectedSuspenseID !== null &&
state.selectedSuspenseID !== selectedSuspenseID
? store.getSuspenseLineage(selectedSuspenseID)
: state.lineage;
return {
...state,
lineage: nextLineage,
roots: store.roots,
selectedSuspenseID,
timeline: nextTimeline,
timelineIndex: nextTimelineIndex,
};
}
case 'SELECT_SUSPENSE_BY_ID': {
const selectedSuspenseID = action.payload;
return {
...state,
selectedSuspenseID,
playing: false,
autoSelect: false,
autoScroll: {id: selectedSuspenseID},
};
}
case 'SET_SUSPENSE_LINEAGE': {
const suspenseID = action.payload;
const lineage = store.getSuspenseLineage(suspenseID);
return {
...state,
lineage,
selectedSuspenseID: suspenseID,
playing: false,
autoSelect: false,
};
}
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;
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].id;
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
} else if (nextRootID !== null) {
nextMilestoneIndex = nextTimeline.length - 1;
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
return {
...state,
selectedSuspenseID: nextSelectedSuspenseID,
lineage: nextLineage,
timeline: nextTimeline,
timelineIndex: nextMilestoneIndex,
uniqueSuspendersOnly: nextUniqueSuspendersOnly,
};
}
case 'SUSPENSE_SET_TIMELINE_INDEX': {
const nextTimelineIndex = action.payload;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
return {
...state,
lineage: nextLineage,
selectedSuspenseID: nextSelectedSuspenseID,
timelineIndex: nextTimelineIndex,
playing: false,
autoSelect: false,
autoScroll: {id: nextSelectedSuspenseID},
};
}
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].id;
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
return {
...state,
lineage: nextLineage,
selectedSuspenseID: nextSelectedSuspenseID,
timelineIndex: nextTimelineIndex,
playing: false,
autoSelect: false,
autoScroll: {id: nextSelectedSuspenseID},
};
}
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
) {
nextTimelineIndex = 0;
nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
return {
...state,
lineage: nextLineage,
selectedSuspenseID: nextSelectedSuspenseID,
timelineIndex: nextTimelineIndex,
playing: mode === 'toggle' ? !state.playing : mode === 'play',
autoSelect: false,
};
}
case 'SUSPENSE_PLAY_TICK': {
if (!state.playing) {
return state;
}
const nextTimelineIndex = state.timelineIndex + 1;
if (nextTimelineIndex > state.timeline.length - 1) {
return state;
}
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
const nextPlaying = nextTimelineIndex < state.timeline.length - 1;
return {
...state,
lineage: nextLineage,
selectedSuspenseID: nextSelectedSuspenseID,
timelineIndex: nextTimelineIndex,
playing: nextPlaying,
autoScroll: {id: nextSelectedSuspenseID},
};
}
case 'TOGGLE_TIMELINE_FOR_ID': {
const suspenseID = action.payload;
let timelineIndexForSuspenseID = -1;
for (let i = 0; i < state.timeline.length; i++) {
if (state.timeline[i].id === suspenseID) {
timelineIndexForSuspenseID = i;
break;
}
}
if (timelineIndexForSuspenseID === -1) {
return state;
}
const nextTimelineIndex =
timelineIndexForSuspenseID === 0
?
0
:
state.timelineIndex < timelineIndexForSuspenseID
?
timelineIndexForSuspenseID
:
timelineIndexForSuspenseID - 1;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
return {
...state,
lineage: nextLineage,
selectedSuspenseID: nextSelectedSuspenseID,
timelineIndex: nextTimelineIndex,
playing: false,
autoSelect: false,
autoScroll: {id: nextSelectedSuspenseID},
};
}
case 'HOVER_TIMELINE_FOR_ID': {
const suspenseID = action.payload;
let timelineIndexForSuspenseID = -1;
for (let i = 0; i < state.timeline.length; i++) {
if (state.timeline[i].id === suspenseID) {
timelineIndexForSuspenseID = i;
break;
}
}
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]);
useEffect(() => {
const handleSuspenseTreeMutated = ([removedElementIDs]: [
Map<number, number>,
]) => {
dispatch({
type: 'HANDLE_SUSPENSE_TREE_MUTATION',
payload: [removedElementIDs],
});
};
if (store.revisionSuspense !== initialRevision) {
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,
};