In-depth Understanding of React: Priority Levels (Part 2)

Time: Column:Mobile & Frontend views:223

In the previous article, we explored event priority and update priority. In this one, we'll dive into a new concept of priority, which determines how state calculations proceed throughout the rendering process and how the scheduler handles task scheduling. Understanding this section will give you deeper insights into the following questions:

  • How does batch updating work?

  • Why does task priority exist?

  • How is task priority mapped to scheduling priority?

  • How is high-priority interruption of low-priority tasks achieved?

  • What are the differences between the two modes during mounting in React 18?

  • Additional topics...

2. Task Priority

All updates with an update priority need to be scheduled via scheduleUpdateOnFiber. Let’s look at what happens here:

function scheduleUpdateOnFiber(root, fiber, lane, eventTime) {
   // Marking
   ...
   markRootUpdated(root, lane, eventTime);
   // Start scheduling updates from FiberRoot
   ...
   ensureRootIsScheduled(root, eventTime); 
}

This primarily performs two actions:

  1. Marks the event time

  2. Initiates scheduling

function markRootUpdated(root, updateLane, eventTime) {
    root.pendingLanes |= updateLane; 
    if (updateLane !== IdleLane) {
      root.suspendedLanes = NoLanes;
      root.pingedLanes = NoLanes;
    }
    var eventTimes = root.eventTimes;
    var index = laneToIndex(updateLane);
    eventTimes[index] = eventTime;
}

This adds the priority of the current update to root.pendingLanes, which represents the priority levels of updates that need to be executed at any given time across the entire React application. root also has an eventTimes array, a 31-bit array that stores the event times associated with each priority level.

Now, let's look at ensureRootIsScheduled for further details:

function ensureRootIsScheduled(root, currentTime) {
    var existingCallbackNode = root.callbackNode; // This is actually a Task object
    markStarvedLanesAsExpired(root, currentTime); // Marks expiration times for all lanes with priorities

    var nextLanes = getNextLanes( // Gets task priority
      root,
      root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes
    ); // Produces a set of priorities.

    if (nextLanes === NoLanes) {
      // Special case: This only happens if there are currently no tasks
      if (existingCallbackNode !== null) {
        cancelCallback$1(existingCallbackNode);
      }

      root.callbackNode = null;
      root.callbackPriority = NoLane;
      return;
    } 
    var newCallbackPriority = getHighestPriorityLane(nextLanes); // Selects the highest priority from task priorities
    var existingCallbackPriority = root.callbackPriority;
    if (
      existingCallbackPriority === newCallbackPriority && 
      // This is the core of batch updating. When multiple `setState` calls occur within the same event, only the first will continue down, while subsequent calls will return here.
      !(
        ReactCurrentActQueue$1.current !== null &&
        existingCallbackNode !== fakeActCallbackNode
      )
    ) {
      return;
    }

    if (existingCallbackNode != null) { // If a higher-priority task arrives, cancel the previous task. `newCallbackPriority` is the highest priority, implementing high-priority interruption.
      cancelCallback$1(existingCallbackNode);
    } 

    var newCallbackNode;

    // Check if the current priority meets the synchronous condition
    if (newCallbackPriority === SyncLane) { // Synchronous priority
      // Synchronous scheduling
    } else {
      var schedulerPriorityLevel;
      switch (lanesToEventPriority(nextLanes)) {
        case DiscreteEventPriority:
          schedulerPriorityLevel = ImmediatePriority;
          break;
        case ContinuousEventPriority:
          schedulerPriorityLevel = UserBlockingPriority;
          break;
        case DefaultEventPriority:
          schedulerPriorityLevel = NormalPriority;
          break;
        case IdleEventPriority:
          schedulerPriorityLevel = IdlePriority;
          break;
        default:
          schedulerPriorityLevel = NormalPriority;
          break;
      }
      // Returns a Task object
      newCallbackNode = scheduleCallback$1(
        schedulerPriorityLevel,
        performConcurrentWorkOnRoot.bind(null, root)
      );
    }

    root.callbackPriority = newCallbackPriority; // Only one assignment can occur here.
    root.callbackNode = newCallbackNode;
}

This function has quite a bit going on, but let’s break it down and summarize its steps for better understanding:

  1. Marks expiration times to prevent starvation issues.

  2. Retrieves task priority.

  3. Hands off scheduling to the scheduler based on task priority.

Step 1: Mark Expiration Times

JavaScript Code Interpretation:

function markStarvedLanesAsExpired(root, currentTime) {
    var pendingLanes = root.pendingLanes; // Set of priorities that need to be scheduled
    var suspendedLanes = root.suspendedLanes; // Typically 0
    var pingedLanes = root.pingedLanes; // Typically 0
    var expirationTimes = root.expirationTimes; // 32-bit array
    var lanes = pendingLanes; 

    while (lanes > 0) {
      var index = pickArbitraryLaneIndex(lanes);
      var lane = 1 << index;
      var expirationTime = expirationTimes[index];

      if (expirationTime === NoTimestamp) { // Indicates unmarked
        if (
          (lane & suspendedLanes) === NoLanes ||
          (lane & pingedLanes) !== NoLanes
        ) {
          // Assumes timestamps are monotonically increasing.
          expirationTimes[index] = computeExpirationTime(lane, currentTime);
        }
      } else if (expirationTime <= currentTime) {
        // Indicates expired priority, execute immediately
        root.expiredLanes |= lane;
      }

      lanes &= ~lane; // Remove lane from lanes and continue until all priorities are marked
    }
  }

This function fills the expiration time for each priority in the current priority set. If an expired priority exists, it’s added to expiredLanes, and during the next scheduling, it checks if there are any expired priorities, which will be treated as synchronous tasks to be executed immediately, preventing starvation.

Step 2: Obtaining Task Priorities

Before diving into this concept, let's consider why task priorities are necessary. Due to JavaScript's single-threaded nature, React’s task pool (root.pendingLanes) may accumulate various update objects. React needs to select the most urgent priority to execute. This process involves selecting a batch of priorities rather than a single one. Let's examine the relevant code in getNextLanes:

JavaScript Code Interpretation:

function getNextLanes(root, wipLanes) { 
    var pendingLanes = root.pendingLanes;
    if (pendingLanes === NoLanes) {
      return NoLanes;
    }
    var nextLanes = NoLanes; // Default is NoLanes
    // If pendingLanes has an Idle-level priority, nonIdlePendingLanes becomes 0 
    var nonIdlePendingLanes = pendingLanes & NonIdleLanes; // Non-idle priority 0b0001111111111111111111111111111;

    if (nonIdlePendingLanes !== NoLanes) { // Indicates pendingLanes is not idle priority, slightly higher priority
      // If pendingLanes is not idle priority, proceed with the following code 
      var nonIdleUnblockedLanes = nonIdlePendingLanes
      if (nonIdleUnblockedLanes !== NoLanes) {
        nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes);
      } else {
        var nonIdlePingedLanes = nonIdlePendingLanes;
        if (nonIdlePingedLanes !== NoLanes) {
          nextLanes = getHighestPriorityLanes(nonIdlePingedLanes);
        }
      }
    } else {
      var unblockedLanes = pendingLanes;
      if (unblockedLanes !== NoLanes) {
        nextLanes = getHighestPriorityLanes(unblockedLanes);
      } 
      ...
    }

    if (nextLanes === NoLanes) {
      return NoLanes;
    } 
   
    // When nextLanes is InputContinuousLane
    if ((nextLanes & InputContinuousLane) !== NoLanes) { //0b0000000000000000000000000000100;
      nextLanes |= pendingLanes & DefaultLane; // 0b0000000000000000000000000010000;
    } 
    var entangledLanes = root.entangledLanes; // Relevant for transitions priority only
    if (entangledLanes !== NoLanes) {
      var entanglements = root.entanglements;
      var lanes = nextLanes & entangledLanes;

      while (lanes > 0) {
        var index = pickArbitraryLaneIndex(lanes);
        var lane = 1 << index;
        nextLanes |= entanglements[index];
        lanes &= ~lane;
      }
    }
    return nextLanes;
  }

This function is key for determining task priority. For simplicity, code related to Suspense priority has been removed. The main logic here focuses on extracting priority from pendingLanes. In most cases, non-idle priorities like event, IO, or transition updates are handled by nonIdleUnblockedLanes, and getHighestPriorityLanes is used to isolate the highest priority set.

JavaScript Code Interpretation:

function getHighestPriorityLanes(lanes) {
    switch (getHighestPriorityLane(lanes)) {
      case SyncLane:
        return SyncLane;

      case InputContinuousHydrationLane:
        return InputContinuousHydrationLane;

      case InputContinuousLane:
        return InputContinuousLane;

      case DefaultHydrationLane:
        return DefaultHydrationLane;

      case DefaultLane:
        return DefaultLane;

      case TransitionHydrationLane:
        return TransitionHydrationLane;

      case TransitionLane1:
      ...
      case TransitionLane16:
        return lanes & TransitionLanes; // Returns a set

      case RetryLane1:
      ...
      case RetryLane5:
        return lanes & RetryLanes; // Returns a set
        
      case SelectiveHydrationLane:
        return SelectiveHydrationLane;
      case IdleHydrationLane:
        return IdleHydrationLane;
      case IdleLane:
        return IdleLane;
      case OffscreenLane:
        return OffscreenLane;

      default:
        {
          error("Should have found matching lanes. This is a bug in React.");
        } // Fallback returns the full bitmask if unmatched

        return lanes;
    }
  }

We can see that only Transition and RetryLane types return a set of priorities; other types return a single priority. This optimizes the scheduling process, as Transition updates constitute a type of update.

The general logic extracts the highest-priority task from pendingLanes, determining whether to return a set or a single lane. Regardless, it represents the highest priority at that moment. The next step involves selecting the highest priority from nextLanes:

var newCallbackPriority = getHighestPriorityLane(nextLanes); // Picks the highest priority

In the previous discussion, we covered the main types of lanes:

  • Synchronous (SyncLanes)

  • Continuous Input (InputContinuousLanes)

  • Default (DefaultLanes)

  • Transition (TransitionLanes)

  • Retry (RetryLanes)

  • Idle (IdleLanes)

  • Offscreen (OffscreenLanes)

The newCallbackPriority obtained here represents the highest priority in each category, which is then handed to the Scheduler for scheduling. Before doing so, let's examine this detail:

var newCallbackPriority = getHighestPriorityLane(nextLanes);
var existingCallbackPriority = root.callbackPriority;
    if (
      existingCallbackPriority === newCallbackPriority && 
      // Core of batch updates: within a single event, multiple setState calls only trigger the first time.
      !(
        ReactCurrentActQueue$1.current !== null &&
        existingCallbackNode !== fakeActCallbackNode
      )
  ) {
    return;
}
...

root.callbackPriority = newCallbackPriority

Since ensureRootIsScheduled triggers for each update priority, the first update priority triggers a scheduling task. If the same update priority appears again, it returns directly here without adding a duplicate. This mechanism enables batch updates in React 18. For example:

const App = () => {
  const [num, setNum] = useState(0);
  const [count, setCount] = useState(0);
  
  const onClick = () => {
    setNum(num + 1)
    setCount(count + 1)
    setNum(n => n + 1)
  }
  
  return (
    <button onClick={onClick}>{num}</button>
  )
}

Here, batch updates take effect; even with three update priorities, only one task priority is generated, assuming they share the same priority level.

If existingCallbackNode differs and exists, it indicates a higher priority, interrupting lower priority tasks. The method cancelCallback$1(existingCallbackNode); cancels the top function in the Scheduler stack, enabling high-priority tasks to interrupt low-priority tasks for better responsiveness.

Step 3: Delegating to the Scheduler for Execution

Now that we have the task priority (the highest urgency task in pendingLanes), we can proceed with execution, which follows two possible paths: synchronous and asynchronous.

If the task priority is SyncLane, it indicates high urgency, requiring synchronous execution. In this case, there’s no need for the scheduler, as tasks managed by the scheduler are always executed in the next macro task. Thus, SyncLane tasks are executed synchronously.

Tasks scheduled for synchronous execution are submitted through performSyncWorkOnRoot. If concurrent mode is disabled, tasks are added to the syncQueue and will be processed together in the following stages.

Tasks for asynchronous execution are submitted through performConcurrentWorkOnRoot, which converts the task priority to a corresponding scheduler priority level before passing it to the scheduler. The conversion logic is as follows:

switch (lanesToEventPriority(nextLanes)) {
    case DiscreteEventPriority:
      schedulerPriorityLevel = ImmediatePriority;
      break;

    case ContinuousEventPriority:
      schedulerPriorityLevel = UserBlockingPriority;
      break;

    case DefaultEventPriority:
      schedulerPriorityLevel = NormalPriority;
      break;

    case IdleEventPriority:
      schedulerPriorityLevel = IdlePriority;
      break;

    default:
      schedulerPriorityLevel = NormalPriority;
      break;
}

Each task priority maps to a scheduling priority, and the Scheduler provides an API to schedule tasks accordingly:

newCallbackNode = scheduleCallback$1(
    schedulerPriorityLevel,
    performConcurrentWorkOnRoot.bind(null, root)
);

Finally, the Scheduler returns the Task, which is saved in root for potential cancellation.

For more detail on scheduling priorities, see the article In-depth Understanding of React: Scheduler.


Further Understanding

At this point, you may feel overwhelmed, so let’s consider an example to clarify.

Imagine Xiaoming, a programmer who feels constantly stressed because people come to him with varying issues—some urgent, some trivial. Despite his efforts, he is criticized for not resolving problems quickly enough. Xiaoming realizes that high-priority issues are often delayed while he’s occupied with less critical tasks, wasting time on repetitive issues.

Xiaoming creates a streamlined workflow. Each reported issue is treated as an event, and each person submitting an issue fills out a template. A product manager then verifies if it’s a duplicate and assigns it a priority, placing it in a corresponding box in the task pool.

Xiaoming’s task is straightforward—he continually takes the task with the highest priority from the left side of the pool and works on it. He also sets a 30-minute timer for himself. Every 30 minutes, he takes a short break, allowing the product manager to add new tasks to the pool. During his break, Xiaoming checks if there are tasks more urgent than his current one. If so, he switches tasks.

As a result, Xiaoming’s workflow is far more efficient, significantly reducing complaints.

In-depth Understanding of React: Priority Levels (Part 2)


Mounted Asynchronous Situations

In React v18, we have two ways to mount the application:

  1. Legacy Mode

    const rootNode = document.getElementById('root');
    ReactDOM.render(<App />, rootNode);
    console.log("script");
    Promise.resolve().then(() => console.log("promise"));

    Output:

    In-depth Understanding of React: Priority Levels (Part 2)

    In this mode, the mounting process is synchronous, as even microtasks are processed after the script tag. In legacy mode, the execution stack finishes with flushSync, with the render phase handled through flushSync, thus running before both the script and promise statements.

    In-depth Understanding of React: Priority Levels (Part 2)

  2. Concurrent Mode

    const root = ReactDOM.createRoot(document.getElementById("root"));
    root.render(<App />);
    console.log("script");
    Promise.resolve().then(() => console.log("promise"));

    Output:

    In-depth Understanding of React: Priority Levels (Part 2)

    In concurrent mode, the render phase happens in the next macro task, as anticipated. Since scheduler-managed tasks must occur in the next macro task, the mounting task in this mode is delegated to the Scheduler for asynchronous execution.