Description#
-
The cover image is my family's cat sister.
-
React version v18.1.0
-
We refer to the components introduced by React.lazy as lazyComponent, and the components within the fallback of Suspense as fallbackComponent.
-
The analysis is based on the following code
/** * src/components/suspense/OtherComponent.js */ import React from 'react'; function OtherComponent() { return ( <div> OtherComponent </div> ); } export default OtherComponent; /** * src/components/suspense/index.js */ import React, { Suspense } from 'react'; const OtherComponent = React.lazy(() => import('./OtherComponent')); function SuspenseTest() { return ( <div> <Suspense fallback={<div>Loading...</div>}> <OtherComponent /> </Suspense> </div> ); } export default SuspenseTest;
TLNR#
During the process of creating fiber in beginWork, when encountering lazyComponent for the first time, a promise in a pending state will be thrown. This promise object will be captured by the outer try...catch... (in sync mode, it is within renderRootSync, and in concurrent mode, it is within renderRootConcurrent), and then it will traverse upwards, marking all fiber nodes along the way with the Incomplete label until reaching the Suspense node (completeUnitOfWork), and then it will begin to create fiber downwards from that node, at which point it will create the fallback node for Suspense. Subsequently, in the mutation phase (commitMutationEffects), a success callback will be added to the previously thrown promise to support re-rendering after the asynchronous component successfully loads.
React.lazy#
React.lazy is not a significant point in the analysis of Suspense, so it is only necessary to refer to its TypeScript declaration to understand the input and output parameter types.
function lazy<T extends ComponentType<any>>(
factory: () => Promise<{ default: T }>
): LazyExoticComponent<T>;
If you place a breakpoint on OtherComponent in the example code, you can see its value more intuitively, where we only need to focus on $$typeof.
The first pass#
The title is taken from the comment within updateSuspenseComponent, which is called when beginWork runs to the Suspense node. Its internal logic has different logic in the First pass and the Second pass (in The second pass, it will create the corresponding node for the fallback on LazyComponent). However, in The first pass, it will first create offScreen. After the First pass phase, we can obtain the fiber tree structure shown below.
The fiber corresponding to lazy is the OtherComponent introduced by React.lazy, and at this point, it is still just a placeholder and cannot be called a component. The logic for importing the component will only be called when beginWork runs to lazy (the code is in mountLazyComponent).
function mountLazyComponent(
_current,
workInProgress,
elementType,
renderLanes,
) {
resetSuspendedCurrentOnMountInLegacyMode(_current, workInProgress);
const props = workInProgress.pendingProps;
const lazyComponent: LazyComponentType<any, any> = elementType;
const payload = lazyComponent._payload;
const init = lazyComponent._init;
let Component = init(payload);
...
// The following does not need to be focused on, the init function will interrupt through throw, see the code below
}
// init called within mountLazyComponent
function lazyInitializer<T>(payload: Payload<T>): T {
if (payload._status === Uninitialized) {
// At this point, payload._result is the import OtherComponent part, its call will return a pending promise
const ctor = payload._result;
const thenable = ctor();
// Here, a callback is added to the promise instance, but the logic for re-rendering after successful loading is not here
thenable.then(
moduleObject => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const resolved: ResolvedPayload<T> = (payload: any);
resolved._status = Resolved;
resolved._result = moduleObject;
}
},
error => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const rejected: RejectedPayload = (payload: any);
rejected._status = Rejected;
rejected._result = error;
}
},
);
if (payload._status === Uninitialized) {
// During the first pass, this logic will be entered, changing the status from Uninitialized to Pending
const pending: PendingPayload = (payload: any);
pending._status = Pending;
pending._result = thenable;
}
}
if (payload._status === Resolved) {
const moduleObject = payload._result;
...dev content
return moduleObject.default;
} else {
// The first pass will throw out the pending state promise
// and be captured by the outer try catch
throw payload._result;
}
}
The above code block corresponds to the function ctor
The corresponding call stack
Error Handling — throwException#
Error handling occurs within handleError of renderRoot. In this function, throwException will first mark the lazy fiber with the Incomplete label and find the nearest Suspense node to mark it with the shouldCapture label (this label will take effect in The second pass). It will start traversing upwards from the lazy fiber and mark all fiber nodes along the way with the Incomplete label until it encounters the Suspense node (the Suspense node, in addition to Incomplete, will also be marked shouldCapture).
Since analyzing the entire handleError would be lengthy, I will only extract key steps to demonstrate.
Marking lazyComponent as Incomplete#
Finding the nearest SuspenseComponent marked shouldCapture#
Some details within throwException#
attachPingListener in ConcurrentMode#
attachPingListener will only be called in ConcurrentMode because asynchronous component requests may return when React enters the commit phase. In ConcurrentMode, high-priority tasks are allowed to interrupt. If the priority of the returned request is higher than the ongoing task, it will be processed first.
function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
// The following is the logic for maintaining pingCache, not the focus of reading
let pingCache = root.pingCache;
let threadIDs;
if (pingCache === null) {
pingCache = root.pingCache = new PossiblyWeakMap();
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
} else {
threadIDs = pingCache.get(wakeable);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
}
}
if (!threadIDs.has(lanes)) {
// Memoize using the thread ID to prevent redundant listeners.
threadIDs.add(lanes);
// The ping function will trigger task scheduling by calling ensureRootIsScheduled
// The resolution of ensureRootIsScheduled can be seen in my [useState](https://github.com/IWSR/react-code-debug/issues/3) analysis
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
if (enableUpdaterTracking) {
if (isDevToolsPresent) {
// If we have pending work still, restore the original updaters
restorePendingUpdaters(root, lanes);
}
}
// Here, the wakeable is the previously pending promise, meaning that in ConcurrentMode, the successful request of the asynchronous component may also trigger ensureRootIsScheduled
wakeable.then(ping, ping);
}
}
attachRetryListener#
attachRetryListener mainly attaches the pending state promise to the updateQueue of the Suspense fiber, which is somewhat different from other fibers (after all, they are used to form a linked list of update instances).
function attachRetryListener(
suspenseBoundary: Fiber,
root: FiberRoot,
wakeable: Wakeable,
lanes: Lanes,
) {
// Retry listener
//
// If the fallback does commit, we need to attach a different type of
// listener. This one schedules an update on the Suspense boundary to turn
// the fallback state off.
//
// Stash the wakeable on the boundary fiber so we can access it in the
// commit phase.
//
// When the wakeable resolves, we'll attempt to render the boundary
// again ("retry").
const wakeables: Set<Wakeable> | null = (suspenseBoundary.updateQueue: any);
if (wakeables === null) {
const updateQueue = (new Set(): any);
updateQueue.add(wakeable);
suspenseBoundary.updateQueue = updateQueue;
} else {
wakeables.add(wakeable);
}
}
Error Handling — completeUnitOfWork#
After throwException, completeUnitOfWork will be called. The function's role here is to traverse upwards from the lazy Component and mark all fiber nodes along the way with the Incomplete label. However, when it encounters a node marked with ShouldCapture, it will remove its error handling-related marks (next.flags &= HostEffectMask) and then enter beginWork from this node. It is also the entry function for completeWork and is a key function in React.
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate; // WIP corresponding current node
const returnFiber = completedWork.return; // Parent node
if ((completedWork.flags & Incomplete) === NoFlags) {
// If the current node has no errors (not marked Incomplete), enter this logic
...
let next;
... omitted unimportant parts
// The role of completeWork is roughly to generate DOM updates, bind events, for detailed suggestions see other articles
next = completeWork(current, completedWork, subtreeRenderLanes);
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next;
return;
}
} else {
// Reaching here means the node has the Incomplete label
// When unwindWork encounters a node with the ShouldCapture label, it will mark that node as DidCapture and return
// The marking of DidCapture in unwindWork is (flags & ~ShouldCapture) | DidCapture
// Nodes with the ShouldCapture label are often referred to as error boundaries, and it can be said that the function of unwindWork
const next = unwindWork(current, completedWork, subtreeRenderLanes);
// Because this fiber did not complete, don't reset its lanes.
if (next !== null) {
/**
* In this example, the SuspenseComponent has the ShouldCapture label, so when encountering the SuspenseComponent, its error handling-related marks will be cleared
*
* The logic for clearing marks can be seen below
*
* 0b0000000000001000000000000000 Incomplete
* 0b0000000000010000000000000000 ShouldCapture
* 0b0000000000011000000000000000 Nodes marked with both Incomplete and ShouldCapture
* 0b0000000000000111111111111111 HostEffectMask
* 0b0000000000000000000000000000 If the above two marks are bitwise ANDed (=&), it is clear that the Incomplete and ShouldCapture marks disappear
*/
next.flags &= HostEffectMask;
// Here, the suspense node is assigned to workInProgress
workInProgress = next;
// Returning will re-enter workLoopSync/workLoopConcurrent, and then beginWork will start from workInProgress
// This means that from here, we will again perform beginWork on the suspense node, marking the beginning of The second pass
return;
}
... omitted unnecessary logic
if (returnFiber !== null) {
// Mark the parent fiber as incomplete and clear its subtree flags.
// Mark the current fiber's parent node as Incomplete
returnFiber.flags |= Incomplete;
returnFiber.subtreeFlags = NoFlags;
returnFiber.deletions = null;
} else {
// We've unwound all the way to the root.
workInProgressRootExitStatus = RootDidNotComplete;
workInProgress = null;
return;
}
}
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// If there is more work to do in this returnFiber, do that next.
// If there are sibling nodes, perform beginWork on the sibling nodes, see the while condition in workLoopSync/workLoopConcurrent
workInProgress = siblingFiber;
return;
}
// Otherwise, return to the parent
// workInProgress points to the current node's parent and continues the do...while... loop
completedWork = returnFiber;
// Update the next thing we're working on in case something throws.
workInProgress = completedWork;
} while (completedWork !== null);
// Already at the root node, mark workInProgressRootExitStatus as completed
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted;
}
}
The second pass#
After the processing of completeUnitOfWork, the Suspense node has already been marked as DidCapture. In The second pass, beginWork will again start creating fiber nodes from the Suspense node (updateSuspenseComponent). Due to the influence of DidCapture, updateSuspenseComponent will also enter the logic skipped in the first pass to handle the fallback content.
function updateSuspenseComponent(current, workInProgress, renderLanes) {
const nextProps = workInProgress.pendingProps;
let suspenseContext: SuspenseContext = suspenseStackCursor.current;
let showFallback = false;
// Determine whether the current fiber is marked DidCapture
const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
if (
didSuspend ||
shouldRemainOnFallback(
suspenseContext,
current,
workInProgress,
renderLanes,
)
) {
// There is a DidCapture mark, so showFallback is needed
showFallback = true;
// Clear DidCapture from the node's marks
workInProgress.flags &= ~DidCapture;
} else {
... The second pass will not enter, so deleted
}
...
if (current === null) {
... deleted hydrate-related code
// This could've been a dehydrated suspense component.
const suspenseState: null | SuspenseState = workInProgress.memoizedState;
...
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
if (showFallback) {
// During the second pass, showFallback is true
// The fiber returned here for fallback, the screenshot below introduces the internal logic
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);
const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
renderLanes,
);
workInProgress.memoizedState = SUSPENDED_MARKER;
...
// Next, the node for fallback will be created
return fallbackFragment;
} else if (
enableCPUSuspense &&
typeof nextProps.unstable_expectedLoadTime === 'number'
) {
...
} else {
...
}
} else {
...
}
The general logic within mountSuspenseFallbackChildren
After the above logic, its structure is as follows
And after The second pass ends, the fiber tree structure is as follows
About the re-render triggered after the promise resolves#
When the asynchronous component loading ends, that is, when the state of the previously pending promise instance changes, a corresponding success callback needs to be added to trigger the update. This callback function is registered within the mutation phase (commitMutationEffects) of the commit stage (commitRootImpl) inside the promise, specifically located within attachSuspenseRetryListeners.
The retry function in the image is resolveRetryWakeable (located in ReactFiberWorkLoop.old.js)
For details related to ensureRootIsScheduled, see my React Hooks: useState analysis