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

Time: Column:Mobile & Frontend views:288

During my study of the React source code, the concept of priority levels is one of the areas where I invested significant time, as I believe it’s one of the most crucial components for understanding React thoroughly. With the groundwork laid in the previous two chapters, we can now delve into priority levels in more detail. This article will use a progressive approach to unpack the code, explaining why different priority levels exist, the reasons for their distinctions, and how they operate within React’s execution flow. Let’s get started!

Key Questions to Consider

Before we dive in, it’s helpful to keep these questions in mind, as this article primarily addresses them:

  • Why separate event priority from update priority?

  • How does React’s Lane model work?

  • What is the structure of updateQueue on a fiber, and what are its advantages?

  • Additional insights on various related topics.


Update Priority Levels

The Lane Model

Readers of this series may already be somewhat familiar with React and know that it uses the Lane model to represent priority levels. However, for completeness, let’s introduce the Lane model, which more experienced readers can skip.

Think of a “lane” as akin to a track on a racetrack, where some tracks are reserved for high-speed racing while others are for slower practice sessions. In React, different update tasks are assigned to different lanes, each with its priority level. To optimize calculations, React uses a 31-bit bitmap to represent these priority levels, allowing up to 31 different levels, as shown below:

export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000001; // 1
export const InputContinuousHydrationLane: Lane = /*    */ 0b0000000000000000000000000000010; // 2
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000000100; // 4

export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000001000; //16
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000010000; //32
// ...

Using the Lane model, React can represent various combinations of priorities with a single value, quickly isolating the most urgent priority level and optimizing performance through bitwise operations. According to the Lane model definition above, the priority levels are:

  • Sync Priority (SyncLanes)

  • Continuous Priority (InputContinuousLanes)

  • Default Priority (DefaultLanes)

  • Transition Priority (TransitionLanes)

  • Retry Priority (RetryLanes)

  • Idle Priority (IdleLanes)

  • Offscreen Priority (OffscreenLanes)

A Quick Recap

In the article "In-depth Understanding of React: Initialization Process," we analyzed the process from createRoot to updateContainer in React. We learned about event priority, which involves classifying all events and assigning each a priority level based on its characteristics. React defines four event priorities:

var DiscreteEventPriority = SyncLane; // Discrete events
var ContinuousEventPriority = InputContinuousLane; // Continuous events
var DefaultEventPriority = DefaultLane; // Default events
var IdleEventPriority = IdleLane; // Idle events

Each event priority corresponds to a lane, facilitating conversion between event priority and the lane’s priority. Now, let’s continue from our previous exploration of updateContainer.

function updateContainer(element, container, parentComponent, callback) {
    // element is <App/>, container is #root, parentComponent is null, callback is null
    var current$1 = container.current; // The first fiber node, RootFiber
    var eventTime = requestEventTime(); // Time since the page opened, not epoch time
    var lane = requestUpdateLane(current$1); // Retrieve the update priority level
    ...

    var update = createUpdate(eventTime, lane); // Create an update
    update.payload = { element: element };
    callback = callback === undefined ? null : callback;
    update.callback = callback;
    var root = enqueueUpdate(current$1, update, lane); // Add the update to the fiber tree
    if (root !== null) {
      scheduleUpdateOnFiber(root, current$1, lane, eventTime); // Start scheduling the update
    }
    return lane;
}

If we categorize React’s UI rendering, there are two main types: mounting and updating. updateContainer is the primary function used in both processes. It performs the following tasks:

  1. Retrieves the update priority level.

  2. Creates an update object.

  3. Adds the update object to the fiber’s update queue.

  4. Begins scheduling the update.

Let’s explore the requestUpdateLane function, which determines the current update priority level in React. When APIs like render, setState, setXXX, and forceUpdate are called, requestUpdateLane fetches the update priority:

function requestUpdateLane(fiber) {
    var mode = fiber.mode;
    // 1. Synchronous
    if ((mode & ConcurrentMode) === NoMode) { 
      return SyncLane;
    } 
    // 2. Transition
    var isTransition = requestCurrentTransition() !== NoTransition;
    if (isTransition) {
      ...
      return currentEventTransitionLane;
    } 
    // 3. Update Type
    var updateLane = getCurrentUpdatePriority();

    if (updateLane !== NoLane) {
      return updateLane;
    } 
    // 4. Event
    var eventLane = getCurrentEventPriority();
    return eventLane;
}

When an API related to updates is called, React first identifies whether the mode supports concurrent updates. If not, it defaults to synchronous priority. If it’s a transition, it returns the corresponding transition priority.

For example:

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

When you click the button, the global currentUpdatePriority is set to the appropriate event priority during onClick, ensuring an accurate update priority level. Some updates, like those triggered by IO or async operations, don’t have a related event and use DefaultEventPriority:

const App = ()=> {
   const [num , setNum] = useState(0)
   useEffect(()=>{
     fetchData().then(res => setNum(res))
   }, [])
}

Here, setNum() gets a DefaultLane update priority. You may wonder, why distinguish between event priority and update priority? This distinction is crucial because one event can trigger updates with different priority levels:

const App = () => {
   const [num, setNum] = useState(0);
   const [count, setCount] = useState(0);

   return (
      <div>
        <h1 id="h1">{num}</h1>
        <button onClick={() => {
          setNum(num + 1); // SyncLane
          startTransition(() => {
            setCount(count + 1); // TransitionLane
          });
        }}>
          {count}
        </button>
      </div>
    );
};

By handling priorities distinctly for events and updates, React enables more nuanced control over complex interactions, resulting in a smoother, more efficient rendering experience.

3. Update Queue

Based on our analysis of updateContainer, let’s now look at the process of creating the queue. The implementation of createUpdate is as follows:

var UpdateState = 0;
var ReplaceState = 1;
var ForceUpdate = 2;
var CaptureUpdate = 3;

function createUpdate(eventTime, lane) {
   var update = {
     eventTime: eventTime,
     lane: lane,
     tag: UpdateState, // 0
     payload: null,
     callback: null,
     next: null, // Seeing this, we can tell it’s a linked list
   };
   return update;
}

Creating an update object is straightforward, holding the priority level and timestamp of the current update, as well as the update's content (payload), which here is the <App /> component.

Next is enqueueUpdate, which focuses on adding updates to the fiber's update queue. This is a key area:

function enqueueUpdate(fiber, update, lane) {
    var updateQueue = fiber.updateQueue;
    if (updateQueue === null) {
      return null;
    }
    var sharedQueue = updateQueue.shared;
    ...
    enqueueConcurrentClassUpdate(fiber, sharedQueue, update, lane);
}

For the root fiber node we created earlier, its updateQueue is structured as follows:

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

function enqueueConcurrentClassUpdate(fiber, queue, update, lane) {
    var interleaved = queue.interleaved; // null
    if (interleaved === null) {
      update.next = update; // Indicates this is the first item
      ...
    } else {
      update.next = interleaved.next;
      interleaved.next = update;
    }
    queue.interleaved = update;
    return markUpdateLaneFromFiberToRoot(fiber, lane);
}

The basic logic here is: if interleaved currently exists, place the new update object at the head of the linked list, setting the head of the previous list as the next node in the current update, thereby forming a circular linked list. If interleaved doesn’t exist, it forms a circular linked list with a single item.

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

Why design updateQueue as a circular linked list, and one that points to the tail of the list? The primary purpose is to enhance performance. In React’s execution flow, new update objects frequently need to be added to the end of the list. In traditional linked list implementations, each addition to the end of the list requires traversing it entirely. With this structure, we can access interleaved as the last node in the list directly, while the first node is easily located since the last node’s next pointer leads to it. Once this step is complete, the next step is marking via markUpdateLaneFromFiberToRoot.

function markUpdateLaneFromFiberToRoot(sourceFiber, lane) {
    sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
    var alternate = sourceFiber.alternate;
    if (alternate !== null) {
      alternate.lanes = mergeLanes(alternate.lanes, lane);
    }
    var node = sourceFiber;
    var parent = sourceFiber.return;
    while (parent !== null) {
      parent.childLanes = mergeLanes(parent.childLanes, lane);
      alternate = parent.alternate;
      if (alternate !== null) {
        alternate.childLanes = mergeLanes(alternate.childLanes, lane);
      } 
      node = parent;
      parent = parent.return;
    }
    if (node.tag === HostRoot) {
      var root = node.stateNode;
      return root;
    } else {
      return null;
    }
}

The primary function here is marking. Each fiber node has two attributes, lanes and childLanes, representing the current priority level set by itself and its subtree’s accumulated update priority levels. Imagine a fiber tree:

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

Assume at a particular moment, Son1 triggers an update with priority level 4, and other nodes have priority levels set to 0. After marking, the result would look like this:

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

Eventually, FiberRoot is returned. The main benefit of this approach lies in optimizing task execution during subsequent operations, like building a new fiber tree. Since the process begins from the root node, React can skip nodes as it follows childLanes that don’t match the renderLanes of the current update. In this example, Son2 would be skipped, reducing unnecessary fiber node creation.

This completes the addition of the task to the update queue. Next, we move on to the actual scheduling of updates. Due to length constraints, the next article will introduce a new priority level: Task Priority.