[DevTools] Send suspense nodes to frontend store (#34070)

This commit is contained in:
Sebastian "Sebbie" Silbermann
2025-08-10 10:12:20 +02:00
committed by GitHub
parent cf6e502ed2
commit 98286cf8e3
11 changed files with 1001 additions and 171 deletions

View File

@@ -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<FiberRoot, FiberInstance> = 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<number, FiberInstance | VirtualInstance> =
new Map();
const idToDevToolsInstanceMap: Map<
FiberInstance['id'] | VirtualInstance['id'],
FiberInstance | VirtualInstance,
> = new Map();
const idToSuspenseNodeMap: Map<FiberInstance['id'], SuspenseNode> = new Map();
// Map of canonical HostInstances to the nearest parent DevToolsInstance.
const publicInstanceToDevToolsInstanceMap: Map<HostInstance, DevToolsInstance> =
@@ -1960,11 +1967,12 @@ export function attach(
};
const pendingOperations: OperationsArray = [];
const pendingRealUnmountedIDs: Array<number> = [];
const pendingRealUnmountedIDs: Array<FiberInstance['id']> = [];
const pendingRealUnmountedSuspenseIDs: Array<FiberInstance['id']> = [];
let pendingOperationsQueue: Array<OperationsArray> | null = [];
const pendingStringTable: Map<string, StringTableEntry> = 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<number>(
// 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<number>,
): 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<number> = [];
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();

View File

@@ -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;

View File

@@ -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<DevToolsHookSettings>],
hostInstanceSelected: [Element['id']],
settingsUpdated: [$ReadOnly<DevToolsHookSettings>],
mutated: [[Array<number>, Map<number, number>]],
mutated: [[Array<Element['id']>, Map<Element['id'], Element['id']>]],
recordChangeDescriptions: [],
roots: [],
rootSupportsBasicProfiling: [],
rootSupportsTimelineProfiling: [],
suspenseTreeMutated: [],
supportsNativeStyleEditor: [],
supportsReloadAndProfile: [],
unsupportedBridgeProtocolDetected: [],
@@ -127,8 +132,10 @@ export default class Store extends EventEmitter<{
_componentFilters: Array<ComponentFilter>;
// Map of ID to number of recorded error and warning message IDs.
_errorsAndWarnings: Map<number, {errorCount: number, warningCount: number}> =
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<number, Element> = new Map();
_idToElement: Map<Element['id'], Element> = new Map();
_idToSuspense: Map<SuspenseNode['id'], SuspenseNode> = 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<number, Set<number>> = new Map();
_ownersMap: Map<Element['id'], Set<Element['id']>> = 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<number> = [];
_roots: $ReadOnlyArray<Element['id']> = [];
_rootIDToCapabilities: Map<number, Capabilities> = new Map();
_rootIDToCapabilities: Map<Element['id'], Capabilities> = new Map();
// Renderer ID is needed to support inspection fiber props, state, and hooks.
_rootIDToRendererID: Map<number, number> = new Map();
_rootIDToRendererID: Map<Element['id'], number> = 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<number, number> {
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();

View File

@@ -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({
<ProfilerContextController>
<TimelineContextController>
<InspectedElementContextController>
<ThemeProvider>
<div
className={styles.DevTools}
ref={devToolsRef}
data-react-devtools-portal-root={true}>
{showTabBar && (
<div className={styles.TabBar}>
<ReactLogo />
<span className={styles.DevToolsVersion}>
{process.env.DEVTOOLS_VERSION}
</span>
<div className={styles.Spacer} />
<TabBar
currentTab={tab}
id="DevTools"
selectTab={selectTab}
tabs={tabs}
type="navigation"
<SuspenseTreeContextController>
<ThemeProvider>
<div
className={styles.DevTools}
ref={devToolsRef}
data-react-devtools-portal-root={true}>
{showTabBar && (
<div className={styles.TabBar}>
<ReactLogo />
<span
className={styles.DevToolsVersion}>
{process.env.DEVTOOLS_VERSION}
</span>
<div className={styles.Spacer} />
<TabBar
currentTab={tab}
id="DevTools"
selectTab={selectTab}
tabs={tabs}
type="navigation"
/>
</div>
)}
<div
className={styles.TabContent}
hidden={tab !== 'components'}>
<Components
portalContainer={
componentsPortalContainer
}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'profiler'}>
<Profiler
portalContainer={
profilerPortalContainer
}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'suspense'}>
<SuspenseTab
portalContainer={
suspensePortalContainer
}
/>
</div>
)}
<div
className={styles.TabContent}
hidden={tab !== 'components'}>
<Components
portalContainer={
componentsPortalContainer
}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'profiler'}>
<Profiler
portalContainer={profilerPortalContainer}
{editorPortalContainer ? (
<EditorPane
selectedSource={currentSelectedSource}
portalContainer={editorPortalContainer}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'suspense'}>
<SuspenseTab
portalContainer={suspensePortalContainer}
/>
</div>
</div>
{editorPortalContainer ? (
<EditorPane
selectedSource={currentSelectedSource}
portalContainer={editorPortalContainer}
/>
) : null}
</ThemeProvider>
) : null}
</ThemeProvider>
</SuspenseTreeContextController>
</InspectedElementContextController>
</TimelineContextController>
</ProfilerContextController>

View File

@@ -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<number>);
i = i + 3 + numChildren;
if (__DEBUG__) {
debug(
'Suspense re-order',
`suspense ${suspenseID} children ${children.join(',')}`,
);
}
break;
}
default:
throw Error(`Unsupported Bridge operation "${operation}"`);
}

View File

@@ -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 <div>tree list</div>;
}
function SuspenseTimeline() {
return <div className={styles.Timeline}>timeline</div>;
}

View File

@@ -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<SuspenseTreeState> =
createContext<SuspenseTreeState>(((null: any): SuspenseTreeState));
SuspenseTreeStateContext.displayName = 'SuspenseTreeStateContext';
const SuspenseTreeDispatcherContext: ReactContext<SuspenseTreeDispatch> =
createContext<SuspenseTreeDispatch>(((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 (
<SuspenseTreeStateContext.Provider value={state}>
<SuspenseTreeDispatcherContext.Provider value={transitionDispatch}>
{children}
</SuspenseTreeDispatcherContext.Provider>
</SuspenseTreeStateContext.Provider>
);
}
export {
SuspenseTreeDispatcherContext,
SuspenseTreeStateContext,
SuspenseTreeContextController,
};

View File

@@ -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<SuspenseNode> {
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 (
<div>
<p>Suspense Tree List</p>
<ul>
{suspenseTreeList.map(suspense => {
const {id, parentID, children, name} = suspense;
return (
<li key={id}>
<div>
<button
onClick={() => {
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: id,
});
}}>
inspect {name || 'N/A'} ({id})
</button>
</div>
<div>
<strong>Suspense ID:</strong> {id}
</div>
<div>
<strong>Parent ID:</strong> {parentID}
</div>
<div>
<strong>Children:</strong>{' '}
{children.length === 0 ? '∅' : children.join(', ')}
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -184,6 +184,13 @@ export type Element = {
compiledWithForget: boolean,
};
export type SuspenseNode = {
id: Element['id'],
parentID: SuspenseNode['id'] | 0,
children: Array<SuspenseNode['id']>,
name: string | null,
};
// Serialized version of ReactIOInfo
export type SerializedIOInfo = {
name: string,

View File

@@ -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<number>) {
// 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<number>) {
`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}"`);
}

View File

@@ -12,6 +12,7 @@ import {
Fragment,
Suspense,
unstable_SuspenseList as SuspenseList,
useReducer,
useState,
} from 'react';
@@ -26,10 +27,156 @@ function SuspenseTree(): React.Node {
<NestedSuspenseTest />
<SuspenseListTest />
<EmptySuspense />
<SuspenseTreeOperations />
</Fragment>
);
}
function IgnoreMePassthrough({children}: {children: React$Node}) {
return <span>{children}</span>;
}
const suspenseTreeOperationsChildren = {
a: (
<Suspense key="a" name="a">
<p>A</p>
</Suspense>
),
b: (
<div key="b">
<Suspense name="b">B</Suspense>
</div>
),
c: (
<p key="c">
<Suspense key="c" name="c">
C
</Suspense>
</p>
),
d: (
<Suspense key="d" name="d">
<div>D</div>
</Suspense>
),
e: (
<Suspense key="e" name="e">
<IgnoreMePassthrough key="e1">
<Suspense name="e-child-one">
<p>e1</p>
</Suspense>
</IgnoreMePassthrough>
<IgnoreMePassthrough key="e2">
<Suspense name="e-child-two">
<div>e2</div>
</Suspense>
</IgnoreMePassthrough>
</Suspense>
),
eReordered: (
<Suspense key="e" name="e">
<IgnoreMePassthrough key="e2">
<Suspense name="e-child-two">
<div>e2</div>
</Suspense>
</IgnoreMePassthrough>
<IgnoreMePassthrough key="e1">
<Suspense name="e-child-one">
<p>e1</p>
</Suspense>
</IgnoreMePassthrough>
</Suspense>
),
};
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 (
<>
<button onClick={() => dispatch('toggle-mount')}>Toggle Mount</button>
<button onClick={() => dispatch('reorder')}>Reorder</button>
<button onClick={() => dispatch('reorder-within-filtered')}>
Reorder Within Filtered
</button>
<Suspense name="operations-parent">
<section>{children}</section>
</Suspense>
</>
);
}
function EmptySuspense() {
return <Suspense />;
}
@@ -144,7 +291,8 @@ function LoadLater() {
<Suspense
fallback={
<Fallback1 onClick={() => setLoadChild(true)}>Click to load</Fallback1>
}>
}
name="LoadLater">
{loadChild ? (
<Primary1 onClick={() => setLoadChild(false)}>
Loaded! Click to suspend again.