diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index b26da3530bf9b..236b31a3d9ef4 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -78,6 +78,9 @@ import { TREE_OPERATION_SET_SUBTREE_MODE, TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS, TREE_OPERATION_UPDATE_TREE_BASE_DURATION, + SUSPENSE_TREE_OPERATION_ADD, + SUSPENSE_TREE_OPERATION_REMOVE, + SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, } from '../../constants'; import {inspectHooksOfFiber} from 'react-debug-tools'; import { @@ -824,8 +827,12 @@ const rootToFiberInstanceMap: Map = new Map(); // Map of id to FiberInstance or VirtualInstance. // This Map is used to e.g. get the display name for a Fiber or schedule an update, // operations that should be the same whether the current and work-in-progress Fiber is used. -const idToDevToolsInstanceMap: Map = - new Map(); +const idToDevToolsInstanceMap: Map< + FiberInstance['id'] | VirtualInstance['id'], + FiberInstance | VirtualInstance, +> = new Map(); + +const idToSuspenseNodeMap: Map = new Map(); // Map of canonical HostInstances to the nearest parent DevToolsInstance. const publicInstanceToDevToolsInstanceMap: Map = @@ -1960,11 +1967,12 @@ export function attach( }; const pendingOperations: OperationsArray = []; - const pendingRealUnmountedIDs: Array = []; + const pendingRealUnmountedIDs: Array = []; + const pendingRealUnmountedSuspenseIDs: Array = []; let pendingOperationsQueue: Array | null = []; const pendingStringTable: Map = new Map(); let pendingStringTableLength: number = 0; - let pendingUnmountedRootID: number | null = null; + let pendingUnmountedRootID: FiberInstance['id'] | null = null; function pushOperation(op: number): void { if (__DEV__) { @@ -1991,6 +1999,7 @@ export function attach( return ( pendingOperations.length === 0 && pendingRealUnmountedIDs.length === 0 && + pendingRealUnmountedSuspenseIDs.length === 0 && pendingUnmountedRootID === null ); } @@ -2056,6 +2065,7 @@ export function attach( const numUnmountIDs = pendingRealUnmountedIDs.length + (pendingUnmountedRootID === null ? 0 : 1); + const numUnmountSuspenseIDs = pendingRealUnmountedSuspenseIDs.length; const operations = new Array( // Identify which renderer this update is coming from. @@ -2064,6 +2074,9 @@ export function attach( 1 + // [stringTableLength] // Then goes the actual string table. pendingStringTableLength + + // All unmounts of Suspense boundaries are batched in a single message. + // [TREE_OPERATION_REMOVE_SUSPENSE, removedSuspenseIDLength, ...ids] + (numUnmountSuspenseIDs > 0 ? 2 + numUnmountSuspenseIDs : 0) + // All unmounts are batched in a single message. // [TREE_OPERATION_REMOVE, removedIDLength, ...ids] (numUnmountIDs > 0 ? 2 + numUnmountIDs : 0) + @@ -2101,6 +2114,19 @@ export function attach( i += length; }); + if (numUnmountSuspenseIDs > 0) { + // All unmounts of Suspense boundaries are batched in a single message. + operations[i++] = SUSPENSE_TREE_OPERATION_REMOVE; + // The first number is how many unmounted IDs we're gonna send. + operations[i++] = numUnmountSuspenseIDs; + // Fill in the real unmounts in the reverse order. + // They were inserted parents-first by React, but we want children-first. + // So we traverse our array backwards. + for (let j = 0; j < pendingRealUnmountedSuspenseIDs.length; j++) { + operations[i++] = pendingRealUnmountedSuspenseIDs[j]; + } + } + if (numUnmountIDs > 0) { // All unmounts except roots are batched in a single message. operations[i++] = TREE_OPERATION_REMOVE; @@ -2130,6 +2156,7 @@ export function attach( // Reset all of the pending state now that we've told the frontend about it. pendingOperations.length = 0; pendingRealUnmountedIDs.length = 0; + pendingRealUnmountedSuspenseIDs.length = 0; pendingUnmountedRootID = null; pendingStringTable.clear(); pendingStringTableLength = 0; @@ -2467,6 +2494,54 @@ export function attach( recordConsoleLogs(instance, componentLogsEntry); } + function recordSuspenseMount( + suspenseInstance: SuspenseNode, + parentSuspenseInstance: SuspenseNode | null, + ): void { + const fiberInstance = suspenseInstance.instance; + if (fiberInstance.kind === FILTERED_FIBER_INSTANCE) { + throw new Error('Cannot record a mount for a filtered Fiber instance.'); + } + const fiberID = fiberInstance.id; + + let unfilteredParent = parentSuspenseInstance; + while ( + unfilteredParent !== null && + unfilteredParent.instance.kind === FILTERED_FIBER_INSTANCE + ) { + unfilteredParent = unfilteredParent.parent; + } + const unfilteredParentInstance = + unfilteredParent !== null ? unfilteredParent.instance : null; + if ( + unfilteredParentInstance !== null && + unfilteredParentInstance.kind === FILTERED_FIBER_INSTANCE + ) { + throw new Error( + 'Should not have a filtered instance at this point. This is a bug.', + ); + } + const parentID = + unfilteredParentInstance === null ? 0 : unfilteredParentInstance.id; + + const fiber = fiberInstance.data; + const props = fiber.memoizedProps; + // TODO: Compute a fallback name based on Owner, key etc. + const name = props === null ? null : props.name || null; + const nameStringID = getStringID(name); + + if (__DEBUG__) { + console.log('recordSuspenseMount()', suspenseInstance); + } + + idToSuspenseNodeMap.set(fiberID, suspenseInstance); + + pushOperation(SUSPENSE_TREE_OPERATION_ADD); + pushOperation(fiberID); + pushOperation(parentID); + pushOperation(nameStringID); + } + function recordUnmount(fiberInstance: FiberInstance): void { if (__DEBUG__) { debug('recordUnmount()', fiberInstance, reconcilingParent); @@ -2474,6 +2549,11 @@ export function attach( recordDisconnect(fiberInstance); + const suspenseNode = fiberInstance.suspenseNode; + if (suspenseNode !== null) { + recordSuspenseUnmount(suspenseNode); + } + idToDevToolsInstanceMap.delete(fiberInstance.id); untrackFiber(fiberInstance, fiberInstance.data); @@ -2511,6 +2591,30 @@ export function attach( // TODO: Notify the front end of the change. } + function recordSuspenseUnmount(suspenseInstance: SuspenseNode): void { + if (__DEBUG__) { + console.log( + 'recordSuspenseUnmount()', + suspenseInstance, + reconcilingParentSuspenseNode, + ); + } + + const devtoolsInstance = suspenseInstance.instance; + if (devtoolsInstance.kind !== FIBER_INSTANCE) { + throw new Error("Can't unmount a filtered SuspenseNode. This is a bug."); + } + const fiberInstance = devtoolsInstance; + const id = fiberInstance.id; + + // To maintain child-first ordering, + // we'll push it into one of these queues, + // and later arrange them in the correct order. + pendingRealUnmountedSuspenseIDs.push(id); + + idToSuspenseNodeMap.delete(id); + } + // Running state of the remaining children from the previous version of this parent that // we haven't yet added back. This should be reset anytime we change parent. // Any remaining ones at the end will be deleted. @@ -3181,6 +3285,7 @@ export function attach( // inserted the new children but since we know this is a FiberInstance we'll // just use the Fiber anyway. newSuspenseNode.rects = measureInstance(newInstance); + recordSuspenseMount(newSuspenseNode, reconcilingParentSuspenseNode); } insertChild(newInstance); if (__DEBUG__) { @@ -3609,6 +3714,56 @@ export function attach( } } + function addUnfilteredSuspenseChildrenIDs( + parentInstance: SuspenseNode, + nextChildren: Array, + ): void { + let child: null | SuspenseNode = parentInstance.firstChild; + while (child !== null) { + if (child.instance.kind === FILTERED_FIBER_INSTANCE) { + addUnfilteredSuspenseChildrenIDs(child, nextChildren); + } else { + nextChildren.push(child.instance.id); + } + child = child.nextSibling; + } + } + + function recordResetSuspenseChildren(parentInstance: SuspenseNode) { + if (__DEBUG__) { + if (parentInstance.firstChild !== null) { + console.log( + 'recordResetSuspenseChildren()', + parentInstance.firstChild, + parentInstance, + ); + } + } + // The frontend only really cares about the name, and children. + // The first two don't really change, so we are only concerned with the order of children here. + // This is trickier than a simple comparison though, since certain types of fibers are filtered. + const nextChildren: Array = []; + + addUnfilteredSuspenseChildrenIDs(parentInstance, nextChildren); + + const numChildren = nextChildren.length; + if (numChildren < 2) { + // No need to reorder. + return; + } + pushOperation(SUSPENSE_TREE_OPERATION_REORDER_CHILDREN); + // $FlowFixMe[incompatible-call] TODO: Allow filtering SuspenseNode + pushOperation(parentInstance.instance.id); + pushOperation(numChildren); + for (let i = 0; i < nextChildren.length; i++) { + pushOperation(nextChildren[i]); + } + } + + const NoUpdate = /* */ 0b00; + const ShouldResetChildren = /* */ 0b01; + const ShouldResetSuspenseChildren = /* */ 0b10; + function updateVirtualInstanceRecursively( virtualInstance: VirtualInstance, nextFirstChild: Fiber, @@ -3616,7 +3771,7 @@ export function attach( prevFirstChild: null | Fiber, traceNearestHostComponentUpdate: boolean, virtualLevel: number, // the nth level of virtual instances - ): void { + ): number { const stashedParent = reconcilingParent; const stashedPrevious = previouslyReconciledSibling; const stashedRemaining = remainingReconcilingChildren; @@ -3630,16 +3785,16 @@ export function attach( virtualInstance.firstChild = null; virtualInstance.suspendedBy = null; try { - if ( - updateVirtualChildrenRecursively( - nextFirstChild, - nextLastChild, - prevFirstChild, - traceNearestHostComponentUpdate, - virtualLevel + 1, - ) - ) { + let updateFlags = updateVirtualChildrenRecursively( + nextFirstChild, + nextLastChild, + prevFirstChild, + traceNearestHostComponentUpdate, + virtualLevel + 1, + ); + if ((updateFlags & ShouldResetChildren) !== NoUpdate) { recordResetChildren(virtualInstance); + updateFlags &= ~ShouldResetChildren; } removePreviousSuspendedBy(virtualInstance, previousSuspendedBy); // Update the errors/warnings count. If this Instance has switched to a different @@ -3652,6 +3807,8 @@ export function attach( recordConsoleLogs(virtualInstance, componentLogsEntry); // Must be called after all children have been appended. recordVirtualProfilingDurations(virtualInstance); + + return updateFlags; } finally { unmountRemainingChildren(); reconcilingParent = stashedParent; @@ -3666,8 +3823,8 @@ export function attach( prevFirstChild: null | Fiber, traceNearestHostComponentUpdate: boolean, virtualLevel: number, // the nth level of virtual instances - ): boolean { - let shouldResetChildren = false; + ): number { + let updateFlags = NoUpdate; // If the first child is different, we need to traverse them. // Each next child will be either a new child (mount) or an alternate (update). let nextChild: null | Fiber = nextFirstChild; @@ -3727,8 +3884,10 @@ export function attach( traceNearestHostComponentUpdate, virtualLevel, ); + updateFlags |= + ShouldResetChildren | ShouldResetSuspenseChildren; } else { - updateVirtualInstanceRecursively( + updateFlags |= updateVirtualInstanceRecursively( previousVirtualInstance, previousVirtualInstanceNextFirstFiber, nextChild, @@ -3779,7 +3938,7 @@ export function attach( insertChild(newVirtualInstance); previousVirtualInstance = newVirtualInstance; previousVirtualInstanceWasMount = true; - shouldResetChildren = true; + updateFlags |= ShouldResetChildren; } // Existing children might be reparented into this new virtual instance. // TODO: This will cause the front end to error which needs to be fixed. @@ -3806,8 +3965,9 @@ export function attach( traceNearestHostComponentUpdate, virtualLevel, ); + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } else { - updateVirtualInstanceRecursively( + updateFlags |= updateVirtualInstanceRecursively( previousVirtualInstance, previousVirtualInstanceNextFirstFiber, nextChild, @@ -3857,44 +4017,36 @@ export function attach( // They are always different referentially, but if the instances line up // conceptually we'll want to know that. if (prevChild !== prevChildAtSameIndex) { - shouldResetChildren = true; + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } moveChild(fiberInstance, previousSiblingOfExistingInstance); - if ( - updateFiberRecursively( - fiberInstance, - nextChild, - (prevChild: any), - traceNearestHostComponentUpdate, - ) - ) { - // If a nested tree child order changed but it can't handle its own - // child order invalidation (e.g. because it's filtered out like host nodes), - // propagate the need to reset child order upwards to this Fiber. - shouldResetChildren = true; - } + // If a nested tree child order changed but it can't handle its own + // child order invalidation (e.g. because it's filtered out like host nodes), + // propagate the need to reset child order upwards to this Fiber. + updateFlags |= updateFiberRecursively( + fiberInstance, + nextChild, + (prevChild: any), + traceNearestHostComponentUpdate, + ); } else if (prevChild !== null && shouldFilterFiber(nextChild)) { // The filtered instance could've reordered. if (prevChild !== prevChildAtSameIndex) { - shouldResetChildren = true; + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } // If this Fiber should be filtered, we need to still update its children. // This relies on an alternate since we don't have an Instance with the previous // child on it. Ideally, the reconciliation wouldn't need previous Fibers that // are filtered from the tree. - if ( - updateFiberRecursively( - null, - nextChild, - prevChild, - traceNearestHostComponentUpdate, - ) - ) { - shouldResetChildren = true; - } + updateFlags |= updateFiberRecursively( + null, + nextChild, + prevChild, + traceNearestHostComponentUpdate, + ); } else { // It's possible for a FiberInstance to be reparented when virtual parents // get their sequence split or change structure with the same render result. @@ -3906,14 +4058,17 @@ export function attach( mountFiberRecursively(nextChild, traceNearestHostComponentUpdate); // Need to mark the parent set to remount the new instance. - shouldResetChildren = true; + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } } // Try the next child. nextChild = nextChild.sibling; // Advance the pointer in the previous list so that we can // keep comparing if they line up. - if (!shouldResetChildren && prevChildAtSameIndex !== null) { + if ( + (updateFlags & ShouldResetChildren) === NoUpdate && + prevChildAtSameIndex !== null + ) { prevChildAtSameIndex = prevChildAtSameIndex.sibling; } } @@ -3926,8 +4081,9 @@ export function attach( traceNearestHostComponentUpdate, virtualLevel, ); + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } else { - updateVirtualInstanceRecursively( + updateFlags |= updateVirtualInstanceRecursively( previousVirtualInstance, previousVirtualInstanceNextFirstFiber, null, @@ -3939,9 +4095,9 @@ export function attach( } // If we have no more children, but used to, they don't line up. if (prevChildAtSameIndex !== null) { - shouldResetChildren = true; + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } - return shouldResetChildren; + return updateFlags; } // Returns whether closest unfiltered fiber parent needs to reset its child list. @@ -3949,9 +4105,9 @@ export function attach( nextFirstChild: null | Fiber, prevFirstChild: null | Fiber, traceNearestHostComponentUpdate: boolean, - ): boolean { + ): number { if (nextFirstChild === null) { - return prevFirstChild !== null; + return prevFirstChild !== null ? ShouldResetChildren : NoUpdate; } return updateVirtualChildrenRecursively( nextFirstChild, @@ -3968,7 +4124,7 @@ export function attach( nextFiber: Fiber, prevFiber: Fiber, traceNearestHostComponentUpdate: boolean, - ): boolean { + ): number { if (__DEBUG__) { if (fiberInstance !== null) { debug('updateFiberRecursively()', fiberInstance, reconcilingParent); @@ -4067,7 +4223,7 @@ export function attach( aquireHostInstance(nearestInstance, nextFiber.stateNode); } - let shouldResetChildren = false; + let updateFlags = NoUpdate; // The behavior of timed-out legacy Suspense trees is unique. Without the Offscreen wrapper. // Rather than unmount the timed out content (and possibly lose important state), @@ -4110,20 +4266,18 @@ export function attach( traceNearestHostComponentUpdate, ); - shouldResetChildren = true; + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } - if ( - nextFallbackChildSet != null && - prevFallbackChildSet != null && - updateChildrenRecursively( - nextFallbackChildSet, - prevFallbackChildSet, - traceNearestHostComponentUpdate, - ) - ) { - shouldResetChildren = true; - } + const childrenUpdateFlags = + nextFallbackChildSet != null && prevFallbackChildSet != null + ? updateChildrenRecursively( + nextFallbackChildSet, + prevFallbackChildSet, + traceNearestHostComponentUpdate, + ) + : NoUpdate; + updateFlags |= childrenUpdateFlags; } else if (prevDidTimeout && !nextDidTimeOut) { // Fallback -> Primary: // 1. Unmount fallback set @@ -4135,8 +4289,8 @@ export function attach( nextPrimaryChildSet, traceNearestHostComponentUpdate, ); + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } - shouldResetChildren = true; } else if (!prevDidTimeout && nextDidTimeOut) { // Primary -> Fallback: // 1. Hide primary set @@ -4152,7 +4306,7 @@ export function attach( nextFallbackChildSet, traceNearestHostComponentUpdate, ); - shouldResetChildren = true; + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } } else if (nextIsHidden) { if (!prevWasHidden) { @@ -4165,7 +4319,11 @@ export function attach( const stashedDisconnected = isInDisconnectedSubtree; isInDisconnectedSubtree = true; try { - updateChildrenRecursively(nextFiber.child, prevFiber.child, false); + updateFlags |= updateChildrenRecursively( + nextFiber.child, + prevFiber.child, + false, + ); } finally { isInDisconnectedSubtree = stashedDisconnected; } @@ -4177,7 +4335,11 @@ export function attach( isInDisconnectedSubtree = true; try { if (nextFiber.child !== null) { - updateChildrenRecursively(nextFiber.child, prevFiber.child, false); + updateFlags |= updateChildrenRecursively( + nextFiber.child, + prevFiber.child, + false, + ); } // Ensure we unmount any remaining children inside the isInDisconnectedSubtree flag // since they should not trigger real deletions. @@ -4189,7 +4351,7 @@ export function attach( if (fiberInstance !== null && !isInDisconnectedSubtree) { reconnectChildrenRecursively(fiberInstance); // Children may have reordered while they were hidden. - shouldResetChildren = true; + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } } else if ( nextFiber.tag === SuspenseComponent && @@ -4209,17 +4371,13 @@ export function attach( const nextFallbackFiber = nextContentFiber.sibling; // First update only the Offscreen boundary. I.e. the main content. - if ( - updateVirtualChildrenRecursively( - nextContentFiber, - nextFallbackFiber, - prevContentFiber, - traceNearestHostComponentUpdate, - 0, - ) - ) { - shouldResetChildren = true; - } + updateFlags |= updateVirtualChildrenRecursively( + nextContentFiber, + nextFallbackFiber, + prevContentFiber, + traceNearestHostComponentUpdate, + 0, + ); // Next, we'll pop back out of the SuspenseNode that we added above and now we'll // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. @@ -4229,17 +4387,13 @@ export function attach( remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; shouldPopSuspenseNode = false; if (nextFallbackFiber !== null) { - if ( - updateVirtualChildrenRecursively( - nextFallbackFiber, - null, - prevFallbackFiber, - traceNearestHostComponentUpdate, - 0, - ) - ) { - shouldResetChildren = true; - } + updateFlags |= updateVirtualChildrenRecursively( + nextFallbackFiber, + null, + prevFallbackFiber, + traceNearestHostComponentUpdate, + 0, + ); } else if ( nextFiber.memoizedState === null && fiberInstance.suspenseNode !== null @@ -4262,15 +4416,11 @@ export function attach( // Common case: Primary -> Primary. // This is the same code path as for non-Suspense fibers. if (nextFiber.child !== prevFiber.child) { - if ( - updateChildrenRecursively( - nextFiber.child, - prevFiber.child, - traceNearestHostComponentUpdate, - ) - ) { - shouldResetChildren = true; - } + updateFlags |= updateChildrenRecursively( + nextFiber.child, + prevFiber.child, + traceNearestHostComponentUpdate, + ); } else { // Children are unchanged. if (fiberInstance !== null) { @@ -4293,15 +4443,19 @@ export function attach( } } } else { + const childrenUpdateFlags = updateChildrenRecursively( + nextFiber.child, + prevFiber.child, + false, + ); // If this fiber is filtered there might be changes to this set elsewhere so we have // to visit each child to place it back in the set. We let the child bail out instead. - if ( - updateChildrenRecursively(nextFiber.child, prevFiber.child, false) - ) { + if ((childrenUpdateFlags & ShouldResetChildren) !== NoUpdate) { throw new Error( 'The children should not have changed if we pass in the same set.', ); } + updateFlags |= childrenUpdateFlags; } } } @@ -4330,21 +4484,35 @@ export function attach( } } } - if (shouldResetChildren) { + + if ((updateFlags & ShouldResetChildren) !== NoUpdate) { // We need to crawl the subtree for closest non-filtered Fibers // so that we can display them in a flat children set. if (fiberInstance !== null && fiberInstance.kind === FIBER_INSTANCE) { recordResetChildren(fiberInstance); + // We've handled the child order change for this Fiber. // Since it's included, there's no need to invalidate parent child order. - return false; + updateFlags &= ~ShouldResetChildren; } else { // Let the closest unfiltered parent Fiber reset its child order instead. - return true; } } else { - return false; } + + if ((updateFlags & ShouldResetSuspenseChildren) !== NoUpdate) { + if (fiberInstance !== null && fiberInstance.kind === FIBER_INSTANCE) { + const suspenseNode = fiberInstance.suspenseNode; + if (suspenseNode !== null) { + recordResetSuspenseChildren(suspenseNode); + updateFlags &= ~ShouldResetSuspenseChildren; + } + } else { + // Let the closest unfiltered parent Fiber reset its child order instead. + } + } + + return updateFlags; } finally { if (fiberInstance !== null) { unmountRemainingChildren(); diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index fa32ead1e934a..391eea6b23e11 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -24,6 +24,9 @@ export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4; export const TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS = 5; export const TREE_OPERATION_REMOVE_ROOT = 6; export const TREE_OPERATION_SET_SUBTREE_MODE = 7; +export const SUSPENSE_TREE_OPERATION_ADD = 8; +export const SUSPENSE_TREE_OPERATION_REMOVE = 9; +export const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10; export const PROFILING_FLAG_BASIC_SUPPORT = 0b01; export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b10; diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 3035c0ae4adba..622c9a475419c 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -20,6 +20,9 @@ import { TREE_OPERATION_SET_SUBTREE_MODE, TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS, TREE_OPERATION_UPDATE_TREE_BASE_DURATION, + SUSPENSE_TREE_OPERATION_ADD, + SUSPENSE_TREE_OPERATION_REMOVE, + SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, } from '../constants'; import {ElementTypeRoot} from '../frontend/types'; import { @@ -44,6 +47,7 @@ import type { Element, ComponentFilter, ElementType, + SuspenseNode, } from 'react-devtools-shared/src/frontend/types'; import type { FrontendBridge, @@ -100,11 +104,12 @@ export default class Store extends EventEmitter<{ hookSettings: [$ReadOnly], hostInstanceSelected: [Element['id']], settingsUpdated: [$ReadOnly], - mutated: [[Array, Map]], + mutated: [[Array, Map]], recordChangeDescriptions: [], roots: [], rootSupportsBasicProfiling: [], rootSupportsTimelineProfiling: [], + suspenseTreeMutated: [], supportsNativeStyleEditor: [], supportsReloadAndProfile: [], unsupportedBridgeProtocolDetected: [], @@ -127,8 +132,10 @@ export default class Store extends EventEmitter<{ _componentFilters: Array; // Map of ID to number of recorded error and warning message IDs. - _errorsAndWarnings: Map = - new Map(); + _errorsAndWarnings: Map< + Element['id'], + {errorCount: number, warningCount: number}, + > = new Map(); // At least one of the injected renderers contains (DEV only) owner metadata. _hasOwnerMetadata: boolean = false; @@ -136,7 +143,9 @@ export default class Store extends EventEmitter<{ // Map of ID to (mutable) Element. // Elements are mutated to avoid excessive cloning during tree updates. // The InspectedElement Suspense cache also relies on this mutability for its WeakMap usage. - _idToElement: Map = new Map(); + _idToElement: Map = new Map(); + + _idToSuspense: Map = new Map(); // Should the React Native style editor panel be shown? _isNativeStyleEditorSupported: boolean = false; @@ -149,7 +158,7 @@ export default class Store extends EventEmitter<{ // Map of element (id) to the set of elements (ids) it owns. // This map enables getOwnersListForElement() to avoid traversing the entire tree. - _ownersMap: Map> = new Map(); + _ownersMap: Map> = new Map(); _profilerStore: ProfilerStore; @@ -158,15 +167,16 @@ export default class Store extends EventEmitter<{ // Incremented each time the store is mutated. // This enables a passive effect to detect a mutation between render and commit phase. _revision: number = 0; + _revisionSuspense: number = 0; // This Array must be treated as immutable! // Passive effects will check it for changes between render and mount. - _roots: $ReadOnlyArray = []; + _roots: $ReadOnlyArray = []; - _rootIDToCapabilities: Map = new Map(); + _rootIDToCapabilities: Map = new Map(); // Renderer ID is needed to support inspection fiber props, state, and hooks. - _rootIDToRendererID: Map = new Map(); + _rootIDToRendererID: Map = new Map(); // These options may be initially set by a configuration option when constructing the Store. _supportsInspectMatchingDOMElement: boolean = false; @@ -439,6 +449,9 @@ export default class Store extends EventEmitter<{ get revision(): number { return this._revision; } + get revisionSuspense(): number { + return this._revisionSuspense; + } get rootIDToRendererID(): Map { return this._rootIDToRendererID; @@ -595,6 +608,16 @@ export default class Store extends EventEmitter<{ return element; } + getSuspenseByID(id: SuspenseNode['id']): SuspenseNode | null { + const suspense = this._idToSuspense.get(id); + if (suspense === undefined) { + console.warn(`No suspense found with id "${id}"`); + return null; + } + + return suspense; + } + // Returns a tuple of [id, index] getElementsWithErrorsAndWarnings(): ErrorAndWarningTuples { if (!this._shouldShowWarningsAndErrors) { @@ -989,6 +1012,7 @@ export default class Store extends EventEmitter<{ let haveRootsChanged = false; let haveErrorsOrWarningsChanged = false; + let hasSuspenseTreeChanged = false; // The first two values are always rendererID and rootID const rendererID = operations[0]; @@ -1369,7 +1393,7 @@ export default class Store extends EventEmitter<{ // The profiler UI uses them lazily in order to generate the tree. i += 3; break; - case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS: + case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS: { const id = operations[i + 1]; const errorCount = operations[i + 2]; const warningCount = operations[i + 3]; @@ -1383,6 +1407,184 @@ export default class Store extends EventEmitter<{ } haveErrorsOrWarningsChanged = true; break; + } + case SUSPENSE_TREE_OPERATION_ADD: { + const id = operations[i + 1]; + const parentID = operations[i + 2]; + const nameStringID = operations[i + 3]; + let name = stringTable[nameStringID]; + + if (this._idToSuspense.has(id)) { + this._throwAndEmitError( + Error( + `Cannot add suspense node "${id}" because a suspense node with that id is already in the Store.`, + ), + ); + } + + const element = this._idToElement.get(id); + if (element === undefined) { + this._throwAndEmitError( + Error( + `Cannot add suspense node "${id}" because no matching element was found in the Store.`, + ), + ); + } else { + if (name === null) { + // The boundary isn't explicitly named. + // Pick a sensible default. + // TODO: Use key + const owner = this._idToElement.get(element.ownerID); + if (owner !== undefined) { + // TODO: This is clowny + name = `${owner.displayName || 'Unknown'}>?`; + } + } + } + + if (__DEBUG__) { + debug('Suspense Add', `node ${id} as child of ${parentID}`); + } + + if (parentID !== 0) { + const parentSuspense = this._idToSuspense.get(parentID); + if (parentSuspense === undefined) { + this._throwAndEmitError( + Error( + `Cannot add suspense child "${id}" to parent suspense "${parentID}" because parent suspense node was not found in the Store.`, + ), + ); + + break; + } + + parentSuspense.children.push(id); + } + + if (name === null) { + name = 'Unknown'; + } + + this._idToSuspense.set(id, { + id, + parentID, + children: [], + name, + }); + + i += 4; + + hasSuspenseTreeChanged = true; + break; + } + case SUSPENSE_TREE_OPERATION_REMOVE: { + const removeLength = operations[i + 1]; + i += 2; + + for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) { + const id = operations[i]; + const suspense = this._idToSuspense.get(id); + + if (suspense === undefined) { + this._throwAndEmitError( + Error( + `Cannot remove suspense node "${id}" because no matching node was found in the Store.`, + ), + ); + + break; + } + + i += 1; + + const {children, parentID} = suspense; + if (children.length > 0) { + this._throwAndEmitError( + Error(`Suspense node "${id}" was removed before its children.`), + ); + } + + this._idToSuspense.delete(id); + + let parentSuspense: ?SuspenseNode = null; + if (parentID === 0) { + if (__DEBUG__) { + debug('Suspense remove', `node ${id} root`); + } + } else { + if (__DEBUG__) { + debug('Suspense Remove', `node ${id} from parent ${parentID}`); + } + + parentSuspense = this._idToSuspense.get(parentID); + if (parentSuspense === undefined) { + this._throwAndEmitError( + Error( + `Cannot remove suspense node "${id}" from parent "${parentID}" because no matching node was found in the Store.`, + ), + ); + + break; + } + + const index = parentSuspense.children.indexOf(id); + parentSuspense.children.splice(index, 1); + } + } + + hasSuspenseTreeChanged = true; + break; + } + case SUSPENSE_TREE_OPERATION_REORDER_CHILDREN: { + const id = operations[i + 1]; + const numChildren = operations[i + 2]; + i += 3; + + const suspense = this._idToSuspense.get(id); + if (suspense === undefined) { + this._throwAndEmitError( + Error( + `Cannot reorder children for suspense node "${id}" because no matching node was found in the Store.`, + ), + ); + + break; + } + + const children = suspense.children; + if (children.length !== numChildren) { + this._throwAndEmitError( + Error( + `Suspense children cannot be added or removed during a reorder operation.`, + ), + ); + } + + for (let j = 0; j < numChildren; j++) { + const childID = operations[i + j]; + children[j] = childID; + if (__DEV__) { + // This check is more expensive so it's gated by __DEV__. + const childSuspense = this._idToSuspense.get(childID); + if (childSuspense == null || childSuspense.parentID !== id) { + console.error( + `Suspense children cannot be added or removed during a reorder operation.`, + ); + } + } + } + i += numChildren; + + if (__DEBUG__) { + debug( + 'Re-order', + `Suspense node ${id} children ${children.join(',')}`, + ); + } + + hasSuspenseTreeChanged = true; + break; + } default: this._throwAndEmitError( new UnsupportedBridgeOperationError( @@ -1393,6 +1595,9 @@ export default class Store extends EventEmitter<{ } this._revision++; + if (hasSuspenseTreeChanged) { + this._revisionSuspense++; + } // Any time the tree changes (e.g. elements added, removed, or reordered) cached indices may be invalid. this._cachedErrorAndWarningTuples = null; @@ -1451,6 +1656,10 @@ export default class Store extends EventEmitter<{ } } + if (hasSuspenseTreeChanged) { + this.emit('suspenseTreeMutated'); + } + if (__DEBUG__) { console.log(printStore(this, true)); console.groupEnd(); diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js index fa02555e4cad4..91a17dcad2b76 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -33,6 +33,7 @@ import FetchFileWithCachingContext from './Components/FetchFileWithCachingContex import {InspectedElementContextController} from './Components/InspectedElementContext'; import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; import {ProfilerContextController} from './Profiler/ProfilerContext'; +import {SuspenseTreeContextController} from './SuspenseTab/SuspenseTreeContext'; import {TimelineContextController} from 'react-devtools-timeline/src/TimelineContext'; import {ModalDialogContextController} from './ModalDialog'; import ReactLogo from './ReactLogo'; @@ -319,58 +320,65 @@ export default function DevTools({ - -
- {showTabBar && ( -
- - - {process.env.DEVTOOLS_VERSION} - -
- + +
+ {showTabBar && ( +
+ + + {process.env.DEVTOOLS_VERSION} + +
+ +
+ )} + + + - )} - - - -
- {editorPortalContainer ? ( - - ) : null} - + ) : null} + + diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js index 75c9b8a6d9cc6..dfa515fffa1d1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -16,6 +16,9 @@ import { TREE_OPERATION_SET_SUBTREE_MODE, TREE_OPERATION_UPDATE_TREE_BASE_DURATION, TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS, + SUSPENSE_TREE_OPERATION_ADD, + SUSPENSE_TREE_OPERATION_REMOVE, + SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, } from 'react-devtools-shared/src/constants'; import { parseElementDisplayNameFromBackend, @@ -366,6 +369,50 @@ function updateTree( break; } + case SUSPENSE_TREE_OPERATION_ADD: { + const fiberID = operations[i + 1]; + const parentID = operations[i + 2]; + const nameStringID = operations[i + 3]; + const name = stringTable[nameStringID]; + + i += 4; + + if (__DEBUG__) { + debug( + 'Add suspense', + `node ${fiberID} (${String(name)}) under ${parentID}`, + ); + } + break; + } + + case SUSPENSE_TREE_OPERATION_REMOVE: { + const removeLength = ((operations[i + 1]: any): number); + i += 2 + removeLength; + + break; + } + + case SUSPENSE_TREE_OPERATION_REORDER_CHILDREN: { + const suspenseID = ((operations[i + 1]: any): number); + const numChildren = ((operations[i + 2]: any): number); + const children = ((operations.slice( + i + 3, + i + 3 + numChildren, + ): any): Array); + + i = i + 3 + numChildren; + + if (__DEBUG__) { + debug( + 'Suspense re-order', + `suspense ${suspenseID} children ${children.join(',')}`, + ); + } + + break; + } + default: throw Error(`Unsupported Bridge operation "${operation}"`); } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index a920b6dabd5f1..d113fd3901f0c 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -19,6 +19,7 @@ import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBo import InspectedElement from '../Components/InspectedElement'; import portaledContent from '../portaledContent'; import styles from './SuspenseTab.css'; +import SuspenseTreeList from './SuspenseTreeList'; import Button from '../Button'; type Orientation = 'horizontal' | 'vertical'; @@ -43,10 +44,6 @@ type LayoutState = { }; type LayoutDispatch = (action: LayoutAction) => void; -function SuspenseTreeList() { - return
tree list
; -} - function SuspenseTimeline() { return
timeline
; } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js new file mode 100644 index 0000000000000..8441a994972cd --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +import type {ReactContext} from 'shared/ReactTypes'; + +import * as React from 'react'; +import { + createContext, + startTransition, + useContext, + useEffect, + useMemo, + useReducer, +} from 'react'; +import {StoreContext} from '../context'; + +export type SuspenseTreeState = {}; + +type ACTION_HANDLE_SUSPENSE_TREE_MUTATION = { + type: 'HANDLE_SUSPENSE_TREE_MUTATION', +}; +export type SuspenseTreeAction = ACTION_HANDLE_SUSPENSE_TREE_MUTATION; +export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void; + +const SuspenseTreeStateContext: ReactContext = + createContext(((null: any): SuspenseTreeState)); +SuspenseTreeStateContext.displayName = 'SuspenseTreeStateContext'; + +const SuspenseTreeDispatcherContext: ReactContext = + createContext(((null: any): SuspenseTreeDispatch)); +SuspenseTreeDispatcherContext.displayName = 'SuspenseTreeDispatcherContext'; + +type Props = { + children: React$Node, +}; + +function SuspenseTreeContextController({children}: Props): React.Node { + const store = useContext(StoreContext); + + const initialRevision = useMemo(() => store.revisionSuspense, [store]); + + // This reducer is created inline because it needs access to the Store. + // The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools, + // so it's okay for the reducer to have an empty dependencies array. + const reducer = useMemo( + () => + ( + state: SuspenseTreeState, + action: SuspenseTreeAction, + ): SuspenseTreeState => { + const {type} = action; + switch (type) { + case 'HANDLE_SUSPENSE_TREE_MUTATION': + return {...state}; + default: + throw new Error(`Unrecognized action "${type}"`); + } + }, + [], + ); + + const [state, dispatch] = useReducer(reducer, {}); + const transitionDispatch = useMemo( + () => (action: SuspenseTreeAction) => + startTransition(() => { + dispatch(action); + }), + [dispatch], + ); + + useEffect(() => { + const handleSuspenseTreeMutated = () => { + transitionDispatch({ + type: 'HANDLE_SUSPENSE_TREE_MUTATION', + }); + }; + + // Since this is a passive effect, the tree may have been mutated before our initial subscription. + if (store.revisionSuspense !== initialRevision) { + // At the moment, we can treat this as a mutation. + // We don't know which Elements were newly added/removed, but that should be okay in this case. + // It would only impact the search state, which is unlikely to exist yet at this point. + transitionDispatch({ + type: 'HANDLE_SUSPENSE_TREE_MUTATION', + }); + } + + store.addListener('suspenseTreeMutated', handleSuspenseTreeMutated); + return () => + store.removeListener('suspenseTreeMutated', handleSuspenseTreeMutated); + }, [dispatch, initialRevision, store]); + + return ( + + + {children} + + + ); +} + +export { + SuspenseTreeDispatcherContext, + SuspenseTreeStateContext, + SuspenseTreeContextController, +}; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeList.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeList.js new file mode 100644 index 0000000000000..43bee6eb12134 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeList.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +import type {SuspenseNode} from '../../../frontend/types'; +import type Store from '../../store'; + +import * as React from 'react'; +import {useContext} from 'react'; +import {StoreContext} from '../context'; +import {SuspenseTreeStateContext} from './SuspenseTreeContext'; +import {TreeDispatcherContext} from '../Components/TreeContext'; + +function getDocumentOrderSuspenseTreeList(store: Store): Array { + const suspenseTreeList: SuspenseNode[] = []; + for (let i = 0; i < store.roots.length; i++) { + const root = store.getElementByID(store.roots[i]); + if (root === null) { + continue; + } + const suspense = store.getSuspenseByID(root.id); + if (suspense !== null) { + const stack = [suspense]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) { + continue; + } + suspenseTreeList.push(current); + // Add children in reverse order to maintain document order + for (let j = current.children.length - 1; j >= 0; j--) { + const childSuspense = store.getSuspenseByID(current.children[j]); + if (childSuspense !== null) { + stack.push(childSuspense); + } + } + } + } + } + + return suspenseTreeList; +} + +export default function SuspenseTreeList(_: {}): React$Node { + const store = useContext(StoreContext); + const treeDispatch = useContext(TreeDispatcherContext); + useContext(SuspenseTreeStateContext); + + const suspenseTreeList = getDocumentOrderSuspenseTreeList(store); + + return ( +
+

Suspense Tree List

+
    + {suspenseTreeList.map(suspense => { + const {id, parentID, children, name} = suspense; + return ( +
  • +
    + +
    +
    + Suspense ID: {id} +
    +
    + Parent ID: {parentID} +
    +
    + Children:{' '} + {children.length === 0 ? '∅' : children.join(', ')} +
    +
  • + ); + })} +
+
+ ); +} diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index e4a4c5400bfd5..3fff08877ce92 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -184,6 +184,13 @@ export type Element = { compiledWithForget: boolean, }; +export type SuspenseNode = { + id: Element['id'], + parentID: SuspenseNode['id'] | 0, + children: Array, + name: string | null, +}; + // Serialized version of ReactIOInfo export type SerializedIOInfo = { name: string, diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 325224844d746..ef5e7450acdfb 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -40,6 +40,9 @@ import { SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, SESSION_STORAGE_RECORD_TIMELINE_KEY, + SUSPENSE_TREE_OPERATION_ADD, + SUSPENSE_TREE_OPERATION_REMOVE, + SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, } from './constants'; import { ComponentFilterElementType, @@ -318,7 +321,7 @@ export function printOperationsArray(operations: Array) { // The profiler UI uses them lazily in order to generate the tree. i += 3; break; - case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS: + case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS: { const id = operations[i + 1]; const numErrors = operations[i + 2]; const numWarnings = operations[i + 3]; @@ -329,6 +332,45 @@ export function printOperationsArray(operations: Array) { `Node ${id} has ${numErrors} errors and ${numWarnings} warnings`, ); break; + } + case SUSPENSE_TREE_OPERATION_ADD: { + const fiberID = operations[i + 1]; + const parentID = operations[i + 2]; + const nameStringID = operations[i + 3]; + const name = stringTable[nameStringID]; + + i += 4; + + logs.push( + `Add suspense node ${fiberID} (${String(name)}) under ${parentID}`, + ); + break; + } + case SUSPENSE_TREE_OPERATION_REMOVE: { + const removeLength = ((operations[i + 1]: any): number); + i += 2; + + for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) { + const id = ((operations[i]: any): number); + i += 1; + + logs.push(`Remove suspense node ${id}`); + } + + break; + } + case SUSPENSE_TREE_OPERATION_REORDER_CHILDREN: { + const id = ((operations[i + 1]: any): number); + const numChildren = ((operations[i + 2]: any): number); + i += 3; + const children = operations.slice(i, i + numChildren); + i += numChildren; + + logs.push( + `Re-order suspense node ${id} children ${children.join(',')}`, + ); + break; + } default: throw Error(`Unsupported Bridge operation "${operation}"`); } diff --git a/packages/react-devtools-shell/src/app/SuspenseTree/index.js b/packages/react-devtools-shell/src/app/SuspenseTree/index.js index 846e3f8ef636e..c18a6315a6e71 100644 --- a/packages/react-devtools-shell/src/app/SuspenseTree/index.js +++ b/packages/react-devtools-shell/src/app/SuspenseTree/index.js @@ -12,6 +12,7 @@ import { Fragment, Suspense, unstable_SuspenseList as SuspenseList, + useReducer, useState, } from 'react'; @@ -26,10 +27,156 @@ function SuspenseTree(): React.Node { + ); } +function IgnoreMePassthrough({children}: {children: React$Node}) { + return {children}; +} + +const suspenseTreeOperationsChildren = { + a: ( + +

A

+
+ ), + b: ( +
+ B +
+ ), + c: ( +

+ + C + +

+ ), + d: ( + +
D
+
+ ), + e: ( + + + +

e1

+
+
+ + +
e2
+
+
+
+ ), + eReordered: ( + + + +
e2
+
+
+ + +

e1

+
+
+
+ ), +}; + +function SuspenseTreeOperations() { + const initialChildren: any[] = [ + suspenseTreeOperationsChildren.a, + suspenseTreeOperationsChildren.b, + suspenseTreeOperationsChildren.c, + suspenseTreeOperationsChildren.d, + suspenseTreeOperationsChildren.e, + ]; + const [children, dispatch] = useReducer( + ( + pendingState: any[], + action: 'toggle-mount' | 'reorder' | 'reorder-within-filtered', + ): React$Node[] => { + switch (action) { + case 'toggle-mount': + if (pendingState.length === 5) { + return [ + suspenseTreeOperationsChildren.a, + suspenseTreeOperationsChildren.b, + suspenseTreeOperationsChildren.c, + suspenseTreeOperationsChildren.d, + ]; + } else { + return [ + suspenseTreeOperationsChildren.a, + suspenseTreeOperationsChildren.b, + suspenseTreeOperationsChildren.c, + suspenseTreeOperationsChildren.d, + suspenseTreeOperationsChildren.e, + ]; + } + case 'reorder': + if (pendingState[1] === suspenseTreeOperationsChildren.b) { + return [ + suspenseTreeOperationsChildren.a, + suspenseTreeOperationsChildren.c, + suspenseTreeOperationsChildren.b, + suspenseTreeOperationsChildren.d, + suspenseTreeOperationsChildren.e, + ]; + } else { + return [ + suspenseTreeOperationsChildren.a, + suspenseTreeOperationsChildren.b, + suspenseTreeOperationsChildren.c, + suspenseTreeOperationsChildren.d, + suspenseTreeOperationsChildren.e, + ]; + } + case 'reorder-within-filtered': + if (pendingState[4] === suspenseTreeOperationsChildren.e) { + return [ + suspenseTreeOperationsChildren.a, + suspenseTreeOperationsChildren.b, + suspenseTreeOperationsChildren.c, + suspenseTreeOperationsChildren.d, + suspenseTreeOperationsChildren.eReordered, + ]; + } else { + return [ + suspenseTreeOperationsChildren.a, + suspenseTreeOperationsChildren.b, + suspenseTreeOperationsChildren.c, + suspenseTreeOperationsChildren.d, + suspenseTreeOperationsChildren.e, + ]; + } + default: + return pendingState; + } + }, + initialChildren, + ); + + return ( + <> + + + + +
{children}
+
+ + ); +} + function EmptySuspense() { return ; } @@ -144,7 +291,8 @@ function LoadLater() { setLoadChild(true)}>Click to load - }> + } + name="LoadLater"> {loadChild ? ( setLoadChild(false)}> Loaded! Click to suspend again.