@samitouri / QOS-React-1 / commits / 6773248311

[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