[DevTools] Track whether a boundary is currently suspended and make transparent (#34853)
This makes the rects that are currently in a suspended state appear ghostly so that you can see where along the timeline you are in the rects screen. <img width="451" height="407" alt="Screenshot 2025-10-14 at 11 43 20 PM" src="https://github.com/user-attachments/assets/f89e362b-a0d5-46e3-8171-564909715cd1" />
Sebastian Markbåge committed
Oct 15, 2025 at 10:26 UTC
6773248311fca29669283e1059f11c9009f8f51b
8 files changed
+76
-24
packages/react-devtools-shared/src/backend/fiber/renderer.js
+46
-12
@@ -2139,8 +2139,8 @@ export function attach(
2139
// Regular operations
2140
pendingOperations.length +
2141
// All suspender changes are batched in a single message.
2142
- // [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders]]
2143
- (numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 2 : 0),
2142
+ // [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders, isSuspended]]
2143
+ (numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 3 : 0),
2144
);
2145
2146
// Identify which renderer this update is coming from.
@@ -2225,6 +2225,14 @@ export function attach(
2225
}
2226
operations[i++] = fiberIdWithChanges;
2227
operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
2228
+ const instance = suspense.instance;
2229
+ const isSuspended =
2230
+ // TODO: Track if other SuspenseNode like SuspenseList rows are suspended.
2231
+ (instance.kind === FIBER_INSTANCE ||
2232
+ instance.kind === FILTERED_FIBER_INSTANCE) &&
2233
+ instance.data.tag === SuspenseComponent &&
2234
+ instance.data.memoizedState !== null;
2235
+ operations[i++] = isSuspended ? 1 : 0;
2236
operations[i++] = suspense.environments.size;
2237
suspense.environments.forEach((count, env) => {
2238
operations[i++] = getStringID(env);
@@ -2657,9 +2665,15 @@ export function attach(
2665
const fiber = fiberInstance.data;
2666
const props = fiber.memoizedProps;
2667
// TODO: Compute a fallback name based on Owner, key etc.
2660
- const name = props === null ? null : props.name || null;
2668
+ const name =
2669
+ fiber.tag !== SuspenseComponent || props === null
2670
+ ? null
2671
+ : props.name || null;
2672
const nameStringID = getStringID(name);
2673
2674
+ const isSuspended =
2675
+ fiber.tag === SuspenseComponent && fiber.memoizedState !== null;
2676
+
2677
if (__DEBUG__) {
2678
console.log('recordSuspenseMount()', suspenseInstance);
2679
}
@@ -2670,6 +2684,7 @@ export function attach(
2684
pushOperation(fiberID);
2685
pushOperation(parentID);
2686
pushOperation(nameStringID);
2687
+ pushOperation(isSuspended ? 1 : 0);
2688
2689
const rects = suspenseInstance.rects;
2690
if (rects === null) {
@@ -5038,15 +5053,24 @@ export function attach(
5053
const nextIsSuspended = isSuspendedOffscreen(nextFiber);
5054
5055
if (isLegacySuspense) {
5041
- if (
5042
- fiberInstance !== null &&
5043
- fiberInstance.suspenseNode !== null &&
5044
- (prevFiber.stateNode === null) !== (nextFiber.stateNode === null)
5045
- ) {
5046
- trackThrownPromisesFromRetryCache(
5047
- fiberInstance.suspenseNode,
5048
- nextFiber.stateNode,
5049
- );
5056
+ if (fiberInstance !== null && fiberInstance.suspenseNode !== null) {
5057
+ const suspenseNode = fiberInstance.suspenseNode;
5058
+ if (
5059
+ (prevFiber.stateNode === null) !==
5060
+ (nextFiber.stateNode === null)
5061
+ ) {
5062
+ trackThrownPromisesFromRetryCache(
5063
+ suspenseNode,
5064
+ nextFiber.stateNode,
5065
+ );
5066
+ }
5067
+ if (
5068
+ (prevFiber.memoizedState === null) !==
5069
+ (nextFiber.memoizedState === null)
5070
+ ) {
5071
+ // Toggle suspended state.
5072
+ recordSuspenseSuspenders(suspenseNode);
5073
+ }
5074
}
5075
}
5076
// The logic below is inspired by the code paths in updateSuspenseComponent()
@@ -5194,6 +5218,14 @@ export function attach(
5218
);
5219
}
5220
5221
+ if (
5222
+ (prevFiber.memoizedState === null) !==
5223
+ (nextFiber.memoizedState === null)
5224
+ ) {
5225
+ // Toggle suspended state.
5226
+ recordSuspenseSuspenders(suspenseNode);
5227
+ }
5228
+
5229
shouldMeasureSuspenseNode = false;
5230
updateFlags |= updateSuspenseChildrenRecursively(
5231
nextContentFiber,
@@ -5220,6 +5252,8 @@ export function attach(
5252
}
5253
5254
trackThrownPromisesFromRetryCache(suspenseNode, nextFiber.stateNode);
5255
+ // Toggle suspended state.
5256
+ recordSuspenseSuspenders(suspenseNode);
5257
5258
mountSuspenseChildrenRecursively(
5259
nextContentFiber,
packages/react-devtools-shared/src/backend/legacy/renderer.js
+1
@@ -417,6 +417,7 @@ export function attach(
417
pushOperation(id);
418
pushOperation(parentID);
419
pushOperation(getStringID(null)); // name
420
+ pushOperation(0); // isSuspended
421
// TODO: Measure rect of root
422
pushOperation(-1);
423
} else {
packages/react-devtools-shared/src/devtools/store.js
+6
-2
@@ -1552,7 +1552,8 @@ export default class Store extends EventEmitter<{
1552
const id = operations[i + 1];
1553
const parentID = operations[i + 2];
1554
const nameStringID = operations[i + 3];
1555
- const numRects = ((operations[i + 4]: any): number);
1555
+ const isSuspended = operations[i + 4] === 1;
1556
+ const numRects = ((operations[i + 5]: any): number);
1557
let name = stringTable[nameStringID];
1558
1559
if (this._idToSuspense.has(id)) {
@@ -1579,7 +1580,7 @@ export default class Store extends EventEmitter<{
1580
}
1581
}
1582
1582
- i += 5;
1583
+ i += 6;
1584
let rects: SuspenseNode['rects'];
1585
if (numRects === -1) {
1586
rects = null;
@@ -1625,6 +1626,7 @@ export default class Store extends EventEmitter<{
1626
name,
1627
rects,
1628
hasUniqueSuspenders: false,
1629
+ isSuspended: isSuspended,
1630
});
1631
1632
hasSuspenseTreeChanged = true;
@@ -1801,6 +1803,7 @@ export default class Store extends EventEmitter<{
1803
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
1804
const id = operations[i++];
1805
const hasUniqueSuspenders = operations[i++] === 1;
1806
+ const isSuspended = operations[i++] === 1;
1807
const environmentNamesLength = operations[i++];
1808
const environmentNames = [];
1809
for (
@@ -1832,6 +1835,7 @@ export default class Store extends EventEmitter<{
1835
}
1836
1837
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
1838
+ suspense.isSuspended = isSuspended;
1839
// TODO: Recompute the environment names.
1840
}
1841
packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js
+7
-5
@@ -378,7 +378,8 @@ function updateTree(
378
const fiberID = operations[i + 1];
379
const parentID = operations[i + 2];
380
const nameStringID = operations[i + 3];
381
- const numRects = operations[i + 4];
381
+ const isSuspended = operations[i + 4];
382
+ const numRects = operations[i + 5];
383
const name = stringTable[nameStringID];
384
385
if (__DEBUG__) {
@@ -388,16 +389,16 @@ function updateTree(
389
} else {
390
rects =
391
'[' +
391
- operations.slice(i + 5, i + 5 + numRects * 4).join(',') +
392
+ operations.slice(i + 6, i + 6 + numRects * 4).join(',') +
393
']';
394
}
395
debug(
396
'Add suspense',
396
- `node ${fiberID} (name=${JSON.stringify(name)}, rects={${rects}}) under ${parentID}`,
397
+ `node ${fiberID} (name=${JSON.stringify(name)}, rects={${rects}}) under ${parentID} suspended ${isSuspended}`,
398
);
399
}
400
400
- i += 5 + (numRects === -1 ? 0 : numRects * 4);
401
+ i += 6 + (numRects === -1 ? 0 : numRects * 4);
402
break;
403
}
404
@@ -459,12 +460,13 @@ function updateTree(
460
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
461
const suspenseNodeId = operations[i++];
462
const hasUniqueSuspenders = operations[i++] === 1;
463
+ const isSuspended = operations[i++] === 1;
464
const environmentNamesLength = operations[i++];
465
i += environmentNamesLength;
466
if (__DEBUG__) {
467
debug(
468
'Suspender changes',
467
- `Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`,
469
+ `Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
470
);
471
}
472
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css
+4
@@ -44,6 +44,10 @@
44
outline-width: 0;
45
}
46
47
+.SuspenseRectsScaledRect[data-suspended='true'] {
48
+ opacity: 0.3;
49
+}
50
+
51
/* highlight this boundary */
52
.SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect, .SuspenseRectsBoundary[data-highlighted='true'] > .SuspenseRectsRect {
53
background-color: var(--color-background-hover);
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+5
-1
@@ -35,11 +35,13 @@ function ScaledRect({
35
className,
36
rect,
37
visible,
38
+ suspended,
39
...props
40
}: {
41
className: string,
42
rect: Rect,
43
visible: boolean,
44
+ suspended: boolean,
45
...
46
}): React$Node {
47
const viewBox = useContext(ViewBox);
@@ -53,6 +55,7 @@ function ScaledRect({
55
{...props}
56
className={styles.SuspenseRectsScaledRect + ' ' + className}
57
data-visible={visible}
58
+ data-suspended={suspended}
59
style={{
60
width,
61
height,
@@ -145,7 +148,8 @@ function SuspenseRects({
148
<ScaledRect
149
rect={boundingBox}
150
className={styles.SuspenseRectsBoundary}
148
- visible={visible}>
151
+ visible={visible}
152
+ suspended={suspense.isSuspended}>
153
<ViewBox.Provider value={boundingBox}>
154
{visible &&
155
suspense.rects !== null &&
packages/react-devtools-shared/src/frontend/types.js
+1
@@ -200,6 +200,7 @@ export type SuspenseNode = {
200
name: string | null,
201
rects: null | Array<Rect>,
202
hasUniqueSuspenders: boolean,
203
+ isSuspended: boolean,
204
};
205
206
// Serialized version of ReactIOInfo
packages/react-devtools-shared/src/utils.js
+6
-4
@@ -340,9 +340,10 @@ export function printOperationsArray(operations: Array<number>) {
340
const fiberID = operations[i + 1];
341
const parentID = operations[i + 2];
342
const nameStringID = operations[i + 3];
343
- const numRects = operations[i + 4];
343
+ const isSuspended = operations[i + 4];
344
+ const numRects = operations[i + 5];
345
345
- i += 5;
346
+ i += 6;
347
348
const name = stringTable[nameStringID];
349
let rects: string;
@@ -368,7 +369,7 @@ export function printOperationsArray(operations: Array<number>) {
369
}
370
371
logs.push(
371
- `Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID}`,
372
+ `Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID} suspended ${isSuspended}`,
373
);
374
break;
375
}
@@ -431,10 +432,11 @@ export function printOperationsArray(operations: Array<number>) {
432
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
433
const id = operations[i++];
434
const hasUniqueSuspenders = operations[i++] === 1;
435
+ const isSuspended = operations[i++] === 1;
436
const environmentNamesLength = operations[i++];
437
i += environmentNamesLength;
438
logs.push(
437
- `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`,
439
+ `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
440
);
441
}
442