@samitouri / QOS-React-1 / commits / 98286cf8e3

[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.