@samitouri / QOS-React / commits / 8c1501452c

[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 };