[DevTools] Send suspense nodes to frontend store (#34070)
Sebastian "Sebbie" Silbermann committed
Aug 10, 2025 at 10:12 UTC
98286cf8e36d67fdef2a225c212cc0f6e62b920e
11 files changed
+1001
-171
packages/react-devtools-shared/src/backend/fiber/renderer.js
+276
-108
@@ -78,6 +78,9 @@ import {
78
TREE_OPERATION_SET_SUBTREE_MODE,
79
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
80
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
81
+ SUSPENSE_TREE_OPERATION_ADD,
82
+ SUSPENSE_TREE_OPERATION_REMOVE,
83
+ SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
84
} from '../../constants';
85
import {inspectHooksOfFiber} from 'react-debug-tools';
86
import {
@@ -824,8 +827,12 @@ const rootToFiberInstanceMap: Map<FiberRoot, FiberInstance> = new Map();
827
// Map of id to FiberInstance or VirtualInstance.
828
// This Map is used to e.g. get the display name for a Fiber or schedule an update,
829
// operations that should be the same whether the current and work-in-progress Fiber is used.
827
-const idToDevToolsInstanceMap: Map<number, FiberInstance | VirtualInstance> =
828
- new Map();
830
+const idToDevToolsInstanceMap: Map<
831
+ FiberInstance['id'] | VirtualInstance['id'],
832
+ FiberInstance | VirtualInstance,
833
+> = new Map();
834
+
835
+const idToSuspenseNodeMap: Map<FiberInstance['id'], SuspenseNode> = new Map();
836
837
// Map of canonical HostInstances to the nearest parent DevToolsInstance.
838
const publicInstanceToDevToolsInstanceMap: Map<HostInstance, DevToolsInstance> =
@@ -1960,11 +1967,12 @@ export function attach(
1967
};
1968
1969
const pendingOperations: OperationsArray = [];
1963
- const pendingRealUnmountedIDs: Array<number> = [];
1970
+ const pendingRealUnmountedIDs: Array<FiberInstance['id']> = [];
1971
+ const pendingRealUnmountedSuspenseIDs: Array<FiberInstance['id']> = [];
1972
let pendingOperationsQueue: Array<OperationsArray> | null = [];
1973
const pendingStringTable: Map<string, StringTableEntry> = new Map();
1974
let pendingStringTableLength: number = 0;
1967
- let pendingUnmountedRootID: number | null = null;
1975
+ let pendingUnmountedRootID: FiberInstance['id'] | null = null;
1976
1977
function pushOperation(op: number): void {
1978
if (__DEV__) {
@@ -1991,6 +1999,7 @@ export function attach(
1999
return (
2000
pendingOperations.length === 0 &&
2001
pendingRealUnmountedIDs.length === 0 &&
2002
+ pendingRealUnmountedSuspenseIDs.length === 0 &&
2003
pendingUnmountedRootID === null
2004
);
2005
}
@@ -2056,6 +2065,7 @@ export function attach(
2065
const numUnmountIDs =
2066
pendingRealUnmountedIDs.length +
2067
(pendingUnmountedRootID === null ? 0 : 1);
2068
+ const numUnmountSuspenseIDs = pendingRealUnmountedSuspenseIDs.length;
2069
2070
const operations = new Array<number>(
2071
// Identify which renderer this update is coming from.
@@ -2064,6 +2074,9 @@ export function attach(
2074
1 + // [stringTableLength]
2075
// Then goes the actual string table.
2076
pendingStringTableLength +
2077
+ // All unmounts of Suspense boundaries are batched in a single message.
2078
+ // [TREE_OPERATION_REMOVE_SUSPENSE, removedSuspenseIDLength, ...ids]
2079
+ (numUnmountSuspenseIDs > 0 ? 2 + numUnmountSuspenseIDs : 0) +
2080
// All unmounts are batched in a single message.
2081
// [TREE_OPERATION_REMOVE, removedIDLength, ...ids]
2082
(numUnmountIDs > 0 ? 2 + numUnmountIDs : 0) +
@@ -2101,6 +2114,19 @@ export function attach(
2114
i += length;
2115
});
2116
2117
+ if (numUnmountSuspenseIDs > 0) {
2118
+ // All unmounts of Suspense boundaries are batched in a single message.
2119
+ operations[i++] = SUSPENSE_TREE_OPERATION_REMOVE;
2120
+ // The first number is how many unmounted IDs we're gonna send.
2121
+ operations[i++] = numUnmountSuspenseIDs;
2122
+ // Fill in the real unmounts in the reverse order.
2123
+ // They were inserted parents-first by React, but we want children-first.
2124
+ // So we traverse our array backwards.
2125
+ for (let j = 0; j < pendingRealUnmountedSuspenseIDs.length; j++) {
2126
+ operations[i++] = pendingRealUnmountedSuspenseIDs[j];
2127
+ }
2128
+ }
2129
+
2130
if (numUnmountIDs > 0) {
2131
// All unmounts except roots are batched in a single message.
2132
operations[i++] = TREE_OPERATION_REMOVE;
@@ -2130,6 +2156,7 @@ export function attach(
2156
// Reset all of the pending state now that we've told the frontend about it.
2157
pendingOperations.length = 0;
2158
pendingRealUnmountedIDs.length = 0;
2159
+ pendingRealUnmountedSuspenseIDs.length = 0;
2160
pendingUnmountedRootID = null;
2161
pendingStringTable.clear();
2162
pendingStringTableLength = 0;
@@ -2467,6 +2494,54 @@ export function attach(
2494
recordConsoleLogs(instance, componentLogsEntry);
2495
}
2496
2497
+ function recordSuspenseMount(
2498
+ suspenseInstance: SuspenseNode,
2499
+ parentSuspenseInstance: SuspenseNode | null,
2500
+ ): void {
2501
+ const fiberInstance = suspenseInstance.instance;
2502
+ if (fiberInstance.kind === FILTERED_FIBER_INSTANCE) {
2503
+ throw new Error('Cannot record a mount for a filtered Fiber instance.');
2504
+ }
2505
+ const fiberID = fiberInstance.id;
2506
+
2507
+ let unfilteredParent = parentSuspenseInstance;
2508
+ while (
2509
+ unfilteredParent !== null &&
2510
+ unfilteredParent.instance.kind === FILTERED_FIBER_INSTANCE
2511
+ ) {
2512
+ unfilteredParent = unfilteredParent.parent;
2513
+ }
2514
+ const unfilteredParentInstance =
2515
+ unfilteredParent !== null ? unfilteredParent.instance : null;
2516
+ if (
2517
+ unfilteredParentInstance !== null &&
2518
+ unfilteredParentInstance.kind === FILTERED_FIBER_INSTANCE
2519
+ ) {
2520
+ throw new Error(
2521
+ 'Should not have a filtered instance at this point. This is a bug.',
2522
+ );
2523
+ }
2524
+ const parentID =
2525
+ unfilteredParentInstance === null ? 0 : unfilteredParentInstance.id;
2526
+
2527
+ const fiber = fiberInstance.data;
2528
+ const props = fiber.memoizedProps;
2529
+ // TODO: Compute a fallback name based on Owner, key etc.
2530
+ const name = props === null ? null : props.name || null;
2531
+ const nameStringID = getStringID(name);
2532
+
2533
+ if (__DEBUG__) {
2534
+ console.log('recordSuspenseMount()', suspenseInstance);
2535
+ }
2536
+
2537
+ idToSuspenseNodeMap.set(fiberID, suspenseInstance);
2538
+
2539
+ pushOperation(SUSPENSE_TREE_OPERATION_ADD);
2540
+ pushOperation(fiberID);
2541
+ pushOperation(parentID);
2542
+ pushOperation(nameStringID);
2543
+ }
2544
+
2545
function recordUnmount(fiberInstance: FiberInstance): void {
2546
if (__DEBUG__) {
2547
debug('recordUnmount()', fiberInstance, reconcilingParent);
@@ -2474,6 +2549,11 @@ export function attach(
2549
2550
recordDisconnect(fiberInstance);
2551
2552
+ const suspenseNode = fiberInstance.suspenseNode;
2553
+ if (suspenseNode !== null) {
2554
+ recordSuspenseUnmount(suspenseNode);
2555
+ }
2556
+
2557
idToDevToolsInstanceMap.delete(fiberInstance.id);
2558
2559
untrackFiber(fiberInstance, fiberInstance.data);
@@ -2511,6 +2591,30 @@ export function attach(
2591
// TODO: Notify the front end of the change.
2592
}
2593
2594
+ function recordSuspenseUnmount(suspenseInstance: SuspenseNode): void {
2595
+ if (__DEBUG__) {
2596
+ console.log(
2597
+ 'recordSuspenseUnmount()',
2598
+ suspenseInstance,
2599
+ reconcilingParentSuspenseNode,
2600
+ );
2601
+ }
2602
+
2603
+ const devtoolsInstance = suspenseInstance.instance;
2604
+ if (devtoolsInstance.kind !== FIBER_INSTANCE) {
2605
+ throw new Error("Can't unmount a filtered SuspenseNode. This is a bug.");
2606
+ }
2607
+ const fiberInstance = devtoolsInstance;
2608
+ const id = fiberInstance.id;
2609
+
2610
+ // To maintain child-first ordering,
2611
+ // we'll push it into one of these queues,
2612
+ // and later arrange them in the correct order.
2613
+ pendingRealUnmountedSuspenseIDs.push(id);
2614
+
2615
+ idToSuspenseNodeMap.delete(id);
2616
+ }
2617
+
2618
// Running state of the remaining children from the previous version of this parent that
2619
// we haven't yet added back. This should be reset anytime we change parent.
2620
// Any remaining ones at the end will be deleted.
@@ -3181,6 +3285,7 @@ export function attach(
3285
// inserted the new children but since we know this is a FiberInstance we'll
3286
// just use the Fiber anyway.
3287
newSuspenseNode.rects = measureInstance(newInstance);
3288
+ recordSuspenseMount(newSuspenseNode, reconcilingParentSuspenseNode);
3289
}
3290
insertChild(newInstance);
3291
if (__DEBUG__) {
@@ -3609,6 +3714,56 @@ export function attach(
3714
}
3715
}
3716
3717
+ function addUnfilteredSuspenseChildrenIDs(
3718
+ parentInstance: SuspenseNode,
3719
+ nextChildren: Array<number>,
3720
+ ): void {
3721
+ let child: null | SuspenseNode = parentInstance.firstChild;
3722
+ while (child !== null) {
3723
+ if (child.instance.kind === FILTERED_FIBER_INSTANCE) {
3724
+ addUnfilteredSuspenseChildrenIDs(child, nextChildren);
3725
+ } else {
3726
+ nextChildren.push(child.instance.id);
3727
+ }
3728
+ child = child.nextSibling;
3729
+ }
3730
+ }
3731
+
3732
+ function recordResetSuspenseChildren(parentInstance: SuspenseNode) {
3733
+ if (__DEBUG__) {
3734
+ if (parentInstance.firstChild !== null) {
3735
+ console.log(
3736
+ 'recordResetSuspenseChildren()',
3737
+ parentInstance.firstChild,
3738
+ parentInstance,
3739
+ );
3740
+ }
3741
+ }
3742
+ // The frontend only really cares about the name, and children.
3743
+ // The first two don't really change, so we are only concerned with the order of children here.
3744
+ // This is trickier than a simple comparison though, since certain types of fibers are filtered.
3745
+ const nextChildren: Array<number> = [];
3746
+
3747
+ addUnfilteredSuspenseChildrenIDs(parentInstance, nextChildren);
3748
+
3749
+ const numChildren = nextChildren.length;
3750
+ if (numChildren < 2) {
3751
+ // No need to reorder.
3752
+ return;
3753
+ }
3754
+ pushOperation(SUSPENSE_TREE_OPERATION_REORDER_CHILDREN);
3755
+ // $FlowFixMe[incompatible-call] TODO: Allow filtering SuspenseNode
3756
+ pushOperation(parentInstance.instance.id);
3757
+ pushOperation(numChildren);
3758
+ for (let i = 0; i < nextChildren.length; i++) {
3759
+ pushOperation(nextChildren[i]);
3760
+ }
3761
+ }
3762
+
3763
+ const NoUpdate = /* */ 0b00;
3764
+ const ShouldResetChildren = /* */ 0b01;
3765
+ const ShouldResetSuspenseChildren = /* */ 0b10;
3766
+
3767
function updateVirtualInstanceRecursively(
3768
virtualInstance: VirtualInstance,
3769
nextFirstChild: Fiber,
@@ -3616,7 +3771,7 @@ export function attach(
3771
prevFirstChild: null | Fiber,
3772
traceNearestHostComponentUpdate: boolean,
3773
virtualLevel: number, // the nth level of virtual instances
3619
- ): void {
3774
+ ): number {
3775
const stashedParent = reconcilingParent;
3776
const stashedPrevious = previouslyReconciledSibling;
3777
const stashedRemaining = remainingReconcilingChildren;
@@ -3630,16 +3785,16 @@ export function attach(
3785
virtualInstance.firstChild = null;
3786
virtualInstance.suspendedBy = null;
3787
try {
3633
- if (
3634
- updateVirtualChildrenRecursively(
3635
- nextFirstChild,
3636
- nextLastChild,
3637
- prevFirstChild,
3638
- traceNearestHostComponentUpdate,
3639
- virtualLevel + 1,
3640
- )
3641
- ) {
3788
+ let updateFlags = updateVirtualChildrenRecursively(
3789
+ nextFirstChild,
3790
+ nextLastChild,
3791
+ prevFirstChild,
3792
+ traceNearestHostComponentUpdate,
3793
+ virtualLevel + 1,
3794
+ );
3795
+ if ((updateFlags & ShouldResetChildren) !== NoUpdate) {
3796
recordResetChildren(virtualInstance);
3797
+ updateFlags &= ~ShouldResetChildren;
3798
}
3799
removePreviousSuspendedBy(virtualInstance, previousSuspendedBy);
3800
// Update the errors/warnings count. If this Instance has switched to a different
@@ -3652,6 +3807,8 @@ export function attach(
3807
recordConsoleLogs(virtualInstance, componentLogsEntry);
3808
// Must be called after all children have been appended.
3809
recordVirtualProfilingDurations(virtualInstance);
3810
+
3811
+ return updateFlags;
3812
} finally {
3813
unmountRemainingChildren();
3814
reconcilingParent = stashedParent;
@@ -3666,8 +3823,8 @@ export function attach(
3823
prevFirstChild: null | Fiber,
3824
traceNearestHostComponentUpdate: boolean,
3825
virtualLevel: number, // the nth level of virtual instances
3669
- ): boolean {
3670
- let shouldResetChildren = false;
3826
+ ): number {
3827
+ let updateFlags = NoUpdate;
3828
// If the first child is different, we need to traverse them.
3829
// Each next child will be either a new child (mount) or an alternate (update).
3830
let nextChild: null | Fiber = nextFirstChild;
@@ -3727,8 +3884,10 @@ export function attach(
3884
traceNearestHostComponentUpdate,
3885
virtualLevel,
3886
);
3887
+ updateFlags |=
3888
+ ShouldResetChildren | ShouldResetSuspenseChildren;
3889
} else {
3731
- updateVirtualInstanceRecursively(
3890
+ updateFlags |= updateVirtualInstanceRecursively(
3891
previousVirtualInstance,
3892
previousVirtualInstanceNextFirstFiber,
3893
nextChild,
@@ -3779,7 +3938,7 @@ export function attach(
3938
insertChild(newVirtualInstance);
3939
previousVirtualInstance = newVirtualInstance;
3940
previousVirtualInstanceWasMount = true;
3782
- shouldResetChildren = true;
3941
+ updateFlags |= ShouldResetChildren;
3942
}
3943
// Existing children might be reparented into this new virtual instance.
3944
// TODO: This will cause the front end to error which needs to be fixed.
@@ -3806,8 +3965,9 @@ export function attach(
3965
traceNearestHostComponentUpdate,
3966
virtualLevel,
3967
);
3968
+ updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
3969
} else {
3810
- updateVirtualInstanceRecursively(
3970
+ updateFlags |= updateVirtualInstanceRecursively(
3971
previousVirtualInstance,
3972
previousVirtualInstanceNextFirstFiber,
3973
nextChild,
@@ -3857,44 +4017,36 @@ export function attach(
4017
// They are always different referentially, but if the instances line up
4018
// conceptually we'll want to know that.
4019
if (prevChild !== prevChildAtSameIndex) {
3860
- shouldResetChildren = true;
4020
+ updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4021
}
4022
4023
moveChild(fiberInstance, previousSiblingOfExistingInstance);
4024
3865
- if (
3866
- updateFiberRecursively(
3867
- fiberInstance,
3868
- nextChild,
3869
- (prevChild: any),
3870
- traceNearestHostComponentUpdate,
3871
- )
3872
- ) {
3873
- // If a nested tree child order changed but it can't handle its own
3874
- // child order invalidation (e.g. because it's filtered out like host nodes),
3875
- // propagate the need to reset child order upwards to this Fiber.
3876
- shouldResetChildren = true;
3877
- }
4025
+ // If a nested tree child order changed but it can't handle its own
4026
+ // child order invalidation (e.g. because it's filtered out like host nodes),
4027
+ // propagate the need to reset child order upwards to this Fiber.
4028
+ updateFlags |= updateFiberRecursively(
4029
+ fiberInstance,
4030
+ nextChild,
4031
+ (prevChild: any),
4032
+ traceNearestHostComponentUpdate,
4033
+ );
4034
} else if (prevChild !== null && shouldFilterFiber(nextChild)) {
4035
// The filtered instance could've reordered.
4036
if (prevChild !== prevChildAtSameIndex) {
3881
- shouldResetChildren = true;
4037
+ updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4038
}
4039
4040
// If this Fiber should be filtered, we need to still update its children.
4041
// This relies on an alternate since we don't have an Instance with the previous
4042
// child on it. Ideally, the reconciliation wouldn't need previous Fibers that
4043
// are filtered from the tree.
3888
- if (
3889
- updateFiberRecursively(
3890
- null,
3891
- nextChild,
3892
- prevChild,
3893
- traceNearestHostComponentUpdate,
3894
- )
3895
- ) {
3896
- shouldResetChildren = true;
3897
- }
4044
+ updateFlags |= updateFiberRecursively(
4045
+ null,
4046
+ nextChild,
4047
+ prevChild,
4048
+ traceNearestHostComponentUpdate,
4049
+ );
4050
} else {
4051
// It's possible for a FiberInstance to be reparented when virtual parents
4052
// get their sequence split or change structure with the same render result.
@@ -3906,14 +4058,17 @@ export function attach(
4058
4059
mountFiberRecursively(nextChild, traceNearestHostComponentUpdate);
4060
// Need to mark the parent set to remount the new instance.
3909
- shouldResetChildren = true;
4061
+ updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4062
}
4063
}
4064
// Try the next child.
4065
nextChild = nextChild.sibling;
4066
// Advance the pointer in the previous list so that we can
4067
// keep comparing if they line up.
3916
- if (!shouldResetChildren && prevChildAtSameIndex !== null) {
4068
+ if (
4069
+ (updateFlags & ShouldResetChildren) === NoUpdate &&
4070
+ prevChildAtSameIndex !== null
4071
+ ) {
4072
prevChildAtSameIndex = prevChildAtSameIndex.sibling;
4073
}
4074
}
@@ -3926,8 +4081,9 @@ export function attach(
4081
traceNearestHostComponentUpdate,
4082
virtualLevel,
4083
);
4084
+ updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4085
} else {
3930
- updateVirtualInstanceRecursively(
4086
+ updateFlags |= updateVirtualInstanceRecursively(
4087
previousVirtualInstance,
4088
previousVirtualInstanceNextFirstFiber,
4089
null,
@@ -3939,9 +4095,9 @@ export function attach(
4095
}
4096
// If we have no more children, but used to, they don't line up.
4097
if (prevChildAtSameIndex !== null) {
3942
- shouldResetChildren = true;
4098
+ updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4099
}
3944
- return shouldResetChildren;
4100
+ return updateFlags;
4101
}
4102
4103
// Returns whether closest unfiltered fiber parent needs to reset its child list.
@@ -3949,9 +4105,9 @@ export function attach(
4105
nextFirstChild: null | Fiber,
4106
prevFirstChild: null | Fiber,
4107
traceNearestHostComponentUpdate: boolean,
3952
- ): boolean {
4108
+ ): number {
4109
if (nextFirstChild === null) {
3954
- return prevFirstChild !== null;
4110
+ return prevFirstChild !== null ? ShouldResetChildren : NoUpdate;
4111
}
4112
return updateVirtualChildrenRecursively(
4113
nextFirstChild,
@@ -3968,7 +4124,7 @@ export function attach(
4124
nextFiber: Fiber,
4125
prevFiber: Fiber,
4126
traceNearestHostComponentUpdate: boolean,
3971
- ): boolean {
4127
+ ): number {
4128
if (__DEBUG__) {
4129
if (fiberInstance !== null) {
4130
debug('updateFiberRecursively()', fiberInstance, reconcilingParent);
@@ -4067,7 +4223,7 @@ export function attach(
4223
aquireHostInstance(nearestInstance, nextFiber.stateNode);
4224
}
4225
4070
- let shouldResetChildren = false;
4226
+ let updateFlags = NoUpdate;
4227
4228
// The behavior of timed-out legacy Suspense trees is unique. Without the Offscreen wrapper.
4229
// Rather than unmount the timed out content (and possibly lose important state),
@@ -4110,20 +4266,18 @@ export function attach(
4266
traceNearestHostComponentUpdate,
4267
);
4268
4113
- shouldResetChildren = true;
4269
+ updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4270
}
4271
4116
- if (
4117
- nextFallbackChildSet != null &&
4118
- prevFallbackChildSet != null &&
4119
- updateChildrenRecursively(
4120
- nextFallbackChildSet,
4121
- prevFallbackChildSet,
4122
- traceNearestHostComponentUpdate,
4123
- )
4124
- ) {
4125
- shouldResetChildren = true;
4126
- }
4272
+ const childrenUpdateFlags =
4273
+ nextFallbackChildSet != null && prevFallbackChildSet != null
4274
+ ? updateChildrenRecursively(
4275
+ nextFallbackChildSet,
4276
+ prevFallbackChildSet,
4277
+ traceNearestHostComponentUpdate,
4278
+ )
4279
+ : NoUpdate;
4280
+ updateFlags |= childrenUpdateFlags;
4281
} else if (prevDidTimeout && !nextDidTimeOut) {
4282
// Fallback -> Primary:
4283
// 1. Unmount fallback set
@@ -4135,8 +4289,8 @@ export function attach(
4289
nextPrimaryChildSet,
4290
traceNearestHostComponentUpdate,
4291
);
4292
+ updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4293
}
4139
- shouldResetChildren = true;
4294
} else if (!prevDidTimeout && nextDidTimeOut) {
4295
// Primary -> Fallback:
4296
// 1. Hide primary set
@@ -4152,7 +4306,7 @@ export function attach(
4306
nextFallbackChildSet,
4307
traceNearestHostComponentUpdate,
4308
);
4155
- shouldResetChildren = true;
4309
+ updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4310
}
4311
} else if (nextIsHidden) {
4312
if (!prevWasHidden) {
@@ -4165,7 +4319,11 @@ export function attach(
4319
const stashedDisconnected = isInDisconnectedSubtree;
4320
isInDisconnectedSubtree = true;
4321
try {
4168
- updateChildrenRecursively(nextFiber.child, prevFiber.child, false);
4322
+ updateFlags |= updateChildrenRecursively(
4323
+ nextFiber.child,
4324
+ prevFiber.child,
4325
+ false,
4326
+ );
4327
} finally {
4328
isInDisconnectedSubtree = stashedDisconnected;
4329
}
@@ -4177,7 +4335,11 @@ export function attach(
4335
isInDisconnectedSubtree = true;
4336
try {
4337
if (nextFiber.child !== null) {
4180
- updateChildrenRecursively(nextFiber.child, prevFiber.child, false);
4338
+ updateFlags |= updateChildrenRecursively(
4339
+ nextFiber.child,
4340
+ prevFiber.child,
4341
+ false,
4342
+ );
4343
}
4344
// Ensure we unmount any remaining children inside the isInDisconnectedSubtree flag
4345
// since they should not trigger real deletions.
@@ -4189,7 +4351,7 @@ export function attach(
4351
if (fiberInstance !== null && !isInDisconnectedSubtree) {
4352
reconnectChildrenRecursively(fiberInstance);
4353
// Children may have reordered while they were hidden.
4192
- shouldResetChildren = true;
4354
+ updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4355
}
4356
} else if (
4357
nextFiber.tag === SuspenseComponent &&
@@ -4209,17 +4371,13 @@ export function attach(
4371
const nextFallbackFiber = nextContentFiber.sibling;
4372
4373
// First update only the Offscreen boundary. I.e. the main content.
4212
- if (
4213
- updateVirtualChildrenRecursively(
4214
- nextContentFiber,
4215
- nextFallbackFiber,
4216
- prevContentFiber,
4217
- traceNearestHostComponentUpdate,
4218
- 0,
4219
- )
4220
- ) {
4221
- shouldResetChildren = true;
4222
- }
4374
+ updateFlags |= updateVirtualChildrenRecursively(
4375
+ nextContentFiber,
4376
+ nextFallbackFiber,
4377
+ prevContentFiber,
4378
+ traceNearestHostComponentUpdate,
4379
+ 0,
4380
+ );
4381
4382
// Next, we'll pop back out of the SuspenseNode that we added above and now we'll
4383
// reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode.
@@ -4229,17 +4387,13 @@ export function attach(
4387
remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
4388
shouldPopSuspenseNode = false;
4389
if (nextFallbackFiber !== null) {
4232
- if (
4233
- updateVirtualChildrenRecursively(
4234
- nextFallbackFiber,
4235
- null,
4236
- prevFallbackFiber,
4237
- traceNearestHostComponentUpdate,
4238
- 0,
4239
- )
4240
- ) {
4241
- shouldResetChildren = true;
4242
- }
4390
+ updateFlags |= updateVirtualChildrenRecursively(
4391
+ nextFallbackFiber,
4392
+ null,
4393
+ prevFallbackFiber,
4394
+ traceNearestHostComponentUpdate,
4395
+ 0,
4396
+ );
4397
} else if (
4398
nextFiber.memoizedState === null &&
4399
fiberInstance.suspenseNode !== null
@@ -4262,15 +4416,11 @@ export function attach(
4416
// Common case: Primary -> Primary.
4417
// This is the same code path as for non-Suspense fibers.
4418
if (nextFiber.child !== prevFiber.child) {
4265
- if (
4266
- updateChildrenRecursively(
4267
- nextFiber.child,
4268
- prevFiber.child,
4269
- traceNearestHostComponentUpdate,
4270
- )
4271
- ) {
4272
- shouldResetChildren = true;
4273
- }
4419
+ updateFlags |= updateChildrenRecursively(
4420
+ nextFiber.child,
4421
+ prevFiber.child,
4422
+ traceNearestHostComponentUpdate,
4423
+ );
4424
} else {
4425
// Children are unchanged.
4426
if (fiberInstance !== null) {
@@ -4293,15 +4443,19 @@ export function attach(
4443
}
4444
}
4445
} else {
4446
+ const childrenUpdateFlags = updateChildrenRecursively(
4447
+ nextFiber.child,
4448
+ prevFiber.child,
4449
+ false,
4450
+ );
4451
// If this fiber is filtered there might be changes to this set elsewhere so we have
4452
// to visit each child to place it back in the set. We let the child bail out instead.
4298
- if (
4299
- updateChildrenRecursively(nextFiber.child, prevFiber.child, false)
4300
- ) {
4453
+ if ((childrenUpdateFlags & ShouldResetChildren) !== NoUpdate) {
4454
throw new Error(
4455
'The children should not have changed if we pass in the same set.',
4456
);
4457
}
4458
+ updateFlags |= childrenUpdateFlags;
4459
}
4460
}
4461
}
@@ -4330,21 +4484,35 @@ export function attach(
4484
}
4485
}
4486
}
4333
- if (shouldResetChildren) {
4487
+
4488
+ if ((updateFlags & ShouldResetChildren) !== NoUpdate) {
4489
// We need to crawl the subtree for closest non-filtered Fibers
4490
// so that we can display them in a flat children set.
4491
if (fiberInstance !== null && fiberInstance.kind === FIBER_INSTANCE) {
4492
recordResetChildren(fiberInstance);
4493
+
4494
// We've handled the child order change for this Fiber.
4495
// Since it's included, there's no need to invalidate parent child order.
4340
- return false;
4496
+ updateFlags &= ~ShouldResetChildren;
4497
} else {
4498
// Let the closest unfiltered parent Fiber reset its child order instead.
4343
- return true;
4499
}
4500
} else {
4346
- return false;
4501
}
4502
+
4503
+ if ((updateFlags & ShouldResetSuspenseChildren) !== NoUpdate) {
4504
+ if (fiberInstance !== null && fiberInstance.kind === FIBER_INSTANCE) {
4505
+ const suspenseNode = fiberInstance.suspenseNode;
4506
+ if (suspenseNode !== null) {
4507
+ recordResetSuspenseChildren(suspenseNode);
4508
+ updateFlags &= ~ShouldResetSuspenseChildren;
4509
+ }
4510
+ } else {
4511
+ // Let the closest unfiltered parent Fiber reset its child order instead.
4512
+ }
4513
+ }
4514
+
4515
+ return updateFlags;
4516
} finally {
4517
if (fiberInstance !== null) {
4518
unmountRemainingChildren();
packages/react-devtools-shared/src/constants.js
+3
@@ -24,6 +24,9 @@ export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4;
24
export const TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS = 5;
25
export const TREE_OPERATION_REMOVE_ROOT = 6;
26
export const TREE_OPERATION_SET_SUBTREE_MODE = 7;
27
+export const SUSPENSE_TREE_OPERATION_ADD = 8;
28
+export const SUSPENSE_TREE_OPERATION_REMOVE = 9;
29
+export const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10;
30
31
export const PROFILING_FLAG_BASIC_SUPPORT = 0b01;
32
export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b10;
packages/react-devtools-shared/src/devtools/store.js
+218
-9
@@ -20,6 +20,9 @@ import {
20
TREE_OPERATION_SET_SUBTREE_MODE,
21
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
22
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
23
+ SUSPENSE_TREE_OPERATION_ADD,
24
+ SUSPENSE_TREE_OPERATION_REMOVE,
25
+ SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
26
} from '../constants';
27
import {ElementTypeRoot} from '../frontend/types';
28
import {
@@ -44,6 +47,7 @@ import type {
47
Element,
48
ComponentFilter,
49
ElementType,
50
+ SuspenseNode,
51
} from 'react-devtools-shared/src/frontend/types';
52
import type {
53
FrontendBridge,
@@ -100,11 +104,12 @@ export default class Store extends EventEmitter<{
104
hookSettings: [$ReadOnly<DevToolsHookSettings>],
105
hostInstanceSelected: [Element['id']],
106
settingsUpdated: [$ReadOnly<DevToolsHookSettings>],
103
- mutated: [[Array<number>, Map<number, number>]],
107
+ mutated: [[Array<Element['id']>, Map<Element['id'], Element['id']>]],
108
recordChangeDescriptions: [],
109
roots: [],
110
rootSupportsBasicProfiling: [],
111
rootSupportsTimelineProfiling: [],
112
+ suspenseTreeMutated: [],
113
supportsNativeStyleEditor: [],
114
supportsReloadAndProfile: [],
115
unsupportedBridgeProtocolDetected: [],
@@ -127,8 +132,10 @@ export default class Store extends EventEmitter<{
132
_componentFilters: Array<ComponentFilter>;
133
134
// Map of ID to number of recorded error and warning message IDs.
130
- _errorsAndWarnings: Map<number, {errorCount: number, warningCount: number}> =
131
- new Map();
135
+ _errorsAndWarnings: Map<
136
+ Element['id'],
137
+ {errorCount: number, warningCount: number},
138
+ > = new Map();
139
140
// At least one of the injected renderers contains (DEV only) owner metadata.
141
_hasOwnerMetadata: boolean = false;
@@ -136,7 +143,9 @@ export default class Store extends EventEmitter<{
143
// Map of ID to (mutable) Element.
144
// Elements are mutated to avoid excessive cloning during tree updates.
145
// The InspectedElement Suspense cache also relies on this mutability for its WeakMap usage.
139
- _idToElement: Map<number, Element> = new Map();
146
+ _idToElement: Map<Element['id'], Element> = new Map();
147
+
148
+ _idToSuspense: Map<SuspenseNode['id'], SuspenseNode> = new Map();
149
150
// Should the React Native style editor panel be shown?
151
_isNativeStyleEditorSupported: boolean = false;
@@ -149,7 +158,7 @@ export default class Store extends EventEmitter<{
158
159
// Map of element (id) to the set of elements (ids) it owns.
160
// This map enables getOwnersListForElement() to avoid traversing the entire tree.
152
- _ownersMap: Map<number, Set<number>> = new Map();
161
+ _ownersMap: Map<Element['id'], Set<Element['id']>> = new Map();
162
163
_profilerStore: ProfilerStore;
164
@@ -158,15 +167,16 @@ export default class Store extends EventEmitter<{
167
// Incremented each time the store is mutated.
168
// This enables a passive effect to detect a mutation between render and commit phase.
169
_revision: number = 0;
170
+ _revisionSuspense: number = 0;
171
172
// This Array must be treated as immutable!
173
// Passive effects will check it for changes between render and mount.
164
- _roots: $ReadOnlyArray<number> = [];
174
+ _roots: $ReadOnlyArray<Element['id']> = [];
175
166
- _rootIDToCapabilities: Map<number, Capabilities> = new Map();
176
+ _rootIDToCapabilities: Map<Element['id'], Capabilities> = new Map();
177
178
// Renderer ID is needed to support inspection fiber props, state, and hooks.
169
- _rootIDToRendererID: Map<number, number> = new Map();
179
+ _rootIDToRendererID: Map<Element['id'], number> = new Map();
180
181
// These options may be initially set by a configuration option when constructing the Store.
182
_supportsInspectMatchingDOMElement: boolean = false;
@@ -439,6 +449,9 @@ export default class Store extends EventEmitter<{
449
get revision(): number {
450
return this._revision;
451
}
452
+ get revisionSuspense(): number {
453
+ return this._revisionSuspense;
454
+ }
455
456
get rootIDToRendererID(): Map<number, number> {
457
return this._rootIDToRendererID;
@@ -595,6 +608,16 @@ export default class Store extends EventEmitter<{
608
return element;
609
}
610
611
+ getSuspenseByID(id: SuspenseNode['id']): SuspenseNode | null {
612
+ const suspense = this._idToSuspense.get(id);
613
+ if (suspense === undefined) {
614
+ console.warn(`No suspense found with id "${id}"`);
615
+ return null;
616
+ }
617
+
618
+ return suspense;
619
+ }
620
+
621
// Returns a tuple of [id, index]
622
getElementsWithErrorsAndWarnings(): ErrorAndWarningTuples {
623
if (!this._shouldShowWarningsAndErrors) {
@@ -989,6 +1012,7 @@ export default class Store extends EventEmitter<{
1012
1013
let haveRootsChanged = false;
1014
let haveErrorsOrWarningsChanged = false;
1015
+ let hasSuspenseTreeChanged = false;
1016
1017
// The first two values are always rendererID and rootID
1018
const rendererID = operations[0];
@@ -1369,7 +1393,7 @@ export default class Store extends EventEmitter<{
1393
// The profiler UI uses them lazily in order to generate the tree.
1394
i += 3;
1395
break;
1372
- case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS:
1396
+ case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS: {
1397
const id = operations[i + 1];
1398
const errorCount = operations[i + 2];
1399
const warningCount = operations[i + 3];
@@ -1383,6 +1407,184 @@ export default class Store extends EventEmitter<{
1407
}
1408
haveErrorsOrWarningsChanged = true;
1409
break;
1410
+ }
1411
+ case SUSPENSE_TREE_OPERATION_ADD: {
1412
+ const id = operations[i + 1];
1413
+ const parentID = operations[i + 2];
1414
+ const nameStringID = operations[i + 3];
1415
+ let name = stringTable[nameStringID];
1416
+
1417
+ if (this._idToSuspense.has(id)) {
1418
+ this._throwAndEmitError(
1419
+ Error(
1420
+ `Cannot add suspense node "${id}" because a suspense node with that id is already in the Store.`,
1421
+ ),
1422
+ );
1423
+ }
1424
+
1425
+ const element = this._idToElement.get(id);
1426
+ if (element === undefined) {
1427
+ this._throwAndEmitError(
1428
+ Error(
1429
+ `Cannot add suspense node "${id}" because no matching element was found in the Store.`,
1430
+ ),
1431
+ );
1432
+ } else {
1433
+ if (name === null) {
1434
+ // The boundary isn't explicitly named.
1435
+ // Pick a sensible default.
1436
+ // TODO: Use key
1437
+ const owner = this._idToElement.get(element.ownerID);
1438
+ if (owner !== undefined) {
1439
+ // TODO: This is clowny
1440
+ name = `${owner.displayName || 'Unknown'}>?`;
1441
+ }
1442
+ }
1443
+ }
1444
+
1445
+ if (__DEBUG__) {
1446
+ debug('Suspense Add', `node ${id} as child of ${parentID}`);
1447
+ }
1448
+
1449
+ if (parentID !== 0) {
1450
+ const parentSuspense = this._idToSuspense.get(parentID);
1451
+ if (parentSuspense === undefined) {
1452
+ this._throwAndEmitError(
1453
+ Error(
1454
+ `Cannot add suspense child "${id}" to parent suspense "${parentID}" because parent suspense node was not found in the Store.`,
1455
+ ),
1456
+ );
1457
+
1458
+ break;
1459
+ }
1460
+
1461
+ parentSuspense.children.push(id);
1462
+ }
1463
+
1464
+ if (name === null) {
1465
+ name = 'Unknown';
1466
+ }
1467
+
1468
+ this._idToSuspense.set(id, {
1469
+ id,
1470
+ parentID,
1471
+ children: [],
1472
+ name,
1473
+ });
1474
+
1475
+ i += 4;
1476
+
1477
+ hasSuspenseTreeChanged = true;
1478
+ break;
1479
+ }
1480
+ case SUSPENSE_TREE_OPERATION_REMOVE: {
1481
+ const removeLength = operations[i + 1];
1482
+ i += 2;
1483
+
1484
+ for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) {
1485
+ const id = operations[i];
1486
+ const suspense = this._idToSuspense.get(id);
1487
+
1488
+ if (suspense === undefined) {
1489
+ this._throwAndEmitError(
1490
+ Error(
1491
+ `Cannot remove suspense node "${id}" because no matching node was found in the Store.`,
1492
+ ),
1493
+ );
1494
+
1495
+ break;
1496
+ }
1497
+
1498
+ i += 1;
1499
+
1500
+ const {children, parentID} = suspense;
1501
+ if (children.length > 0) {
1502
+ this._throwAndEmitError(
1503
+ Error(`Suspense node "${id}" was removed before its children.`),
1504
+ );
1505
+ }
1506
+
1507
+ this._idToSuspense.delete(id);
1508
+
1509
+ let parentSuspense: ?SuspenseNode = null;
1510
+ if (parentID === 0) {
1511
+ if (__DEBUG__) {
1512
+ debug('Suspense remove', `node ${id} root`);
1513
+ }
1514
+ } else {
1515
+ if (__DEBUG__) {
1516
+ debug('Suspense Remove', `node ${id} from parent ${parentID}`);
1517
+ }
1518
+
1519
+ parentSuspense = this._idToSuspense.get(parentID);
1520
+ if (parentSuspense === undefined) {
1521
+ this._throwAndEmitError(
1522
+ Error(
1523
+ `Cannot remove suspense node "${id}" from parent "${parentID}" because no matching node was found in the Store.`,
1524
+ ),
1525
+ );
1526
+
1527
+ break;
1528
+ }
1529
+
1530
+ const index = parentSuspense.children.indexOf(id);
1531
+ parentSuspense.children.splice(index, 1);
1532
+ }
1533
+ }
1534
+
1535
+ hasSuspenseTreeChanged = true;
1536
+ break;
1537
+ }
1538
+ case SUSPENSE_TREE_OPERATION_REORDER_CHILDREN: {
1539
+ const id = operations[i + 1];
1540
+ const numChildren = operations[i + 2];
1541
+ i += 3;
1542
+
1543
+ const suspense = this._idToSuspense.get(id);
1544
+ if (suspense === undefined) {
1545
+ this._throwAndEmitError(
1546
+ Error(
1547
+ `Cannot reorder children for suspense node "${id}" because no matching node was found in the Store.`,
1548
+ ),
1549
+ );
1550
+
1551
+ break;
1552
+ }
1553
+
1554
+ const children = suspense.children;
1555
+ if (children.length !== numChildren) {
1556
+ this._throwAndEmitError(
1557
+ Error(
1558
+ `Suspense children cannot be added or removed during a reorder operation.`,
1559
+ ),
1560
+ );
1561
+ }
1562
+
1563
+ for (let j = 0; j < numChildren; j++) {
1564
+ const childID = operations[i + j];
1565
+ children[j] = childID;
1566
+ if (__DEV__) {
1567
+ // This check is more expensive so it's gated by __DEV__.
1568
+ const childSuspense = this._idToSuspense.get(childID);
1569
+ if (childSuspense == null || childSuspense.parentID !== id) {
1570
+ console.error(
1571
+ `Suspense children cannot be added or removed during a reorder operation.`,
1572
+ );
1573
+ }
1574
+ }
1575
+ }
1576
+ i += numChildren;
1577
+
1578
+ if (__DEBUG__) {
1579
+ debug(
1580
+ 'Re-order',
1581
+ `Suspense node ${id} children ${children.join(',')}`,
1582
+ );
1583
+ }
1584
+
1585
+ hasSuspenseTreeChanged = true;
1586
+ break;
1587
+ }
1588
default:
1589
this._throwAndEmitError(
1590
new UnsupportedBridgeOperationError(
@@ -1393,6 +1595,9 @@ export default class Store extends EventEmitter<{
1595
}
1596
1597
this._revision++;
1598
+ if (hasSuspenseTreeChanged) {
1599
+ this._revisionSuspense++;
1600
+ }
1601
1602
// Any time the tree changes (e.g. elements added, removed, or reordered) cached indices may be invalid.
1603
this._cachedErrorAndWarningTuples = null;
@@ -1451,6 +1656,10 @@ export default class Store extends EventEmitter<{
1656
}
1657
}
1658
1659
+ if (hasSuspenseTreeChanged) {
1660
+ this.emit('suspenseTreeMutated');
1661
+ }
1662
+
1663
if (__DEBUG__) {
1664
console.log(printStore(this, true));
1665
console.groupEnd();
packages/react-devtools-shared/src/devtools/views/DevTools.js
+56
-48
@@ -33,6 +33,7 @@ import FetchFileWithCachingContext from './Components/FetchFileWithCachingContex
33
import {InspectedElementContextController} from './Components/InspectedElementContext';
34
import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext';
35
import {ProfilerContextController} from './Profiler/ProfilerContext';
36
+import {SuspenseTreeContextController} from './SuspenseTab/SuspenseTreeContext';
37
import {TimelineContextController} from 'react-devtools-timeline/src/TimelineContext';
38
import {ModalDialogContextController} from './ModalDialog';
39
import ReactLogo from './ReactLogo';
@@ -319,58 +320,65 @@ export default function DevTools({
320
<ProfilerContextController>
321
<TimelineContextController>
322
<InspectedElementContextController>
322
- <ThemeProvider>
323
- <div
324
- className={styles.DevTools}
325
- ref={devToolsRef}
326
- data-react-devtools-portal-root={true}>
327
- {showTabBar && (
328
- <div className={styles.TabBar}>
329
- <ReactLogo />
330
- <span className={styles.DevToolsVersion}>
331
- {process.env.DEVTOOLS_VERSION}
332
- </span>
333
- <div className={styles.Spacer} />
334
- <TabBar
335
- currentTab={tab}
336
- id="DevTools"
337
- selectTab={selectTab}
338
- tabs={tabs}
339
- type="navigation"
323
+ <SuspenseTreeContextController>
324
+ <ThemeProvider>
325
+ <div
326
+ className={styles.DevTools}
327
+ ref={devToolsRef}
328
+ data-react-devtools-portal-root={true}>
329
+ {showTabBar && (
330
+ <div className={styles.TabBar}>
331
+ <ReactLogo />
332
+ <span
333
+ className={styles.DevToolsVersion}>
334
+ {process.env.DEVTOOLS_VERSION}
335
+ </span>
336
+ <div className={styles.Spacer} />
337
+ <TabBar
338
+ currentTab={tab}
339
+ id="DevTools"
340
+ selectTab={selectTab}
341
+ tabs={tabs}
342
+ type="navigation"
343
+ />
344
+ </div>
345
+ )}
346
+ <div
347
+ className={styles.TabContent}
348
+ hidden={tab !== 'components'}>
349
+ <Components
350
+ portalContainer={
351
+ componentsPortalContainer
352
+ }
353
+ />
354
+ </div>
355
+ <div
356
+ className={styles.TabContent}
357
+ hidden={tab !== 'profiler'}>
358
+ <Profiler
359
+ portalContainer={
360
+ profilerPortalContainer
361
+ }
362
+ />
363
+ </div>
364
+ <div
365
+ className={styles.TabContent}
366
+ hidden={tab !== 'suspense'}>
367
+ <SuspenseTab
368
+ portalContainer={
369
+ suspensePortalContainer
370
+ }
371
/>
372
</div>
342
- )}
343
- <div
344
- className={styles.TabContent}
345
- hidden={tab !== 'components'}>
346
- <Components
347
- portalContainer={
348
- componentsPortalContainer
349
- }
350
- />
351
- </div>
352
- <div
353
- className={styles.TabContent}
354
- hidden={tab !== 'profiler'}>
355
- <Profiler
356
- portalContainer={profilerPortalContainer}
357
- />
373
</div>
359
- <div
360
- className={styles.TabContent}
361
- hidden={tab !== 'suspense'}>
362
- <SuspenseTab
363
- portalContainer={suspensePortalContainer}
374
+ {editorPortalContainer ? (
375
+ <EditorPane
376
+ selectedSource={currentSelectedSource}
377
+ portalContainer={editorPortalContainer}
378
/>
365
- </div>
366
- </div>
367
- {editorPortalContainer ? (
368
- <EditorPane
369
- selectedSource={currentSelectedSource}
370
- portalContainer={editorPortalContainer}
371
- />
372
- ) : null}
373
- </ThemeProvider>
379
+ ) : null}
380
+ </ThemeProvider>
381
+ </SuspenseTreeContextController>
382
</InspectedElementContextController>
383
</TimelineContextController>
384
</ProfilerContextController>
packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js
+47
@@ -16,6 +16,9 @@ import {
16
TREE_OPERATION_SET_SUBTREE_MODE,
17
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
18
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
19
+ SUSPENSE_TREE_OPERATION_ADD,
20
+ SUSPENSE_TREE_OPERATION_REMOVE,
21
+ SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
22
} from 'react-devtools-shared/src/constants';
23
import {
24
parseElementDisplayNameFromBackend,
@@ -366,6 +369,50 @@ function updateTree(
369
break;
370
}
371
372
+ case SUSPENSE_TREE_OPERATION_ADD: {
373
+ const fiberID = operations[i + 1];
374
+ const parentID = operations[i + 2];
375
+ const nameStringID = operations[i + 3];
376
+ const name = stringTable[nameStringID];
377
+
378
+ i += 4;
379
+
380
+ if (__DEBUG__) {
381
+ debug(
382
+ 'Add suspense',
383
+ `node ${fiberID} (${String(name)}) under ${parentID}`,
384
+ );
385
+ }
386
+ break;
387
+ }
388
+
389
+ case SUSPENSE_TREE_OPERATION_REMOVE: {
390
+ const removeLength = ((operations[i + 1]: any): number);
391
+ i += 2 + removeLength;
392
+
393
+ break;
394
+ }
395
+
396
+ case SUSPENSE_TREE_OPERATION_REORDER_CHILDREN: {
397
+ const suspenseID = ((operations[i + 1]: any): number);
398
+ const numChildren = ((operations[i + 2]: any): number);
399
+ const children = ((operations.slice(
400
+ i + 3,
401
+ i + 3 + numChildren,
402
+ ): any): Array<number>);
403
+
404
+ i = i + 3 + numChildren;
405
+
406
+ if (__DEBUG__) {
407
+ debug(
408
+ 'Suspense re-order',
409
+ `suspense ${suspenseID} children ${children.join(',')}`,
410
+ );
411
+ }
412
+
413
+ break;
414
+ }
415
+
416
default:
417
throw Error(`Unsupported Bridge operation "${operation}"`);
418
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+1
-4
@@ -19,6 +19,7 @@ import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBo
19
import InspectedElement from '../Components/InspectedElement';
20
import portaledContent from '../portaledContent';
21
import styles from './SuspenseTab.css';
22
+import SuspenseTreeList from './SuspenseTreeList';
23
import Button from '../Button';
24
25
type Orientation = 'horizontal' | 'vertical';
@@ -43,10 +44,6 @@ type LayoutState = {
44
};
45
type LayoutDispatch = (action: LayoutAction) => void;
46
46
-function SuspenseTreeList() {
47
- return <div>tree list</div>;
48
-}
49
-
47
function SuspenseTimeline() {
48
return <div className={styles.Timeline}>timeline</div>;
49
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
new
+111
@@ -0,0 +1,111 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+import type {ReactContext} from 'shared/ReactTypes';
10
+
11
+import * as React from 'react';
12
+import {
13
+ createContext,
14
+ startTransition,
15
+ useContext,
16
+ useEffect,
17
+ useMemo,
18
+ useReducer,
19
+} from 'react';
20
+import {StoreContext} from '../context';
21
+
22
+export type SuspenseTreeState = {};
23
+
24
+type ACTION_HANDLE_SUSPENSE_TREE_MUTATION = {
25
+ type: 'HANDLE_SUSPENSE_TREE_MUTATION',
26
+};
27
+export type SuspenseTreeAction = ACTION_HANDLE_SUSPENSE_TREE_MUTATION;
28
+export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void;
29
+
30
+const SuspenseTreeStateContext: ReactContext<SuspenseTreeState> =
31
+ createContext<SuspenseTreeState>(((null: any): SuspenseTreeState));
32
+SuspenseTreeStateContext.displayName = 'SuspenseTreeStateContext';
33
+
34
+const SuspenseTreeDispatcherContext: ReactContext<SuspenseTreeDispatch> =
35
+ createContext<SuspenseTreeDispatch>(((null: any): SuspenseTreeDispatch));
36
+SuspenseTreeDispatcherContext.displayName = 'SuspenseTreeDispatcherContext';
37
+
38
+type Props = {
39
+ children: React$Node,
40
+};
41
+
42
+function SuspenseTreeContextController({children}: Props): React.Node {
43
+ const store = useContext(StoreContext);
44
+
45
+ const initialRevision = useMemo(() => store.revisionSuspense, [store]);
46
+
47
+ // This reducer is created inline because it needs access to the Store.
48
+ // The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
49
+ // so it's okay for the reducer to have an empty dependencies array.
50
+ const reducer = useMemo(
51
+ () =>
52
+ (
53
+ state: SuspenseTreeState,
54
+ action: SuspenseTreeAction,
55
+ ): SuspenseTreeState => {
56
+ const {type} = action;
57
+ switch (type) {
58
+ case 'HANDLE_SUSPENSE_TREE_MUTATION':
59
+ return {...state};
60
+ default:
61
+ throw new Error(`Unrecognized action "${type}"`);
62
+ }
63
+ },
64
+ [],
65
+ );
66
+
67
+ const [state, dispatch] = useReducer(reducer, {});
68
+ const transitionDispatch = useMemo(
69
+ () => (action: SuspenseTreeAction) =>
70
+ startTransition(() => {
71
+ dispatch(action);
72
+ }),
73
+ [dispatch],
74
+ );
75
+
76
+ useEffect(() => {
77
+ const handleSuspenseTreeMutated = () => {
78
+ transitionDispatch({
79
+ type: 'HANDLE_SUSPENSE_TREE_MUTATION',
80
+ });
81
+ };
82
+
83
+ // Since this is a passive effect, the tree may have been mutated before our initial subscription.
84
+ if (store.revisionSuspense !== initialRevision) {
85
+ // At the moment, we can treat this as a mutation.
86
+ // We don't know which Elements were newly added/removed, but that should be okay in this case.
87
+ // It would only impact the search state, which is unlikely to exist yet at this point.
88
+ transitionDispatch({
89
+ type: 'HANDLE_SUSPENSE_TREE_MUTATION',
90
+ });
91
+ }
92
+
93
+ store.addListener('suspenseTreeMutated', handleSuspenseTreeMutated);
94
+ return () =>
95
+ store.removeListener('suspenseTreeMutated', handleSuspenseTreeMutated);
96
+ }, [dispatch, initialRevision, store]);
97
+
98
+ return (
99
+ <SuspenseTreeStateContext.Provider value={state}>
100
+ <SuspenseTreeDispatcherContext.Provider value={transitionDispatch}>
101
+ {children}
102
+ </SuspenseTreeDispatcherContext.Provider>
103
+ </SuspenseTreeStateContext.Provider>
104
+ );
105
+}
106
+
107
+export {
108
+ SuspenseTreeDispatcherContext,
109
+ SuspenseTreeStateContext,
110
+ SuspenseTreeContextController,
111
+};
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeList.js
new
+90
@@ -0,0 +1,90 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+import type {SuspenseNode} from '../../../frontend/types';
10
+import type Store from '../../store';
11
+
12
+import * as React from 'react';
13
+import {useContext} from 'react';
14
+import {StoreContext} from '../context';
15
+import {SuspenseTreeStateContext} from './SuspenseTreeContext';
16
+import {TreeDispatcherContext} from '../Components/TreeContext';
17
+
18
+function getDocumentOrderSuspenseTreeList(store: Store): Array<SuspenseNode> {
19
+ const suspenseTreeList: SuspenseNode[] = [];
20
+ for (let i = 0; i < store.roots.length; i++) {
21
+ const root = store.getElementByID(store.roots[i]);
22
+ if (root === null) {
23
+ continue;
24
+ }
25
+ const suspense = store.getSuspenseByID(root.id);
26
+ if (suspense !== null) {
27
+ const stack = [suspense];
28
+ while (stack.length > 0) {
29
+ const current = stack.pop();
30
+ if (current === undefined) {
31
+ continue;
32
+ }
33
+ suspenseTreeList.push(current);
34
+ // Add children in reverse order to maintain document order
35
+ for (let j = current.children.length - 1; j >= 0; j--) {
36
+ const childSuspense = store.getSuspenseByID(current.children[j]);
37
+ if (childSuspense !== null) {
38
+ stack.push(childSuspense);
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ return suspenseTreeList;
46
+}
47
+
48
+export default function SuspenseTreeList(_: {}): React$Node {
49
+ const store = useContext(StoreContext);
50
+ const treeDispatch = useContext(TreeDispatcherContext);
51
+ useContext(SuspenseTreeStateContext);
52
+
53
+ const suspenseTreeList = getDocumentOrderSuspenseTreeList(store);
54
+
55
+ return (
56
+ <div>
57
+ <p>Suspense Tree List</p>
58
+ <ul>
59
+ {suspenseTreeList.map(suspense => {
60
+ const {id, parentID, children, name} = suspense;
61
+ return (
62
+ <li key={id}>
63
+ <div>
64
+ <button
65
+ onClick={() => {
66
+ treeDispatch({
67
+ type: 'SELECT_ELEMENT_BY_ID',
68
+ payload: id,
69
+ });
70
+ }}>
71
+ inspect {name || 'N/A'} ({id})
72
+ </button>
73
+ </div>
74
+ <div>
75
+ <strong>Suspense ID:</strong> {id}
76
+ </div>
77
+ <div>
78
+ <strong>Parent ID:</strong> {parentID}
79
+ </div>
80
+ <div>
81
+ <strong>Children:</strong>{' '}
82
+ {children.length === 0 ? '∅' : children.join(', ')}
83
+ </div>
84
+ </li>
85
+ );
86
+ })}
87
+ </ul>
88
+ </div>
89
+ );
90
+}
packages/react-devtools-shared/src/frontend/types.js
+7
@@ -184,6 +184,13 @@ export type Element = {
184
compiledWithForget: boolean,
185
};
186
187
+export type SuspenseNode = {
188
+ id: Element['id'],
189
+ parentID: SuspenseNode['id'] | 0,
190
+ children: Array<SuspenseNode['id']>,
191
+ name: string | null,
192
+};
193
+
194
// Serialized version of ReactIOInfo
195
export type SerializedIOInfo = {
196
name: string,
packages/react-devtools-shared/src/utils.js
+43
-1
@@ -40,6 +40,9 @@ import {
40
SESSION_STORAGE_RELOAD_AND_PROFILE_KEY,
41
SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY,
42
SESSION_STORAGE_RECORD_TIMELINE_KEY,
43
+ SUSPENSE_TREE_OPERATION_ADD,
44
+ SUSPENSE_TREE_OPERATION_REMOVE,
45
+ SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
46
} from './constants';
47
import {
48
ComponentFilterElementType,
@@ -318,7 +321,7 @@ export function printOperationsArray(operations: Array<number>) {
321
// The profiler UI uses them lazily in order to generate the tree.
322
i += 3;
323
break;
321
- case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS:
324
+ case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS: {
325
const id = operations[i + 1];
326
const numErrors = operations[i + 2];
327
const numWarnings = operations[i + 3];
@@ -329,6 +332,45 @@ export function printOperationsArray(operations: Array<number>) {
332
`Node ${id} has ${numErrors} errors and ${numWarnings} warnings`,
333
);
334
break;
335
+ }
336
+ case SUSPENSE_TREE_OPERATION_ADD: {
337
+ const fiberID = operations[i + 1];
338
+ const parentID = operations[i + 2];
339
+ const nameStringID = operations[i + 3];
340
+ const name = stringTable[nameStringID];
341
+
342
+ i += 4;
343
+
344
+ logs.push(
345
+ `Add suspense node ${fiberID} (${String(name)}) under ${parentID}`,
346
+ );
347
+ break;
348
+ }
349
+ case SUSPENSE_TREE_OPERATION_REMOVE: {
350
+ const removeLength = ((operations[i + 1]: any): number);
351
+ i += 2;
352
+
353
+ for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) {
354
+ const id = ((operations[i]: any): number);
355
+ i += 1;
356
+
357
+ logs.push(`Remove suspense node ${id}`);
358
+ }
359
+
360
+ break;
361
+ }
362
+ case SUSPENSE_TREE_OPERATION_REORDER_CHILDREN: {
363
+ const id = ((operations[i + 1]: any): number);
364
+ const numChildren = ((operations[i + 2]: any): number);
365
+ i += 3;
366
+ const children = operations.slice(i, i + numChildren);
367
+ i += numChildren;
368
+
369
+ logs.push(
370
+ `Re-order suspense node ${id} children ${children.join(',')}`,
371
+ );
372
+ break;
373
+ }
374
default:
375
throw Error(`Unsupported Bridge operation "${operation}"`);
376
}
packages/react-devtools-shell/src/app/SuspenseTree/index.js
+149
-1
@@ -12,6 +12,7 @@ import {
12
Fragment,
13
Suspense,
14
unstable_SuspenseList as SuspenseList,
15
+ useReducer,
16
useState,
17
} from 'react';
18
@@ -26,10 +27,156 @@ function SuspenseTree(): React.Node {
27
<NestedSuspenseTest />
28
<SuspenseListTest />
29
<EmptySuspense />
30
+ <SuspenseTreeOperations />
31
</Fragment>
32
);
33
}
34
35
+function IgnoreMePassthrough({children}: {children: React$Node}) {
36
+ return <span>{children}</span>;
37
+}
38
+
39
+const suspenseTreeOperationsChildren = {
40
+ a: (
41
+ <Suspense key="a" name="a">
42
+ <p>A</p>
43
+ </Suspense>
44
+ ),
45
+ b: (
46
+ <div key="b">
47
+ <Suspense name="b">B</Suspense>
48
+ </div>
49
+ ),
50
+ c: (
51
+ <p key="c">
52
+ <Suspense key="c" name="c">
53
+ C
54
+ </Suspense>
55
+ </p>
56
+ ),
57
+ d: (
58
+ <Suspense key="d" name="d">
59
+ <div>D</div>
60
+ </Suspense>
61
+ ),
62
+ e: (
63
+ <Suspense key="e" name="e">
64
+ <IgnoreMePassthrough key="e1">
65
+ <Suspense name="e-child-one">
66
+ <p>e1</p>
67
+ </Suspense>
68
+ </IgnoreMePassthrough>
69
+ <IgnoreMePassthrough key="e2">
70
+ <Suspense name="e-child-two">
71
+ <div>e2</div>
72
+ </Suspense>
73
+ </IgnoreMePassthrough>
74
+ </Suspense>
75
+ ),
76
+ eReordered: (
77
+ <Suspense key="e" name="e">
78
+ <IgnoreMePassthrough key="e2">
79
+ <Suspense name="e-child-two">
80
+ <div>e2</div>
81
+ </Suspense>
82
+ </IgnoreMePassthrough>
83
+ <IgnoreMePassthrough key="e1">
84
+ <Suspense name="e-child-one">
85
+ <p>e1</p>
86
+ </Suspense>
87
+ </IgnoreMePassthrough>
88
+ </Suspense>
89
+ ),
90
+};
91
+
92
+function SuspenseTreeOperations() {
93
+ const initialChildren: any[] = [
94
+ suspenseTreeOperationsChildren.a,
95
+ suspenseTreeOperationsChildren.b,
96
+ suspenseTreeOperationsChildren.c,
97
+ suspenseTreeOperationsChildren.d,
98
+ suspenseTreeOperationsChildren.e,
99
+ ];
100
+ const [children, dispatch] = useReducer(
101
+ (
102
+ pendingState: any[],
103
+ action: 'toggle-mount' | 'reorder' | 'reorder-within-filtered',
104
+ ): React$Node[] => {
105
+ switch (action) {
106
+ case 'toggle-mount':
107
+ if (pendingState.length === 5) {
108
+ return [
109
+ suspenseTreeOperationsChildren.a,
110
+ suspenseTreeOperationsChildren.b,
111
+ suspenseTreeOperationsChildren.c,
112
+ suspenseTreeOperationsChildren.d,
113
+ ];
114
+ } else {
115
+ return [
116
+ suspenseTreeOperationsChildren.a,
117
+ suspenseTreeOperationsChildren.b,
118
+ suspenseTreeOperationsChildren.c,
119
+ suspenseTreeOperationsChildren.d,
120
+ suspenseTreeOperationsChildren.e,
121
+ ];
122
+ }
123
+ case 'reorder':
124
+ if (pendingState[1] === suspenseTreeOperationsChildren.b) {
125
+ return [
126
+ suspenseTreeOperationsChildren.a,
127
+ suspenseTreeOperationsChildren.c,
128
+ suspenseTreeOperationsChildren.b,
129
+ suspenseTreeOperationsChildren.d,
130
+ suspenseTreeOperationsChildren.e,
131
+ ];
132
+ } else {
133
+ return [
134
+ suspenseTreeOperationsChildren.a,
135
+ suspenseTreeOperationsChildren.b,
136
+ suspenseTreeOperationsChildren.c,
137
+ suspenseTreeOperationsChildren.d,
138
+ suspenseTreeOperationsChildren.e,
139
+ ];
140
+ }
141
+ case 'reorder-within-filtered':
142
+ if (pendingState[4] === suspenseTreeOperationsChildren.e) {
143
+ return [
144
+ suspenseTreeOperationsChildren.a,
145
+ suspenseTreeOperationsChildren.b,
146
+ suspenseTreeOperationsChildren.c,
147
+ suspenseTreeOperationsChildren.d,
148
+ suspenseTreeOperationsChildren.eReordered,
149
+ ];
150
+ } else {
151
+ return [
152
+ suspenseTreeOperationsChildren.a,
153
+ suspenseTreeOperationsChildren.b,
154
+ suspenseTreeOperationsChildren.c,
155
+ suspenseTreeOperationsChildren.d,
156
+ suspenseTreeOperationsChildren.e,
157
+ ];
158
+ }
159
+ default:
160
+ return pendingState;
161
+ }
162
+ },
163
+ initialChildren,
164
+ );
165
+
166
+ return (
167
+ <>
168
+ <button onClick={() => dispatch('toggle-mount')}>Toggle Mount</button>
169
+ <button onClick={() => dispatch('reorder')}>Reorder</button>
170
+ <button onClick={() => dispatch('reorder-within-filtered')}>
171
+ Reorder Within Filtered
172
+ </button>
173
+ <Suspense name="operations-parent">
174
+ <section>{children}</section>
175
+ </Suspense>
176
+ </>
177
+ );
178
+}
179
+
180
function EmptySuspense() {
181
return <Suspense />;
182
}
@@ -144,7 +291,8 @@ function LoadLater() {
291
<Suspense
292
fallback={
293
<Fallback1 onClick={() => setLoadChild(true)}>Click to load</Fallback1>
147
- }>
294
+ }
295
+ name="LoadLater">
296
{loadChild ? (
297
<Primary1 onClick={() => setLoadChild(false)}>
298
Loaded! Click to suspend again.