Skip to content

Commit d57a481

Browse files
committed
[DevTools] Show boundaries with unique suspenders only by default in the timeline
1 parent beae8b5 commit d57a481

File tree

9 files changed

+156
-18
lines changed

9 files changed

+156
-18
lines changed

packages/react-devtools-shared/src/backend/fiber/renderer.js

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
SUSPENSE_TREE_OPERATION_REMOVE,
8989
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
9090
SUSPENSE_TREE_OPERATION_RESIZE,
91+
SUSPENSE_TREE_OPERATION_SUSPENDERS,
9192
UNKNOWN_SUSPENDERS_NONE,
9293
UNKNOWN_SUSPENDERS_REASON_PRODUCTION,
9394
UNKNOWN_SUSPENDERS_REASON_OLD_VERSION,
@@ -2016,6 +2017,7 @@ export function attach(
20162017
const pendingOperations: OperationsArray = [];
20172018
const pendingRealUnmountedIDs: Array<FiberInstance['id']> = [];
20182019
const pendingRealUnmountedSuspenseIDs: Array<FiberInstance['id']> = [];
2020+
const pendingSuspenderChanges: Set<FiberInstance['id']> = new Set();
20192021
let pendingOperationsQueue: Array<OperationsArray> | null = [];
20202022
const pendingStringTable: Map<string, StringTableEntry> = new Map();
20212023
let pendingStringTableLength: number = 0;
@@ -2047,6 +2049,7 @@ export function attach(
20472049
pendingOperations.length === 0 &&
20482050
pendingRealUnmountedIDs.length === 0 &&
20492051
pendingRealUnmountedSuspenseIDs.length === 0 &&
2052+
pendingSuspenderChanges.size === 0 &&
20502053
pendingUnmountedRootID === null
20512054
);
20522055
}
@@ -2113,6 +2116,7 @@ export function attach(
21132116
pendingRealUnmountedIDs.length +
21142117
(pendingUnmountedRootID === null ? 0 : 1);
21152118
const numUnmountSuspenseIDs = pendingRealUnmountedSuspenseIDs.length;
2119+
const numSuspenderChanges = pendingSuspenderChanges.size;
21162120
21172121
const operations = new Array<number>(
21182122
// Identify which renderer this update is coming from.
@@ -2128,7 +2132,10 @@ export function attach(
21282132
// [TREE_OPERATION_REMOVE, removedIDLength, ...ids]
21292133
(numUnmountIDs > 0 ? 2 + numUnmountIDs : 0) +
21302134
// Regular operations
2131-
pendingOperations.length,
2135+
pendingOperations.length +
2136+
// All suspender changes are batched in a single message.
2137+
// [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders]]
2138+
(numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 2 : 0),
21322139
);
21332140
21342141
// Identify which renderer this update is coming from.
@@ -2191,19 +2198,40 @@ export function attach(
21912198
i++;
21922199
}
21932200
}
2194-
// Fill in the rest of the operations.
2201+
2202+
// Fill in pending operations.
21952203
for (let j = 0; j < pendingOperations.length; j++) {
21962204
operations[i + j] = pendingOperations[j];
21972205
}
21982206
i += pendingOperations.length;
21992207
2208+
// Suspender changes might affect newly mounted nodes that we already recorded
2209+
// in pending operations.
2210+
if (numSuspenderChanges > 0) {
2211+
operations[i++] = SUSPENSE_TREE_OPERATION_SUSPENDERS;
2212+
operations[i++] = numSuspenderChanges;
2213+
pendingSuspenderChanges.forEach(fiberIdWithChanges => {
2214+
const suspense = idToSuspenseNodeMap.get(fiberIdWithChanges);
2215+
if (suspense === undefined) {
2216+
// Probably forgot to cleanup pendingSuspenderChanges when this node was removed.
2217+
throw new Error(
2218+
`Could send suspender changes for "${fiberIdWithChanges}" since the Fiber no longer exists.`,
2219+
);
2220+
}
2221+
operations[i++] = fiberIdWithChanges;
2222+
operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
2223+
// TODO: Include unknown suspenders reason
2224+
});
2225+
}
2226+
22002227
// Let the frontend know about tree operations.
22012228
flushOrQueueOperations(operations);
22022229
22032230
// Reset all of the pending state now that we've told the frontend about it.
22042231
pendingOperations.length = 0;
22052232
pendingRealUnmountedIDs.length = 0;
22062233
pendingRealUnmountedSuspenseIDs.length = 0;
2234+
pendingSuspenderChanges.clear();
22072235
pendingUnmountedRootID = null;
22082236
pendingStringTable.clear();
22092237
pendingStringTableLength = 0;
@@ -2688,6 +2716,19 @@ export function attach(
26882716
}
26892717
}
26902718
2719+
function recordSuspenseSuspenders(suspenseNode: SuspenseNode): void {
2720+
if (__DEBUG__) {
2721+
console.log('recordSuspenseSuspenders()', suspenseNode);
2722+
}
2723+
const fiberInstance = suspenseNode.instance;
2724+
if (fiberInstance.kind !== FIBER_INSTANCE) {
2725+
// TODO: Suspender updates of filtered Suspense nodes are currently dropped.
2726+
return;
2727+
}
2728+
2729+
pendingSuspenderChanges.add(fiberInstance.id);
2730+
}
2731+
26912732
function recordSuspenseUnmount(suspenseInstance: SuspenseNode): void {
26922733
if (__DEBUG__) {
26932734
console.log(
@@ -2709,6 +2750,7 @@ export function attach(
27092750
// and later arrange them in the correct order.
27102751
pendingRealUnmountedSuspenseIDs.push(id);
27112752
2753+
pendingSuspenderChanges.delete(id);
27122754
idToSuspenseNodeMap.delete(id);
27132755
}
27142756
@@ -2779,6 +2821,7 @@ export function attach(
27792821
) {
27802822
// This didn't exist in the parent before, so let's mark this boundary as having a unique suspender.
27812823
parentSuspenseNode.hasUniqueSuspenders = true;
2824+
recordSuspenseSuspenders(parentSuspenseNode);
27822825
}
27832826
}
27842827
// We have observed at least one known reason this might have been suspended.
@@ -2820,6 +2863,9 @@ export function attach(
28202863
// We have found a child boundary that depended on the unblocked I/O.
28212864
// It can now be marked as having unique suspenders. We can skip its children
28222865
// since they'll still be blocked by this one.
2866+
if (!node.hasUniqueSuspenders) {
2867+
recordSuspenseSuspenders(node);
2868+
}
28232869
node.hasUniqueSuspenders = true;
28242870
node.hasUnknownSuspenders = false;
28252871
} else if (node.firstChild !== null) {
@@ -3522,6 +3568,9 @@ export function attach(
35223568
// Unfortunately if we don't have any DEV time debug info or debug thenables then
35233569
// we have no meta data to show. However, we still mark this Suspense boundary as
35243570
// participating in the loading sequence since apparently it can suspend.
3571+
if (!suspenseNode.hasUniqueSuspenders) {
3572+
recordSuspenseSuspenders(suspenseNode);
3573+
}
35253574
suspenseNode.hasUniqueSuspenders = true;
35263575
// We have not seen any reason yet for why this suspense node might have been
35273576
// suspended but it clearly has been at some point. If we later discover a reason

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const SUSPENSE_TREE_OPERATION_ADD = 8;
2828
export const SUSPENSE_TREE_OPERATION_REMOVE = 9;
2929
export const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10;
3030
export const SUSPENSE_TREE_OPERATION_RESIZE = 11;
31+
export const SUSPENSE_TREE_OPERATION_SUSPENDERS = 12;
3132

3233
export const PROFILING_FLAG_BASIC_SUPPORT = 0b01;
3334
export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b10;

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
SUSPENSE_TREE_OPERATION_REMOVE,
2525
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
2626
SUSPENSE_TREE_OPERATION_RESIZE,
27+
SUSPENSE_TREE_OPERATION_SUSPENDERS,
2728
} from '../constants';
2829
import {ElementTypeRoot} from '../frontend/types';
2930
import {
@@ -32,6 +33,7 @@ import {
3233
shallowDiffers,
3334
utfDecodeStringWithRanges,
3435
parseElementDisplayNameFromBackend,
36+
printOperationsArray,
3537
} from '../utils';
3638
import {localStorageGetItem, localStorageSetItem} from '../storage';
3739
import {__DEBUG__} from '../constants';
@@ -1508,6 +1510,7 @@ export default class Store extends EventEmitter<{
15081510
children: [],
15091511
name,
15101512
rects,
1513+
hasUniqueSuspenders: false,
15111514
});
15121515

15131516
hasSuspenseTreeChanged = true;
@@ -1676,6 +1679,42 @@ export default class Store extends EventEmitter<{
16761679

16771680
break;
16781681
}
1682+
case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
1683+
const changeLength = operations[i + 1];
1684+
i += 2;
1685+
1686+
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
1687+
const id = operations[i];
1688+
const hasUniqueSuspenders = operations[i + 1] === 1;
1689+
const suspense = this._idToSuspense.get(id);
1690+
1691+
if (suspense === undefined) {
1692+
this._throwAndEmitError(
1693+
Error(
1694+
`Cannot update suspenders of suspense node "${id}" because no matching node was found in the Store.`,
1695+
),
1696+
);
1697+
1698+
break;
1699+
}
1700+
1701+
i += 2;
1702+
1703+
if (__DEBUG__) {
1704+
const previousHasUniqueSuspenders = suspense.hasUniqueSuspenders;
1705+
debug(
1706+
'Suspender changes',
1707+
`Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} (was ${String(previousHasUniqueSuspenders)})`,
1708+
);
1709+
}
1710+
1711+
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
1712+
}
1713+
1714+
hasSuspenseTreeChanged = true;
1715+
1716+
break;
1717+
}
16791718
default:
16801719
this._throwAndEmitError(
16811720
new UnsupportedBridgeOperationError(

packages/react-devtools-shared/src/devtools/views/ButtonIcon.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ type Props = {
5252
type: IconType,
5353
};
5454

55-
const materialIconsViewBox = '0 -960 960 960';
55+
const panelIcons = '0 -960 960 820';
5656
export default function ButtonIcon({className = '', type}: Props): React.Node {
5757
let pathData = null;
5858
let viewBox = '0 0 24 24';
@@ -131,27 +131,27 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
131131
break;
132132
case 'panel-left-close':
133133
pathData = PATH_MATERIAL_PANEL_LEFT_CLOSE;
134-
viewBox = materialIconsViewBox;
134+
viewBox = panelIcons;
135135
break;
136136
case 'panel-left-open':
137137
pathData = PATH_MATERIAL_PANEL_LEFT_OPEN;
138-
viewBox = materialIconsViewBox;
138+
viewBox = panelIcons;
139139
break;
140140
case 'panel-right-close':
141141
pathData = PATH_MATERIAL_PANEL_RIGHT_CLOSE;
142-
viewBox = materialIconsViewBox;
142+
viewBox = panelIcons;
143143
break;
144144
case 'panel-right-open':
145145
pathData = PATH_MATERIAL_PANEL_RIGHT_OPEN;
146-
viewBox = materialIconsViewBox;
146+
viewBox = panelIcons;
147147
break;
148148
case 'panel-bottom-open':
149149
pathData = PATH_MATERIAL_PANEL_BOTTOM_OPEN;
150-
viewBox = materialIconsViewBox;
150+
viewBox = panelIcons;
151151
break;
152152
case 'panel-bottom-close':
153153
pathData = PATH_MATERIAL_PANEL_BOTTOM_CLOSE;
154-
viewBox = materialIconsViewBox;
154+
viewBox = panelIcons;
155155
break;
156156
case 'suspend':
157157
pathData = PATH_SUSPEND;

packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
SUSPENSE_TREE_OPERATION_REMOVE,
2121
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
2222
SUSPENSE_TREE_OPERATION_RESIZE,
23+
SUSPENSE_TREE_OPERATION_SUSPENDERS,
2324
} from 'react-devtools-shared/src/constants';
2425
import {
2526
parseElementDisplayNameFromBackend,
@@ -452,6 +453,18 @@ function updateTree(
452453
break;
453454
}
454455

456+
case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
457+
const changesLength = ((operations[i + 1]: any): number);
458+
459+
if (__DEBUG__) {
460+
const changes = operations.slice(i + 2, i + 2 + changesLength * 2);
461+
debug('Suspender changes', `[${changes.join(',')}]`);
462+
}
463+
464+
i += 2 + changesLength * 2;
465+
break;
466+
}
467+
455468
default:
456469
throw Error(`Unsupported Bridge operation "${operation}"`);
457470
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
display: flex;
44
flex-direction: row;
55
padding: 0.25rem;
6+
align-items: center;
67
}
78

89
.SuspenseTimelineInput {

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

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as React from 'react';
1414
import {useContext, useLayoutEffect, useMemo, useRef, useState} from 'react';
1515
import {BridgeContext} from '../context';
1616
import {TreeDispatcherContext} from '../Components/TreeContext';
17+
import Tooltip from '../Components/reach-ui/tooltip';
1718
import {useHighlightHostInstance} from '../hooks';
1819
import {useSuspenseStore} from './SuspenseTreeContext';
1920
import styles from './SuspenseTimeline.css';
@@ -22,9 +23,15 @@ import typeof {
2223
SyntheticPointerEvent,
2324
} from 'react-dom-bindings/src/events/SyntheticEvent';
2425

26+
/**
27+
* @param store
28+
* @param rootID
29+
* @param uniqueSuspendersOnly Filters out boundaries without unqiue suspenders
30+
*/
2531
function getSuspendableDocumentOrderSuspense(
2632
store: Store,
2733
rootID: Element['id'] | void,
34+
uniqueSuspendersOnly: boolean,
2835
): Array<SuspenseNode> {
2936
if (rootID === undefined) {
3037
return [];
@@ -36,7 +43,7 @@ function getSuspendableDocumentOrderSuspense(
3643
if (!store.supportsTogglingSuspense(root.id)) {
3744
return [];
3845
}
39-
const suspenseTreeList: SuspenseNode[] = [];
46+
const list: SuspenseNode[] = [];
4047
const suspense = store.getSuspenseByID(root.id);
4148
if (suspense !== null) {
4249
const stack = [suspense];
@@ -45,9 +52,11 @@ function getSuspendableDocumentOrderSuspense(
4552
if (current === undefined) {
4653
continue;
4754
}
48-
// Include the root even if we won't suspend it.
55+
// Include the root even if we won't show it suspended (because that's just blank).
4956
// You should be able to see what suspended the shell.
50-
suspenseTreeList.push(current);
57+
if (!uniqueSuspendersOnly || current.hasUniqueSuspenders) {
58+
list.push(current);
59+
}
5160
// Add children in reverse order to maintain document order
5261
for (let j = current.children.length - 1; j >= 0; j--) {
5362
const childSuspense = store.getSuspenseByID(current.children[j]);
@@ -58,7 +67,7 @@ function getSuspendableDocumentOrderSuspense(
5867
}
5968
}
6069

61-
return suspenseTreeList;
70+
return list;
6271
}
6372

6473
function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
@@ -67,10 +76,19 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
6776
const dispatch = useContext(TreeDispatcherContext);
6877
const {highlightHostInstance, clearHighlightHostInstance} =
6978
useHighlightHostInstance();
79+
const [uniqueSuspendersOnly, setUniqueSuspendersOnly] = useState(true);
80+
function handleToggleUniqueSuspenders(event: SyntheticEvent) {
81+
// TODO: Currently loses timeline position because position is timeline-index-based not suspense-id-based.
82+
setUniqueSuspendersOnly((event.currentTarget as HTMLInputElement).checked);
83+
}
7084

7185
const timeline = useMemo(() => {
72-
return getSuspendableDocumentOrderSuspense(store, rootID);
73-
}, [store, rootID]);
86+
return getSuspendableDocumentOrderSuspense(
87+
store,
88+
rootID,
89+
uniqueSuspendersOnly,
90+
);
91+
}, [store, rootID, uniqueSuspendersOnly]);
7492

7593
const inputRef = useRef<HTMLElement | null>(null);
7694
const inputBBox = useRef<ClientRect | null>(null);
@@ -191,9 +209,7 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
191209

192210
return (
193211
<>
194-
<div>
195-
{value}/{max}
196-
</div>
212+
{value}/{max}
197213
<div className={styles.SuspenseTimelineInput}>
198214
<input
199215
className={styles.SuspenseTimelineSlider}
@@ -209,6 +225,13 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
209225
ref={inputRef}
210226
/>
211227
</div>
228+
<Tooltip label="Only include boundaries with unique suspenders">
229+
<input
230+
checked={uniqueSuspendersOnly}
231+
type="checkbox"
232+
onChange={handleToggleUniqueSuspenders}
233+
/>
234+
</Tooltip>
212235
</>
213236
);
214237
}

0 commit comments

Comments
 (0)