Skip to content

Commit 541d5e8

Browse files
committed
[DevTools] Preserve Suspense lineage when clicking through breadcrumbs
1 parent 3fb190f commit 541d5e8

File tree

6 files changed

+432
-191
lines changed

6 files changed

+432
-191
lines changed

packages/react-devtools-shared/src/devtools/store.js

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ export default class Store extends EventEmitter<{
111111
roots: [],
112112
rootSupportsBasicProfiling: [],
113113
rootSupportsTimelineProfiling: [],
114-
suspenseTreeMutated: [],
114+
suspenseTreeMutated: [
115+
[Array<SuspenseNode['id']>, Map<SuspenseNode['id'], SuspenseNode['id']>],
116+
],
115117
supportsNativeStyleEditor: [],
116118
supportsReloadAndProfile: [],
117119
unsupportedBridgeProtocolDetected: [],
@@ -847,6 +849,76 @@ export default class Store extends EventEmitter<{
847849
return list;
848850
}
849851

852+
getSuspenseLineage(
853+
suspenseID: SuspenseNode['id'],
854+
): $ReadOnlyArray<SuspenseNode['id']> {
855+
const lineage: Array<SuspenseNode['id']> = [];
856+
let next: null | SuspenseNode = this.getSuspenseByID(suspenseID);
857+
while (next !== null) {
858+
if (next.parentID === 0) {
859+
next = null;
860+
} else {
861+
lineage.unshift(next.id);
862+
next = this.getSuspenseByID(next.parentID);
863+
}
864+
}
865+
866+
return lineage;
867+
}
868+
869+
/**
870+
* Like {@link getRootIDForElement} but should be used for traversing Suspense since it works with disconnected nodes.
871+
*/
872+
getSuspenseRootIDForSuspense(id: SuspenseNode['id']): number | null {
873+
let current = this._idToSuspense.get(id);
874+
while (current !== undefined) {
875+
if (current.parentID === 0) {
876+
return current.id;
877+
} else {
878+
current = this._idToSuspense.get(current.parentID);
879+
}
880+
}
881+
return null;
882+
}
883+
884+
getSuspendableDocumentOrderSuspense(
885+
rootID: Element['id'] | void,
886+
): $ReadOnlyArray<SuspenseNode['id']> {
887+
if (rootID === undefined) {
888+
return [];
889+
}
890+
const root = this.getElementByID(rootID);
891+
if (root === null) {
892+
return [];
893+
}
894+
if (!this.supportsTogglingSuspense(root.id)) {
895+
return [];
896+
}
897+
const suspenseTreeList: SuspenseNode['id'][] = [];
898+
const suspense = this.getSuspenseByID(root.id);
899+
if (suspense !== null) {
900+
const stack = [suspense];
901+
while (stack.length > 0) {
902+
const current = stack.pop();
903+
if (current === undefined) {
904+
continue;
905+
}
906+
// Include the root even if we won't suspend it.
907+
// You should be able to see what suspended the shell.
908+
suspenseTreeList.push(current.id);
909+
// Add children in reverse order to maintain document order
910+
for (let j = current.children.length - 1; j >= 0; j--) {
911+
const childSuspense = this.getSuspenseByID(current.children[j]);
912+
if (childSuspense !== null) {
913+
stack.push(childSuspense);
914+
}
915+
}
916+
}
917+
}
918+
919+
return suspenseTreeList;
920+
}
921+
850922
getRendererIDForElement(id: number): number | null {
851923
let current = this._idToElement.get(id);
852924
while (current !== undefined) {
@@ -1028,8 +1100,11 @@ export default class Store extends EventEmitter<{
10281100
const rendererID = operations[0];
10291101

10301102
const addedElementIDs: Array<number> = [];
1103+
const addedSuspenseIDs: Array<SuspenseNode['id']> = [];
10311104
// This is a mapping of removed ID -> parent ID:
10321105
const removedElementIDs: Map<number, number> = new Map();
1106+
const removedSuspenseIDs: Map<SuspenseNode['id'], SuspenseNode['id']> =
1107+
new Map();
10331108
// We'll use the parent ID to adjust selection if it gets deleted.
10341109

10351110
let i = 2;
@@ -1509,6 +1584,7 @@ export default class Store extends EventEmitter<{
15091584
name,
15101585
rects,
15111586
});
1587+
addedSuspenseIDs.push(id);
15121588

15131589
hasSuspenseTreeChanged = true;
15141590
break;
@@ -1541,6 +1617,7 @@ export default class Store extends EventEmitter<{
15411617
}
15421618

15431619
this._idToSuspense.delete(id);
1620+
removedSuspenseIDs.set(id, parentID);
15441621

15451622
let parentSuspense: ?SuspenseNode = null;
15461623
if (parentID === 0) {
@@ -1748,7 +1825,7 @@ export default class Store extends EventEmitter<{
17481825
}
17491826

17501827
if (hasSuspenseTreeChanged) {
1751-
this.emit('suspenseTreeMutated');
1828+
this.emit('suspenseTreeMutated', [addedSuspenseIDs, removedSuspenseIDs]);
17521829
}
17531830

17541831
if (__DEBUG__) {

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
background: var(--color-button-background);
2020
border: none;
2121
border-radius: 0.25rem;
22+
font-family: var(--font-family-monospace);
23+
font-size: var(--font-size-monospace-normal);
2224
padding: 0.25rem;
2325
white-space: nowrap;
2426
}

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js

Lines changed: 33 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,68 +12,52 @@ import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/Syntheti
1212

1313
import * as React from 'react';
1414
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';
1917
import {useHighlightHostInstance} from '../hooks';
2018
import styles from './SuspenseBreadcrumbs.css';
21-
import {useSuspenseStore} from './SuspenseTreeContext';
19+
import {
20+
SuspenseTreeStateContext,
21+
SuspenseTreeDispatcherContext,
22+
} from './SuspenseTreeContext';
2223

2324
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 {inspectedSuspenseID, lineage} = useContext(SuspenseTreeStateContext);
2729

2830
const {highlightHostInstance, clearHighlightHostInstance} =
2931
useHighlightHostInstance();
3032

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) {
5434
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});
5637
}
5738

5839
return (
5940
<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={inspectedSuspenseID === 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+
})}
7761
</ol>
7862
);
7963
}

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@ import {
1919
TreeDispatcherContext,
2020
TreeStateContext,
2121
} from '../Components/TreeContext';
22+
import {StoreContext} from '../context';
2223
import {useHighlightHostInstance} from '../hooks';
2324
import styles from './SuspenseRects.css';
24-
import {useSuspenseStore} from './SuspenseTreeContext';
25+
import {
26+
SuspenseTreeStateContext,
27+
SuspenseTreeDispatcherContext,
28+
} from './SuspenseTreeContext';
2529
import typeof {
2630
SyntheticMouseEvent,
2731
SyntheticPointerEvent,
@@ -44,8 +48,9 @@ function SuspenseRects({
4448
}: {
4549
suspenseID: SuspenseNode['id'],
4650
}): 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);
4954

5055
const {inspectedElementID} = useContext(TreeStateContext);
5156

@@ -64,7 +69,11 @@ function SuspenseRects({
6469
return;
6570
}
6671
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+
});
6877
}
6978

7079
function handlePointerOver(event: SyntheticPointerEvent) {
@@ -157,7 +166,7 @@ function SuspenseRectsShell({
157166
}: {
158167
rootID: SuspenseNode['id'],
159168
}): React$Node {
160-
const store = useSuspenseStore();
169+
const store = useContext(StoreContext);
161170
const root = store.getSuspenseByID(rootID);
162171
if (root === null) {
163172
console.warn(`<Element> Could not find suspense node id ${rootID}`);
@@ -174,9 +183,9 @@ function SuspenseRectsShell({
174183
}
175184

176185
function SuspenseRectsContainer(): React$Node {
177-
const store = useSuspenseStore();
186+
const store = useContext(StoreContext);
178187
// 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);
180189

181190
const boundingRect = getDocumentBoundingRect(store, roots);
182191

0 commit comments

Comments
 (0)