[DevTools] Preserve Suspense lineage when clicking through breadcrumbs (#34422)
Sebastian "Sebbie" Silbermann committed
Sep 11, 2025 at 10:54 UTC
8c1501452cea0d3779a2b9e7a5e4dd5348427b8a
6 files changed
+454
-192
packages/react-devtools-shared/src/devtools/store.js
+75
-2
@@ -111,7 +111,7 @@ export default class Store extends EventEmitter<{
111
roots: [],
112
rootSupportsBasicProfiling: [],
113
rootSupportsTimelineProfiling: [],
114
- suspenseTreeMutated: [],
114
+ suspenseTreeMutated: [[Map<SuspenseNode['id'], SuspenseNode['id']>]],
115
supportsNativeStyleEditor: [],
116
supportsReloadAndProfile: [],
117
unsupportedBridgeProtocolDetected: [],
@@ -847,6 +847,76 @@ export default class Store extends EventEmitter<{
847
return list;
848
}
849
850
+ getSuspenseLineage(
851
+ suspenseID: SuspenseNode['id'],
852
+ ): $ReadOnlyArray<SuspenseNode['id']> {
853
+ const lineage: Array<SuspenseNode['id']> = [];
854
+ let next: null | SuspenseNode = this.getSuspenseByID(suspenseID);
855
+ while (next !== null) {
856
+ if (next.parentID === 0) {
857
+ next = null;
858
+ } else {
859
+ lineage.unshift(next.id);
860
+ next = this.getSuspenseByID(next.parentID);
861
+ }
862
+ }
863
+
864
+ return lineage;
865
+ }
866
+
867
+ /**
868
+ * Like {@link getRootIDForElement} but should be used for traversing Suspense since it works with disconnected nodes.
869
+ */
870
+ getSuspenseRootIDForSuspense(id: SuspenseNode['id']): number | null {
871
+ let current = this._idToSuspense.get(id);
872
+ while (current !== undefined) {
873
+ if (current.parentID === 0) {
874
+ return current.id;
875
+ } else {
876
+ current = this._idToSuspense.get(current.parentID);
877
+ }
878
+ }
879
+ return null;
880
+ }
881
+
882
+ getSuspendableDocumentOrderSuspense(
883
+ rootID: Element['id'] | void,
884
+ ): $ReadOnlyArray<SuspenseNode['id']> {
885
+ if (rootID === undefined) {
886
+ return [];
887
+ }
888
+ const root = this.getElementByID(rootID);
889
+ if (root === null) {
890
+ return [];
891
+ }
892
+ if (!this.supportsTogglingSuspense(root.id)) {
893
+ return [];
894
+ }
895
+ const suspenseTreeList: SuspenseNode['id'][] = [];
896
+ const suspense = this.getSuspenseByID(root.id);
897
+ if (suspense !== null) {
898
+ const stack = [suspense];
899
+ while (stack.length > 0) {
900
+ const current = stack.pop();
901
+ if (current === undefined) {
902
+ continue;
903
+ }
904
+ // Include the root even if we won't suspend it.
905
+ // You should be able to see what suspended the shell.
906
+ suspenseTreeList.push(current.id);
907
+ // Add children in reverse order to maintain document order
908
+ for (let j = current.children.length - 1; j >= 0; j--) {
909
+ const childSuspense = this.getSuspenseByID(current.children[j]);
910
+ if (childSuspense !== null) {
911
+ stack.push(childSuspense);
912
+ }
913
+ }
914
+ }
915
+ }
916
+
917
+ return suspenseTreeList;
918
+ }
919
+
920
getRendererIDForElement(id: number): number | null {
921
let current = this._idToElement.get(id);
922
while (current !== undefined) {
@@ -1030,6 +1100,8 @@ export default class Store extends EventEmitter<{
1100
const addedElementIDs: Array<number> = [];
1101
// This is a mapping of removed ID -> parent ID:
1102
const removedElementIDs: Map<number, number> = new Map();
1103
+ const removedSuspenseIDs: Map<SuspenseNode['id'], SuspenseNode['id']> =
1104
+ new Map();
1105
// We'll use the parent ID to adjust selection if it gets deleted.
1106
1107
let i = 2;
@@ -1541,6 +1613,7 @@ export default class Store extends EventEmitter<{
1613
}
1614
1615
this._idToSuspense.delete(id);
1616
+ removedSuspenseIDs.set(id, parentID);
1617
1618
let parentSuspense: ?SuspenseNode = null;
1619
if (parentID === 0) {
@@ -1748,7 +1821,7 @@ export default class Store extends EventEmitter<{
1821
}
1822
1823
if (hasSuspenseTreeChanged) {
1751
- this.emit('suspenseTreeMutated');
1824
+ this.emit('suspenseTreeMutated', [removedSuspenseIDs]);
1825
}
1826
1827
if (__DEBUG__) {
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css
+3
@@ -19,6 +19,9 @@
19
background: var(--color-button-background);
20
border: none;
21
border-radius: 0.25rem;
22
+ color: var(--color-button);
23
+ font-family: var(--font-family-monospace);
24
+ font-size: var(--font-size-monospace-normal);
25
padding: 0.25rem;
26
white-space: nowrap;
27
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js
+33
-49
@@ -12,68 +12,52 @@ import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/Syntheti
12
13
import * as React from 'react';
14
import {useContext} from 'react';
15
-import {
16
- TreeDispatcherContext,
17
- TreeStateContext,
18
-} from '../Components/TreeContext';
15
+import {TreeDispatcherContext} from '../Components/TreeContext';
16
+import {StoreContext} from '../context';
17
import {useHighlightHostInstance} from '../hooks';
18
import styles from './SuspenseBreadcrumbs.css';
21
-import {useSuspenseStore} from './SuspenseTreeContext';
19
+import {
20
+ SuspenseTreeStateContext,
21
+ SuspenseTreeDispatcherContext,
22
+} from './SuspenseTreeContext';
23
24
export default function SuspenseBreadcrumbs(): React$Node {
24
- const store = useSuspenseStore();
25
- const dispatch = useContext(TreeDispatcherContext);
26
- const {inspectedElementID} = useContext(TreeStateContext);
25
+ const store = useContext(StoreContext);
26
+ const treeDispatch = useContext(TreeDispatcherContext);
27
+ const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
28
+ const {selectedSuspenseID, lineage} = useContext(SuspenseTreeStateContext);
29
30
const {highlightHostInstance, clearHighlightHostInstance} =
31
useHighlightHostInstance();
32
31
- // TODO: Use the nearest Suspense boundary
32
- const inspectedSuspenseID = inspectedElementID;
33
- if (inspectedSuspenseID === null) {
34
- return null;
35
- }
36
-
37
- const suspense = store.getSuspenseByID(inspectedSuspenseID);
38
- if (suspense === null) {
39
- return null;
40
- }
41
-
42
- const lineage: SuspenseNode[] = [];
43
- let next: null | SuspenseNode = suspense;
44
- while (next !== null) {
45
- if (next.parentID === 0) {
46
- next = null;
47
- } else {
48
- lineage.unshift(next);
49
- next = store.getSuspenseByID(next.parentID);
50
- }
51
- }
52
-
53
- function handleClick(node: SuspenseNode, event: SyntheticMouseEvent) {
33
+ function handleClick(id: SuspenseNode['id'], event: SyntheticMouseEvent) {
34
event.preventDefault();
55
- dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: node.id});
35
+ treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
36
+ suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id});
37
}
38
39
return (
40
<ol className={styles.SuspenseBreadcrumbsList}>
60
- {lineage.map((node, index) => {
61
- return (
62
- <li
63
- key={node.id}
64
- className={styles.SuspenseBreadcrumbsListItem}
65
- aria-current={index === lineage.length - 1}
66
- onPointerEnter={highlightHostInstance.bind(null, node.id)}
67
- onPointerLeave={clearHighlightHostInstance}>
68
- <button
69
- className={styles.SuspenseBreadcrumbsButton}
70
- onClick={handleClick.bind(null, node)}
71
- type="button">
72
- {node.name}
73
- </button>
74
- </li>
75
- );
76
- })}
41
+ {lineage !== null &&
42
+ lineage.map((id, index) => {
43
+ const node = store.getSuspenseByID(id);
44
+
45
+ return (
46
+ <li
47
+ key={id}
48
+ className={styles.SuspenseBreadcrumbsListItem}
49
+ aria-current={selectedSuspenseID === id}
50
+ onPointerEnter={highlightHostInstance.bind(null, id)}
51
+ onPointerLeave={clearHighlightHostInstance}>
52
+ <button
53
+ className={styles.SuspenseBreadcrumbsButton}
54
+ onClick={handleClick.bind(null, id)}
55
+ type="button">
56
+ {node === null ? 'Unknown' : node.name}
57
+ </button>
58
+ </li>
59
+ );
60
+ })}
61
</ol>
62
);
63
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+16
-7
@@ -19,9 +19,13 @@ import {
19
TreeDispatcherContext,
20
TreeStateContext,
21
} from '../Components/TreeContext';
22
+import {StoreContext} from '../context';
23
import {useHighlightHostInstance} from '../hooks';
24
import styles from './SuspenseRects.css';
24
-import {useSuspenseStore} from './SuspenseTreeContext';
25
+import {
26
+ SuspenseTreeStateContext,
27
+ SuspenseTreeDispatcherContext,
28
+} from './SuspenseTreeContext';
29
import typeof {
30
SyntheticMouseEvent,
31
SyntheticPointerEvent,
@@ -44,8 +48,9 @@ function SuspenseRects({
48
}: {
49
suspenseID: SuspenseNode['id'],
50
}): React$Node {
47
- const dispatch = useContext(TreeDispatcherContext);
48
- const store = useSuspenseStore();
51
+ const store = useContext(StoreContext);
52
+ const treeDispatch = useContext(TreeDispatcherContext);
53
+ const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
54
55
const {inspectedElementID} = useContext(TreeStateContext);
56
@@ -64,7 +69,11 @@ function SuspenseRects({
69
return;
70
}
71
event.preventDefault();
67
- dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID});
72
+ treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID});
73
+ suspenseTreeDispatch({
74
+ type: 'SET_SUSPENSE_LINEAGE',
75
+ payload: suspenseID,
76
+ });
77
}
78
79
function handlePointerOver(event: SyntheticPointerEvent) {
@@ -157,7 +166,7 @@ function SuspenseRectsShell({
166
}: {
167
rootID: SuspenseNode['id'],
168
}): React$Node {
160
- const store = useSuspenseStore();
169
+ const store = useContext(StoreContext);
170
const root = store.getSuspenseByID(rootID);
171
if (root === null) {
172
console.warn(`<Element> Could not find suspense node id ${rootID}`);
@@ -174,9 +183,9 @@ function SuspenseRectsShell({
183
}
184
185
function SuspenseRectsContainer(): React$Node {
177
- const store = useSuspenseStore();
186
+ const store = useContext(StoreContext);
187
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
179
- const roots = store.roots;
188
+ const {roots} = useContext(SuspenseTreeStateContext);
189
190
const boundingRect = getDocumentBoundingRect(store, roots);
191
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+60
-97
@@ -7,70 +7,34 @@
7
* @flow
8
*/
9
10
-import type {Element, SuspenseNode} from '../../../frontend/types';
11
-import type Store from '../../store';
12
-
10
import * as React from 'react';
14
-import {useContext, useLayoutEffect, useMemo, useRef, useState} from 'react';
15
-import {BridgeContext} from '../context';
11
+import {useContext, useLayoutEffect, useRef} from 'react';
12
+import {BridgeContext, StoreContext} from '../context';
13
import {TreeDispatcherContext} from '../Components/TreeContext';
14
import {useHighlightHostInstance} from '../hooks';
18
-import {useSuspenseStore} from './SuspenseTreeContext';
15
+import {
16
+ SuspenseTreeDispatcherContext,
17
+ SuspenseTreeStateContext,
18
+} from './SuspenseTreeContext';
19
import styles from './SuspenseTimeline.css';
20
import typeof {
21
SyntheticEvent,
22
SyntheticPointerEvent,
23
} from 'react-dom-bindings/src/events/SyntheticEvent';
24
25
-function getSuspendableDocumentOrderSuspense(
26
- store: Store,
27
- rootID: Element['id'] | void,
28
-): Array<SuspenseNode> {
29
- if (rootID === undefined) {
30
- return [];
31
- }
32
- const root = store.getElementByID(rootID);
33
- if (root === null) {
34
- return [];
35
- }
36
- if (!store.supportsTogglingSuspense(root.id)) {
37
- return [];
38
- }
39
- const suspenseTreeList: SuspenseNode[] = [];
40
- const suspense = store.getSuspenseByID(root.id);
41
- if (suspense !== null) {
42
- const stack = [suspense];
43
- while (stack.length > 0) {
44
- const current = stack.pop();
45
- if (current === undefined) {
46
- continue;
47
- }
48
- // Include the root even if we won't suspend it.
49
- // You should be able to see what suspended the shell.
50
- suspenseTreeList.push(current);
51
- // Add children in reverse order to maintain document order
52
- for (let j = current.children.length - 1; j >= 0; j--) {
53
- const childSuspense = store.getSuspenseByID(current.children[j]);
54
- if (childSuspense !== null) {
55
- stack.push(childSuspense);
56
- }
57
- }
58
- }
59
- }
60
-
61
- return suspenseTreeList;
62
-}
63
-
64
-function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
25
+function SuspenseTimelineInput() {
26
const bridge = useContext(BridgeContext);
66
- const store = useSuspenseStore();
67
- const dispatch = useContext(TreeDispatcherContext);
27
+ const store = useContext(StoreContext);
28
+ const treeDispatch = useContext(TreeDispatcherContext);
29
+ const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
30
const {highlightHostInstance, clearHighlightHostInstance} =
31
useHighlightHostInstance();
32
71
- const timeline = useMemo(() => {
72
- return getSuspendableDocumentOrderSuspense(store, rootID);
73
- }, [store, store.revisionSuspense, rootID]);
33
+ const {
34
+ selectedRootID: rootID,
35
+ timeline,
36
+ timelineIndex,
37
+ } = useContext(SuspenseTreeStateContext);
38
39
const inputRef = useRef<HTMLElement | null>(null);
40
const inputBBox = useRef<ClientRect | null>(null);
@@ -97,15 +61,11 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
61
62
const min = 0;
63
const max = timeline.length > 0 ? timeline.length - 1 : 0;
100
- const [value, setValue] = useState(max);
101
-
102
- if (value > max) {
103
- // TODO: Handle timeline changes
104
- setValue(max);
105
- }
64
107
- if (rootID === undefined) {
108
- return <div className={styles.SuspenseTimelineInput}>Root not found.</div>;
65
+ if (rootID === null) {
66
+ return (
67
+ <div className={styles.SuspenseTimelineInput}>No root selected.</div>
68
+ );
69
}
70
71
if (!store.supportsTogglingSuspense(rootID)) {
@@ -124,8 +84,21 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
84
);
85
}
86
87
+ function switchSuspenseNode(nextTimelineIndex: number) {
88
+ const nextSelectedSuspenseID = timeline[nextTimelineIndex];
89
+ highlightHostInstance(nextSelectedSuspenseID);
90
+ treeDispatch({
91
+ type: 'SELECT_ELEMENT_BY_ID',
92
+ payload: nextSelectedSuspenseID,
93
+ });
94
+ suspenseTreeDispatch({
95
+ type: 'SUSPENSE_SET_TIMELINE_INDEX',
96
+ payload: nextTimelineIndex,
97
+ });
98
+ }
99
+
100
function handleChange(event: SyntheticEvent) {
128
- if (rootID === undefined) {
101
+ if (rootID === null) {
102
return;
103
}
104
const rendererID = store.getRendererIDForElement(rootID);
@@ -136,10 +109,8 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
109
return;
110
}
111
139
- const pendingValue = +event.currentTarget.value;
140
- const suspendedSet = timeline
141
- .slice(pendingValue)
142
- .map(suspense => suspense.id);
112
+ const pendingTimelineIndex = +event.currentTarget.value;
113
+ const suspendedSet = timeline.slice(pendingTimelineIndex);
114
115
bridge.send('overrideSuspenseMilestone', {
116
rendererID,
@@ -147,11 +118,7 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
118
suspendedSet,
119
});
120
150
- const suspense = timeline[pendingValue];
151
- const elementID = suspense.id;
152
- highlightHostInstance(elementID);
153
- dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: elementID});
154
- setValue(pendingValue);
121
+ switchSuspenseNode(pendingTimelineIndex);
122
}
123
124
function handleBlur() {
@@ -159,10 +126,7 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
126
}
127
128
function handleFocus() {
162
- const suspense = timeline[value];
163
-
164
- dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspense.id});
165
- highlightHostInstance(suspense.id);
129
+ switchSuspenseNode(timelineIndex);
130
}
131
132
function handlePointerMove(event: SyntheticPointerEvent) {
@@ -180,19 +144,19 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
144
max,
145
),
146
);
183
- const suspense = timeline[hoveredValue];
184
- if (suspense === undefined) {
147
+ const suspenseID = timeline[hoveredValue];
148
+ if (suspenseID === undefined) {
149
throw new Error(
150
`Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`,
151
);
152
}
189
- highlightHostInstance(suspense.id);
153
+ highlightHostInstance(suspenseID);
154
}
155
156
return (
157
<>
158
<div>
195
- {value}/{max}
159
+ {timelineIndex}/{max}
160
</div>
161
<div className={styles.SuspenseTimelineInput}>
162
<input
@@ -200,7 +164,7 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
164
type="range"
165
min={min}
166
max={max}
203
- value={value}
167
+ value={timelineIndex}
168
onBlur={handleBlur}
169
onChange={handleChange}
170
onFocus={handleFocus}
@@ -214,38 +178,37 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
178
}
179
180
export default function SuspenseTimeline(): React$Node {
217
- const store = useSuspenseStore();
218
-
219
- const roots = store.roots;
220
- const defaultSelectedRootID = roots.find(rootID => {
221
- const suspense = store.getSuspenseByID(rootID);
222
- return (
223
- store.supportsTogglingSuspense(rootID) &&
224
- suspense !== null &&
225
- suspense.children.length > 1
226
- );
227
- });
228
- const [selectedRootID, setSelectedRootID] = useState(defaultSelectedRootID);
229
-
230
- if (selectedRootID === undefined && defaultSelectedRootID !== undefined) {
231
- setSelectedRootID(defaultSelectedRootID);
232
- }
181
+ const store = useContext(StoreContext);
182
+ const {roots, selectedRootID} = useContext(SuspenseTreeStateContext);
183
+ const treeDispatch = useContext(TreeDispatcherContext);
184
+ const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
185
186
function handleChange(event: SyntheticEvent) {
187
const newRootID = +event.currentTarget.value;
188
// TODO: scrollIntoView both suspense rects and host instance.
237
- setSelectedRootID(newRootID);
189
+ const nextTimeline = store.getSuspendableDocumentOrderSuspense(newRootID);
190
+ suspenseTreeDispatch({
191
+ type: 'SET_SUSPENSE_TIMELINE',
192
+ payload: [nextTimeline, newRootID],
193
+ });
194
+ if (nextTimeline.length > 0) {
195
+ const milestone = nextTimeline[nextTimeline.length - 1];
196
+ treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: milestone});
197
+ }
198
}
199
200
return (
201
<div className={styles.SuspenseTimelineContainer}>
242
- <SuspenseTimelineInput key={selectedRootID} rootID={selectedRootID} />
202
+ <SuspenseTimelineInput key={selectedRootID} />
203
{roots.length > 0 && (
204
<select
205
aria-label="Select Suspense Root"
206
className={styles.SuspenseTimelineRootSwitcher}
207
onChange={handleChange}
248
- value={selectedRootID}>
208
+ value={selectedRootID === null ? -1 : selectedRootID}>
209
+ <option disabled={true} value={-1}>
210
+ ----
211
+ </option>
212
{roots.map(rootID => {
213
// TODO: Use name
214
const name = '#' + rootID;
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+267
-37
@@ -7,6 +7,10 @@
7
* @flow
8
*/
9
import type {ReactContext} from 'shared/ReactTypes';
10
+import type {
11
+ Element,
12
+ SuspenseNode,
13
+} from 'react-devtools-shared/src/frontend/types';
14
import type Store from '../../store';
15
16
import * as React from 'react';
@@ -20,10 +24,45 @@ import {
24
} from 'react';
25
import {StoreContext} from '../context';
26
23
-export type SuspenseTreeState = {};
27
+export type SuspenseTreeState = {
28
+ lineage: $ReadOnlyArray<SuspenseNode['id']> | null,
29
+ roots: $ReadOnlyArray<SuspenseNode['id']>,
30
+ selectedRootID: SuspenseNode['id'] | null,
31
+ selectedSuspenseID: SuspenseNode['id'] | null,
32
+ timeline: $ReadOnlyArray<SuspenseNode['id']>,
33
+ timelineIndex: number | -1,
34
+};
35
25
-// unused for now
26
-export type SuspenseTreeAction = {type: 'unused'};
36
+type ACTION_SUSPENSE_TREE_MUTATION = {
37
+ type: 'HANDLE_SUSPENSE_TREE_MUTATION',
38
+ payload: [Map<SuspenseNode['id'], SuspenseNode['id']>],
39
+};
40
+type ACTION_SET_SUSPENSE_LINEAGE = {
41
+ type: 'SET_SUSPENSE_LINEAGE',
42
+ payload: SuspenseNode['id'],
43
+};
44
+type ACTION_SELECT_SUSPENSE_BY_ID = {
45
+ type: 'SELECT_SUSPENSE_BY_ID',
46
+ payload: SuspenseNode['id'],
47
+};
48
+type ACTION_SET_SUSPENSE_TIMELINE = {
49
+ type: 'SET_SUSPENSE_TIMELINE',
50
+ payload: [
51
+ $ReadOnlyArray<SuspenseNode['id']>,
52
+ // The next Suspense ID to select in the timeline
53
+ SuspenseNode['id'] | null,
54
+ ],
55
+};
56
+type ACTION_SUSPENSE_SET_TIMELINE_INDEX = {
57
+ type: 'SUSPENSE_SET_TIMELINE_INDEX',
58
+ payload: number,
59
+};
60
+export type SuspenseTreeAction =
61
+ | ACTION_SUSPENSE_TREE_MUTATION
62
+ | ACTION_SET_SUSPENSE_LINEAGE
63
+ | ACTION_SELECT_SUSPENSE_BY_ID
64
+ | ACTION_SET_SUSPENSE_TIMELINE
65
+ | ACTION_SUSPENSE_SET_TIMELINE_INDEX;
66
export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void;
67
68
const SuspenseTreeStateContext: ReactContext<SuspenseTreeState> =
@@ -38,39 +77,56 @@ type Props = {
77
children: React$Node,
78
};
79
41
-/**
42
- * The Store is mutable. This Hook ensures renders read the latest Suspense related
43
- * data.
44
- */
45
-function useSuspenseStore(): Store {
46
- const store = useContext(StoreContext);
47
- const [, storeUpdated] = useReducer<number, number, void>(
48
- (x: number) => (x + 1) % Number.MAX_SAFE_INTEGER,
49
- 0,
50
- );
51
- const initialRevision = useMemo(() => store.revisionSuspense, [store]);
52
- // We're currently storing everything Suspense related in the same Store as
53
- // Components. However, most reads are currently stateless. This ensures
54
- // the latest state is always read from the Store.
55
- useEffect(() => {
56
- const handleSuspenseTreeMutated = () => {
57
- storeUpdated();
58
- };
80
+function getDefaultRootID(store: Store): Element['id'] | null {
81
+ const designatedRootID = store.roots.find(rootID => {
82
+ const suspense = store.getSuspenseByID(rootID);
83
+ return (
84
+ store.supportsTogglingSuspense(rootID) &&
85
+ suspense !== null &&
86
+ suspense.children.length > 1
87
+ );
88
+ });
89
60
- // Since this is a passive effect, the tree may have been mutated before our initial subscription.
61
- if (store.revisionSuspense !== initialRevision) {
62
- // At the moment, we can treat this as a mutation.
63
- handleSuspenseTreeMutated();
64
- }
90
+ return designatedRootID === undefined ? null : designatedRootID;
91
+}
92
66
- store.addListener('suspenseTreeMutated', handleSuspenseTreeMutated);
67
- return () =>
68
- store.removeListener('suspenseTreeMutated', handleSuspenseTreeMutated);
69
- }, [initialRevision, store]);
70
- return store;
93
+function getInitialState(store: Store): SuspenseTreeState {
94
+ let initialState: SuspenseTreeState;
95
+ const selectedRootID = getDefaultRootID(store);
96
+ // TODO: Default to nearest from inspected
97
+ if (selectedRootID === null) {
98
+ initialState = {
99
+ selectedSuspenseID: null,
100
+ lineage: null,
101
+ roots: store.roots,
102
+ selectedRootID,
103
+ timeline: [],
104
+ timelineIndex: -1,
105
+ };
106
+ } else {
107
+ const timeline = store.getSuspendableDocumentOrderSuspense(selectedRootID);
108
+ const timelineIndex = timeline.length - 1;
109
+ const selectedSuspenseID =
110
+ timelineIndex === -1 ? null : timeline[timelineIndex];
111
+ const lineage =
112
+ selectedSuspenseID !== null
113
+ ? store.getSuspenseLineage(selectedSuspenseID)
114
+ : [];
115
+ initialState = {
116
+ selectedSuspenseID,
117
+ lineage,
118
+ roots: store.roots,
119
+ selectedRootID,
120
+ timeline,
121
+ timelineIndex,
122
+ };
123
+ }
124
+
125
+ return initialState;
126
}
127
128
function SuspenseTreeContextController({children}: Props): React.Node {
129
+ const store = useContext(StoreContext);
130
// This reducer is created inline because it needs access to the Store.
131
// The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
132
// so it's okay for the reducer to have an empty dependencies array.
@@ -80,17 +136,192 @@ function SuspenseTreeContextController({children}: Props): React.Node {
136
state: SuspenseTreeState,
137
action: SuspenseTreeAction,
138
): SuspenseTreeState => {
83
- const {type} = action;
84
- switch (type) {
139
+ switch (action.type) {
140
+ case 'HANDLE_SUSPENSE_TREE_MUTATION': {
141
+ let {selectedSuspenseID} = state;
142
+ // If the currently-selected Element has been removed from the tree, update selection state.
143
+ const removedIDs = action.payload[0];
144
+ // Find the closest parent that wasn't removed during this batch.
145
+ // We deduce the parent-child mapping from removedIDs (id -> parentID)
146
+ // because by now it's too late to read them from the store.
147
+
148
+ while (
149
+ selectedSuspenseID !== null &&
150
+ removedIDs.has(selectedSuspenseID)
151
+ ) {
152
+ // $FlowExpectedError[incompatible-type]
153
+ selectedSuspenseID = removedIDs.get(selectedSuspenseID);
154
+ }
155
+ if (selectedSuspenseID === 0) {
156
+ // The whole root was removed.
157
+ selectedSuspenseID = null;
158
+ }
159
+
160
+ let selectedTimelineID =
161
+ state.timeline === null
162
+ ? null
163
+ : state.timeline[state.timelineIndex];
164
+ while (
165
+ selectedTimelineID !== null &&
166
+ removedIDs.has(selectedTimelineID)
167
+ ) {
168
+ // $FlowExpectedError[incompatible-type]
169
+ selectedTimelineID = removedIDs.get(selectedTimelineID);
170
+ }
171
+
172
+ let nextRootID = state.selectedRootID;
173
+ if (selectedTimelineID !== null && selectedTimelineID !== 0) {
174
+ nextRootID =
175
+ store.getSuspenseRootIDForSuspense(selectedTimelineID);
176
+ }
177
+ if (nextRootID === null) {
178
+ nextRootID = getDefaultRootID(store);
179
+ }
180
+
181
+ const nextTimeline =
182
+ nextRootID === null
183
+ ? []
184
+ : // TODO: Handle different timeline modes (e.g. random order)
185
+ store.getSuspendableDocumentOrderSuspense(nextRootID);
186
+
187
+ let nextTimelineIndex =
188
+ selectedTimelineID === null || nextTimeline.length === 0
189
+ ? -1
190
+ : nextTimeline.indexOf(selectedTimelineID);
191
+ if (nextTimeline.length > 0 && nextTimelineIndex === -1) {
192
+ nextTimelineIndex = nextTimeline.length - 1;
193
+ selectedSuspenseID = nextTimeline[nextTimelineIndex];
194
+ }
195
+
196
+ if (selectedSuspenseID === null && nextTimeline.length > 0) {
197
+ selectedSuspenseID = nextTimeline[nextTimeline.length - 1];
198
+ }
199
+
200
+ const nextLineage =
201
+ selectedSuspenseID !== null &&
202
+ state.selectedSuspenseID !== selectedSuspenseID
203
+ ? store.getSuspenseLineage(selectedSuspenseID)
204
+ : state.lineage;
205
+
206
+ return {
207
+ ...state,
208
+ lineage: nextLineage,
209
+ roots: store.roots,
210
+ selectedRootID: nextRootID,
211
+ selectedSuspenseID,
212
+ timeline: nextTimeline,
213
+ timelineIndex: nextTimelineIndex,
214
+ };
215
+ }
216
+ case 'SELECT_SUSPENSE_BY_ID': {
217
+ const selectedSuspenseID = action.payload;
218
+ const selectedRootID =
219
+ store.getSuspenseRootIDForSuspense(selectedSuspenseID);
220
+
221
+ return {
222
+ ...state,
223
+ selectedSuspenseID,
224
+ selectedRootID,
225
+ };
226
+ }
227
+ case 'SET_SUSPENSE_LINEAGE': {
228
+ const suspenseID = action.payload;
229
+ const lineage = store.getSuspenseLineage(suspenseID);
230
+ const selectedRootID =
231
+ store.getSuspenseRootIDForSuspense(suspenseID);
232
+
233
+ return {
234
+ ...state,
235
+ lineage,
236
+ selectedSuspenseID: suspenseID,
237
+ selectedRootID,
238
+ };
239
+ }
240
+ case 'SET_SUSPENSE_TIMELINE': {
241
+ const previousMilestoneIndex = state.timelineIndex;
242
+ const previousTimeline = state.timeline;
243
+ const nextTimeline = action.payload[0];
244
+ const nextRootID: SuspenseNode['id'] | null = action.payload[1];
245
+ let nextLineage = state.lineage;
246
+ let nextMilestoneIndex: number | -1 = -1;
247
+ let nextSelectedSuspenseID = state.selectedSuspenseID;
248
+ // Action has indicated it has no preference for the selected Node.
249
+ // Try to reconcile the new timeline with the previous index.
250
+ if (
251
+ nextRootID === null &&
252
+ previousTimeline !== null &&
253
+ previousMilestoneIndex !== null
254
+ ) {
255
+ const previousMilestoneID =
256
+ previousTimeline[previousMilestoneIndex];
257
+ nextMilestoneIndex = nextTimeline.indexOf(previousMilestoneID);
258
+ if (nextMilestoneIndex === -1) {
259
+ nextMilestoneIndex = nextTimeline.length - 1;
260
+ }
261
+ } else if (nextRootID !== null) {
262
+ nextMilestoneIndex = nextTimeline.length - 1;
263
+ nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
264
+ nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
265
+ }
266
+
267
+ return {
268
+ ...state,
269
+ selectedSuspenseID: nextSelectedSuspenseID,
270
+ lineage: nextLineage,
271
+ selectedRootID:
272
+ nextRootID === null ? state.selectedRootID : nextRootID,
273
+ timeline: nextTimeline,
274
+ timelineIndex: nextMilestoneIndex,
275
+ };
276
+ }
277
+ case 'SUSPENSE_SET_TIMELINE_INDEX': {
278
+ const nextTimelineIndex = action.payload;
279
+ const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
280
+ const nextLineage = store.getSuspenseLineage(
281
+ nextSelectedSuspenseID,
282
+ );
283
+
284
+ return {
285
+ ...state,
286
+ lineage: nextLineage,
287
+ selectedSuspenseID: nextSelectedSuspenseID,
288
+ timelineIndex: nextTimelineIndex,
289
+ };
290
+ }
291
default:
86
- throw new Error(`Unrecognized action "${type}"`);
292
+ throw new Error(`Unrecognized action "${action.type}"`);
293
}
294
},
295
[],
296
);
297
92
- const initialState: SuspenseTreeState = {};
93
- const [state, dispatch] = useReducer(reducer, initialState);
298
+ const [state, dispatch] = useReducer(reducer, store, getInitialState);
299
+
300
+ const initialRevision = useMemo(() => store.revisionSuspense, [store]);
301
+ // We're currently storing everything Suspense related in the same Store as
302
+ // Components. However, most reads are currently stateless. This ensures
303
+ // the latest state is always read from the Store.
304
+ useEffect(() => {
305
+ const handleSuspenseTreeMutated = ([removedElementIDs]: [
306
+ Map<number, number>,
307
+ ]) => {
308
+ dispatch({
309
+ type: 'HANDLE_SUSPENSE_TREE_MUTATION',
310
+ payload: [removedElementIDs],
311
+ });
312
+ };
313
+
314
+ // Since this is a passive effect, the tree may have been mutated before our initial subscription.
315
+ if (store.revisionSuspense !== initialRevision) {
316
+ // At the moment, we can treat this as a mutation.
317
+ handleSuspenseTreeMutated([new Map()]);
318
+ }
319
+
320
+ store.addListener('suspenseTreeMutated', handleSuspenseTreeMutated);
321
+ return () =>
322
+ store.removeListener('suspenseTreeMutated', handleSuspenseTreeMutated);
323
+ }, [initialRevision, store]);
324
+
325
const transitionDispatch = useMemo(
326
() => (action: SuspenseTreeAction) =>
327
startTransition(() => {
@@ -112,5 +343,4 @@ export {
343
SuspenseTreeDispatcherContext,
344
SuspenseTreeStateContext,
345
SuspenseTreeContextController,
115
- useSuspenseStore,
346
};