How the work loop works
Before we dive into the work loop, let's see how it starts for the first time.
How the concurrent work loop starts
For the concurrent root
, the last thing we did is to schedule in
scheduleTaskForRootDuringMicrotask
a callback to performConcurrentWorkOnRoot
.
Let's break into the concurrent work on root.
root.callbackNode = scheduleCallback(
schedulerPriorityLevel,// NormalPriority for simple root.render
performConcurrentWorkOnRoot.bind(null, root),
);
performConcurrentWorkOnRoot
signature
performConcurrentWorkOnRoot
is defined as follows:
export function performConcurrentWorkOnRoot(
root: FiberRoot,
didTimeout: boolean,
): RenderTaskFn | null { /* [Not Native Code] */ }
The root
property was bound when scheduling the callback, and the didTimeout
will be passed when the scheduler will be executing this callback.
// inside scheduler, simplified
const didTimeout = didCallbackTimedOut();
performConcurrentWorkOnBoundRoot(didTimeout);
// where
const performConcurrentWorkOnBoundRoot = performConcurrentWork.bind(null, root);
This function will be rendering your components and performing the whole logic,
so basically it will be insanely long, but we will move some parts from it
to their own sections (like WorkTags
, effects
types and their implementation
details, and obviouslyhooks
too ).
Implementation details
TL;DR
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
throw new Error('Should not already be working.');
}
const originalCallbackNode = root.callbackNode;
const didFlushPassiveEffects = flushPassiveEffects();
if (didFlushPassiveEffects && root.callbackNode !== originalCallbackNode) {
return null;
}
let lanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
if (lanes === NoLanes) {
return null;
}
const shouldTimeSlice =
!includesBlockingLane(root, lanes) &&
!includesExpiredLane(root, lanes) &&
(disableSchedulerTimeoutInWorkLoop || !didTimeout);
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);
if (renderWasSuccessfull) {
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
finishConcurrentRender(root, exitStatus, finishedWork, lanes);
} else {
// manage errors and suspense
}
ensureRootIsScheduled(root);
return getContinuationForRoot(root, originalCallbackNode);
Guard against calls to this function when already rendering or committing
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
throw new Error('Should not already be working.');
}Flush passive effects
Effects will be fully discussed in their own section. It will be linked here once ready.
// simplified
// this referencement here because flushPassiveEffects might schedule updates
const originalCallbackNode = root.callbackNode;
const didFlushPassiveEffects = flushPassiveEffects();
if (didFlushPassiveEffects && root.callbackNode !== originalCallbackNode) {
return null;
}When passive effects are flushed and something did cancel the current scheduled callback, this function will stop execution and return
null
, because the other schedule would trigger its own work.From the
root.render()
perspective, we have no effects until now, so we will continue the execution. That's another reason to skip this section for now.Compute again the
nextLanes
This is a leftover that should be fixed (until now), the next lanes are computed here again (we computed them first above in the
scheduleTaskForRootDuringMicrotask
function.)And obviously, if there are
NoLanes
(no work to do), thennull
is returned.Determine if it should
time slice
const shouldTimeSlice =
!includesBlockingLane(root, lanes) &&
!includesExpiredLane(root, lanes) &&
(disableSchedulerTimeoutInWorkLoop || !didTimeout);To use time slicing , the root should not include
BlockingLanes
orExpiredLanes
.Blocking lanes are (per the previous link):
SyncDefaultLanes
,InputContinuousHydrationLane
,InputContinuousLane
,DefaultHydrationLane
andDefaultLane
.Yes, from
root.render()
without transition we are inDefaultLane
so this render will be considered without time slicing, and thus aSync
one.Call render function based on time slicing
Depending on whether it should time slice, it will call
renderRootConcurrent
or else it will fall back torenderRootSync
.let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);The whole rendering logic should be done after calling the previous functions.
We will see them in details in a few, but keep in mind that after the whole render ends, we will perform the following steps too
Finish rendering and perform logic based on
exitStatus
The possible exitStatus values are listed here. React will then either mark the root as suspended if the render did not complete, or else:
Verify that the render is consistent with external stores. If not, a sync render will be performed
Then the
while(true)
loopcontinue
until a goodexitStatus
is obtained.In case of
RootErrored
errors, React will attempt to recover from them if possible. It will attempt to render again usingrenderRootSync
.If the
exitStatus
isRootFatalErrored
: TBD, this highlights probably a bug in React that prevented it from rendering.Or else, then the tree is consistent and the work is considered as finished
// simplified
// React when rendering (we will see in the next section) will store
// the render work output in a property called alternate in each fiber
// we will see them in details in a few
const finishedWork: Fiber = root.current.alternate;This variable is then affected to the finishedWork property of the
root
and thenfinishConcurrentRender
is called.root.finishedWork = finishedWork;
root.finishedLanes = lanes;
finishConcurrentRender(root, exitStatus, finishedWork, lanes);// todo: back to this once renderRoot is done
Ensure root is scheduled
We already saw this in the how
root.render()
works section which will schedule again a micro task to attempt to render the root again, but mostly it won't find any work to do (it will know by only looking at thenextLanes
).Return a continuation for the root
This section is not available yet. Please fill an issue.
While waiting for the newsletter, you can get notified when new content drops by following me on X.
How renderRootSync
works
So, we are finally there! The function that renders our components.
function renderRootSync(root: FiberRoot, lanes: Lanes) {
// [Not Native Code]
}
TL;DR
const prevExecutionContext = executionContext;
executionContext |= RenderContext;
const prevDispatcher = pushDispatcher(root.containerInfo);
if (workInProgressRoot !== null || workInProgressRootRenderLanes !== lanes) {
// [...] some work
prepareFreshStack(root, lanes);
}
do {
try {
if (didSuspendDuringHydration) {
resetWIPStack();
workInProgressRootExitStatus = RootDidNotComplete;
break;
}
// [...] Other branches to break when needed
workLoopSync();
// Why a break here you wonder ? Hint: there is no break in the catch block
break;
} catch (e) {
handleThrow(root, e);
}
} while (true)
if (didSuspendInShell) {
root.shellSuspendCounter++;
}
executionContext = prevExecutionContext;
workInProgressRoot = null;
workInProgressRootRenderLanes = NoLanes;
finishQueueingConcurrentUpdates();
return workInProgressRootExitStatus;
1. Mark render started
There is a global variable that WorkLoop module uses: executionContext
, which
is a number used the same way Lanes are used. The possible values are:
export const NoContext = /* */ 0b000;
const BatchedContext = /* */ 0b001;
export const RenderContext = /* */ 0b010;
export const CommitContext = /* */ 0b100;
When the render
starts, the executionContext
is modified as follows:
const prevExecutionContext = executionContext;
executionContext |= RenderContext;
// later, after the render finishes:
executionContext = prevExecutionContext;
This variable is so important to React, it can know what kind of work it is performing and guard against mis-usages.
2. Push the context only dispatcher
The React dispatcher is an object having several properties (such as hooks)
that is used by React to propagate behavior like in dependency injection
fashioned way.
At this point when the work loop is about to start, this is a transitory phase
and coming from the root.render
path, a ContextOnlyDispatcher
is used which will allow only readContext
and use
.
The same is done to the upcoming cache
feature of React, it will have its own
dispatcher.
The dispatchers will be more detailed in their own section.
3. Prepare a fresh stack
Next, renderRoot
will have to check the currently processed root and its lanes
from the global variables against the root that it received from arguments
while scheduling.
You can see it here.
if (workInProgressRoot !== null || workInProgressRootRenderLanes !== lanes) {
// some work
prepareFreshStack(root, lanes);
}
Preparing a fresh stack is the process of removing and emptying any information that was attached during a previous work or render.
Let's dive into its steps:
Set the following
root
properties tonull
finishedWork
finishedLanes
timeoutHandle
, will also cancel any existing timeout (to commit)cancelPendingCommit
Reset the work in progress stack
React will do nothing here when coming from
root.render()
, but it will reset several Fiber modules and their global variables (their internal state) and interrupt any ongoing work. This is normal since we are starting a render cycle and things need to be clean. This process may be the most important and critical to be done correctly.Reset the work loop internal state variables
This involves setting the following variable to their default values:
workInProgressRoot
=root
workInProgress
=createWorkInProgress(root.current, null)
: Pay attention to this particular variable involving the creation of a second fiber.renderLanes
=lanes
workInProgressRootRenderLanes
=lanes
workInProgressSuspendedReason
=NotSuspended
workInProgressThrownValue
=null
workInProgressRootDidAttachPingListener
=false
workInProgressRootExitStatus
=RootInProgress
workInProgressRootFatalError
=null
workInProgressRootSkippedLanes
=NoLanes
workInProgressRootInterleavedUpdatedLanes
=NoLanes
workInProgressRootRenderPhaseUpdatedLanes
=NoLanes
workInProgressRootPingedLanes
=NoLanes
workInProgressRootConcurrentErrors
=null
workInProgressRootRecoverableErrors
=null
The
createWorkInProgress
is very important, so we should visit it too. It is responsible for creating a second fiber (thealternate
) that mirrors out FiberNode (root.current
). We will be seeing it again during the work.The alternate is used by react as a
DRAFT
or aSNAPSHOT
of the ongoing work (or render), that once finished will be committed to be the main fiber and then the previous main fiber is released.The
createWorkInProgress
will be revisited again in the next section.Finish queuing the concurrent updates
Do you remember the
concurrentQueues
variable from howroot.render()
works? Well,root.render()
did not do anything to ourroot
, but it just scheduled its render via microtask queue, and at the same time, it did leave the renderchildren
as an update stored in a global variable; like this:concurrentQueues[id++] = fiber;
concurrentQueues[id++] = sharedQueue;
concurrentQueues[id++] = update;
concurrentQueues[id++] = lane;Now is time to process this array with the following steps:
Loop to the end of this array and each time reference the variables using the same order we added them, then remove them from the array by setting them to
null
.while (i < end) {
const fiber: Fiber = concurrentQueues[i];
concurrentQueues[i++] = null;
const queue: ConcurrentQueue = concurrentQueues[i];
concurrentQueues[i++] = null;
const update: ConcurrentUpdate = concurrentQueues[i];
concurrentQueues[i++] = null;
const lane: Lane = concurrentQueues[i];
concurrentQueues[i++] = null;
// at this point the update is attached to fiber's queue, it wasn't before
if (queue !== null && update !== null) {
const pending = queue.pending;
if (pending === null) {
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
}
if (lane !== NoLane) {
// this will make use of the fiber, we've already seen it too in
// the root.render section.
// it will merge these lanes starting from this fiber to its root
// (including alternate if present)
markUpdateLaneFromFiberToRoot(fiber, update, lane);
}
}
4. Trigger the work loop
Finally, for real this time (almost)! we are there!
React will then start a do while(true)
loop where it will call workLoopSync()
(the work loop 🙄). Let's take a look
at the simplified loop:
// simplified
do {
try {
if (didSuspendDuringHydration) {
resetWIPStack();
workInProgressRootExitStatus = RootDidNotComplete;
break;
}
workLoopSync();
// Why a break here you wonder ? Hint: there is no break in the catch block
break;
} catch (e) {
handleThrow(root, e);
}
} while (true)
The workLoopSync
is defined as:
function workLoopSync() {
// read well the following comment from React codebase
// Perform work without checking if we need to yield between fiber.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrent() {
// read well the following comment from React codebase
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
Here is a simplified version of how performUnitOfWork
works:
// simplified
// the unitOfWork Fiber passed here is the global workInProgress variable
// which was initialized by the fiberRoot.current.alternate
// so basically the workInProgress initially = the alternate
// which means that the 'current' tree is the workInProfress.alternate
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
const next = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
So begin work will render the fiber and return the next one.
beginWork
is detailed in the next section, but it is
important to note at this stage that it receives both the current unit of work
and its alternate. it is like: render into the alternate
the main fiber
using the renderLanes
and return the next
unit of work.
Like mentioned earlier, the alternate
refers to the workInProgerss
while
the original fiber
will be the current
tree.
This is the actual sync work loop: Keep performing work until there is nothing left to do.
As you may know already, React will construct a fiber tree from the received
children
to root.render
and will keep working until it reaches the end.
How they are linked and how the next fiber is chosen will be detailed in a
separate section, this one is for the loop.
beginWork
will end up calling the reconcileChildren
function whose role to
find child to work on next.
So, React will go down in your tree until it reaches the very bottom in sequential way: It will perform work on each component and its first child recursively until the bottom.
When the next child is null
, then React will call completeWork
on the work
that has no children, and in this process, React will either work on its
sibling (the component at the same level on the tree) or its parent's sibling
(their uncle).
This process is detailed in the how completeWork works
section.
5. Handle thrown error
If the render phase did throw an error , it is a sign that React will either show Suspense fallback, show an error boundary or a blank page.
This process is complex and long and manages many cases, we will dedicate a whole section for it.
This section is not available yet. Please fill an issue.
While waiting for the newsletter, you can get notified when new content drops by following me on X.
6. Restore execution context and dispatcher
So by here the render is completed, React will then proceed to reset some variables and perform some checks:
// workloop code
resetContextDependencies();
restoreExecutionContextAndDispatcher();
throwAsDefensiveIfThereIsStillSomeWorkToDo();
Reset context dependencies
This will reset the context's module internal state (module variables) because render did complete.
Restore execution context
// we've already seen it above
executionContext = prevExecutionContext;Throw if there is still work to do
if (workInProgress !== null) {
throw new Error(
'Cannot commit an incomplete root. This error is likely caused by a ' +
'bug in React. Please file an issue.',
);
}
7. Finish the updates
This is the same process we've seen when preparing a fresh stack, React will
perform a second pass on the concurrentQueues
in case another update is
scheduled when rendering.
In a simple case, nothing will be done.
finishQueueingConcurrentUpdates();
How renderRootConcurrent
works
There are noticeable differences from the sync
render, to pass via this
path from root.render()
, you should satisfy the shouldTimeSlice
condition.
To do so, the simplest way (if not the only way) is to wrap root.render()
with startTransition
.
In this case, renderRootConcurrent
is called with the same signature. Let's
dive into it:
todo: back to this after begin work and leaf sections