mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
[DevTools] Send suspense nodes to frontend store (#34070)
This commit is contained in:
committed by
GitHub
parent
cf6e502ed2
commit
98286cf8e3
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
227
packages/react-devtools-shared/src/devtools/store.js
vendored
227
packages/react-devtools-shared/src/devtools/store.js
vendored
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
111
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
vendored
Normal file
111
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
vendored
Normal 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,
|
||||
};
|
||||
90
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeList.js
vendored
Normal file
90
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeList.js
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
44
packages/react-devtools-shared/src/utils.js
vendored
44
packages/react-devtools-shared/src/utils.js
vendored
@@ -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}"`);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user