Skip to content

Commit 8c15014

Browse files
authored
[DevTools] Preserve Suspense lineage when clicking through breadcrumbs (facebook#34422)
1 parent bd9e6e0 commit 8c15014

File tree

6 files changed

+454
-192
lines changed

6 files changed

+454
-192
lines changed

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

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export default class Store extends EventEmitter<{
111111
roots: [],
112112
rootSupportsBasicProfiling: [],
113113
rootSupportsTimelineProfiling: [],
114-
suspenseTreeMutated: [],
114+
suspenseTreeMutated: [[Map<SuspenseNode['id'], SuspenseNode['id']>]],
115115
supportsNativeStyleEditor: [],
116116
supportsReloadAndProfile: [],
117117
unsupportedBridgeProtocolDetected: [],
@@ -847,6 +847,76 @@ export default class Store extends EventEmitter<{
847847
return list;
848848
}
849849

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+
850920
getRendererIDForElement(id: number): number | null {
851921
let current = this._idToElement.get(id);
852922
while (current !== undefined) {
@@ -1030,6 +1100,8 @@ export default class Store extends EventEmitter<{
10301100
const addedElementIDs: Array<number> = [];
10311101
// This is a mapping of removed ID -> parent ID:
10321102
const removedElementIDs: Map<number, number> = new Map();
1103+
const removedSuspenseIDs: Map<SuspenseNode['id'], SuspenseNode['id']> =
1104+
new Map();
10331105
// We'll use the parent ID to adjust selection if it gets deleted.
10341106

10351107
let i = 2;
@@ -1541,6 +1613,7 @@ export default class Store extends EventEmitter<{
15411613
}
15421614

15431615
this._idToSuspense.delete(id);
1616+
removedSuspenseIDs.set(id, parentID);
15441617

15451618
let parentSuspense: ?SuspenseNode = null;
15461619
if (parentID === 0) {
@@ -1748,7 +1821,7 @@ export default class Store extends EventEmitter<{
17481821
}
17491822

17501823
if (hasSuspenseTreeChanged) {
1751-
this.emit('suspenseTreeMutated');
1824+
this.emit('suspenseTreeMutated', [removedSuspenseIDs]);
17521825
}
17531826

17541827
if (__DEBUG__) {

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

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

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 {selectedSuspenseID, 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={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+
})}
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)