Skip to main content

How root render schedule works

In how root.render() works section, the last thing we did is to schedule via microtask our application's render:

// React code was like this
scheduleImmediateTask(processRootScheduleInMicrotask);

// which will do something similar to this (in almost all cases)
queueMicrotask(processRootScheduleInMicrotask);

To be more precise, we have a scheduled root with pending work stored in the global firstScheduledRoot variable.

The work-loop is the process of rendering your components and displaying them on the screen. The loop is triggerred by several actions: root.render(), components updates, recovering from suspense... etc.

Since we are coming from root.render(), we will first explain how we would reach the work loop, and then dive into it.

How processRootScheduleInMicrotask works

When the callstack becomes empty, the javascript event-loop will process the task queue, and will eventually call the callback scheduled here.

If you are wondering, how can they be multiple roots? It is because React can run in the server too and render for several requests in parallel, or you can do it manually in client.

React code smartly and dangerously plays with globals in order to unlock many of the concurrent features. Most of the time, these global variables represents a compact internal state of a module, and external manipulation is offered via dedicated functions.

processRootScheduleInMicrotask will loop over the scheduled roots (in our simple case, the render of a small application via root.render(), there will only one root). And for each root:


let root = firstScheduledRoot;
while (root !== null) {
// perform logic for current root

root = next;
}

In summary, here is a simplified version of what happened:

// simplified, a lot

const currentTime = Date.now();
let root = firstScheduledRoot;

while (root !== null) {
const next = root.next;
// 1
entangleSyncLaneIfInsidePopStateEvent(root);
// 2
const nextLanes = scheduleTaskForRootDuringMicrotask(root, currentTime);
// 3
if (nextLanes === NoLane) { // no pending work to do
detachRootFromScheduledRoots(root);
} else {
// 4
if (includesSyncLane(nextLanes)) {
mightHavePendingSyncWork = true;
}
}
root = next;
}

// 5
flushSyncWorkOnAllRoots();
  1. Entangle the root to a SyncLane if we are inside a popstate event.
  2. Call scheduleTaskForRootDuringMicrotask to get the nextLanes for this root. This function is very important and that's when the work-loop will be triggered, we will see it right after.
  3. If there is no pending work (nextLanes === NoLane), then the root is removed from the chain: Remember? We may have multiple roots in a linked list way, when there is no pending work to do on a root, it is detached from that list.
  4. If there is still work to do, keep this root in the list and verify if it contains a sync work.
  5. Flush sync work

How scheduleTaskForRootDuringMicrotask works

The second step above did make a call to this function which is defined as follows:

Signature

function scheduleTaskForRootDuringMicrotask(
root: FiberRoot,
currentTime: number,
): Lane { /* [Not Native Code] */ }

This function returns the highest priority lane that's scheduled (smallest lane).

Implementation steps

  1. Mark starved lanes as expired

    In this process, we will go through the root's pendingLanes one by one and either compute an expiration time for it if not already computed, or else, check if it expired and add it to root.expiredLanes:

    // simplified
    const pendingLanes = root.pendingLanes;
    const suspendedLanes = root.suspendedLanes;
    const pingedLanes = root.pingedLanes;
    const expirationTimes = root.expirationTimes;

    let lanes = pendingLanes & ~RetryLanes;
    while (lanes > 0) {
    // in the following two lines, we will get the index of the highest priority
    // lane from the lanes, and then we will shift 1 by that index so we obtain
    // the current lane with only one significant bit (power of 2)
    const index = pickArbitraryLaneIndex(lanes);
    const lane = 1 << index;

    // the expiration time for this lane
    const expirationTime = root.expirationTimes[index];

    // this means it is not scheduled yet
    if (expirationTime !== NoTimestamp) {
    // this means this lane isn't suspended or is pinged
    // pinged lanes are the lanes that were suspending the root when
    // the suspending promise did resolve.
    if (
    (lane & suspendedLanes) === NoLanes ||
    (lane & pingedLanes) !== NoLanes
    ) {
    expirationTimes[index] = computeExpirationTime(lane, currentTime);
    }
    } else if (expirationTime <= currentTime) {
    // this is when it is not expired
    root.expiredLanes |= lane;
    }

    lanes &= ~lane;
    }

    Check the codebase via this link.

  2. Compute the next lanes

    Next, React will compute the next lanes:

    // When coming from root.render(children)
    const nextLanes = getNextLanes(root, NoLanes);
  3. Exit when there is no pending work or suspended on data

    When nextLanes are equal to NoLanes (which means there is no pending work to do), or the root is suspended on data, or the root has a scheduled pending commit callback (we will see it later) then it will:

    • Cancel the existing callback if any
    • Clean the root.callbackNode and root.callbackPriority properties
    • return NoLane

    Read more here on the real implementation.

  4. Exit when there is a Sync pending work

    Sync work will be flushed by processRootScheduleInMicrotask and will be detailed in the next section.

    So when a SyncLane, we do exactly same as the previous step, and return a SyncLane rather than NoLane.

  5. Schedule the render on the current root

    By here, we have a pending concurrent work to do, then we will compute a priority from the nextLanes. Wait! what ?

    The new callbackPriority is implemented now as the highest priority lane which will be tested against the existing callback priority. If the priority did not change, React would reuse the same task and priority.

    Or else, we would need to infer a priority from the highest priority lane.

    The highest priority lane is the smallest lane from nextLanes.

    There are until now 4 priorities:

    export const DiscreteEventPriority: EventPriority = SyncLane;
    export const ContinuousEventPriority: EventPriority = InputContinuousLane;
    export const DefaultEventPriority: EventPriority = DefaultLne;
    export const IdleEventPriority: EventPriority = IdleLane;

    Hey! Why we are assigning Lanes values to them ? Well, priorities are like breakpoints for lanes, which means that we can categorize all lanes into 4 groups of priorities.

    You can look at the real implementation here:

    // simplified
    const lane = getHighestPriorityLane(nextLanes);

    if (DiscreteEventPriority > lane) {
    return DiscreteEventPriority;
    }

    if (ContinuousEventPriority > lane) {
    return ContinuousEventPriority;
    }

    if (DefaultEventPriority > lane) {
    return DefaultEventPriority;
    }

    return IdleEventPriority;

    Then, we will translate the EventPriority to the SchedulerPriority:

    switch (lanesToEventPriority(nextLanes)) {
    case DiscreteEventPriority:
    schedulerPriorityLevel = ImmediateSchedulerPriority;
    break;
    case ContinuousEventPriority:
    schedulerPriorityLevel = UserBlockingSchedulerPriority;
    break;
    case DefaultEventPriority:
    schedulerPriorityLevel = NormalSchedulerPriority;
    break;
    case IdleEventPriority:
    schedulerPriorityLevel = IdleSchedulerPriority;
    break;
    default:
    schedulerPriorityLevel = NormalSchedulerPriority;
    break;
    }

    This part is implemented here.

    At this point, we've reached the very last few things to do: Scheduling the work.

    const newCallbackNode = scheduleCallback(
    // NormalPriority for simple root.render
    schedulerPriorityLevel,
    performConcurrentWorkOnRoot.bind(null, root),
    );

    root.callbackPriority = newCallbackPriority;
    root.callbackNode = newCallbackNode;
    return newCallbackPriority;

    We won't talk about scheduleCallback here since it is well documented in the how scheduler works section. performConcurrentWorkOnRoot will be detailed in a few.

    Think of scheduleCallback like a setTimeout for now.

note

Reminder: After looping over all scheduled roots and re-schedule their render, processRootScheduleInMicrotask will finally flush sync work on all roots.

How flush sync work on roots works

This function will loop over all scheduled roots starting from firstScheduledRoot, will exclude any non-legacy roots or those who don't have a SyncLane flag.

Then, it will call the performSyncWorkOnRoot which will trigger the sync work-loop.

We will first start by the concurrent work loop and then the syncone.