The introduction

While the previous article described the implementation of time slicing in Concurrent mode, this article discusses another feature of Concurrent mode: update queue-jumping. Let’s start with an example:

import React from 'react'
import {useRef, useState, useEffect} from 'react'

const Item = ({i, children}) = > {
  for (let i = 0; i< 999999; i++){}return <span key={i}>{children}</span>
}

function updateFn(count) {
  return count + 2
}

export default() = > {const buttonRef = useRef(null);
  const [count, updateCount] = useState(0);

  const onClick = () = > {
    updateCount(updateFn);
  };

  useEffect(() = > {
    const button = buttonRef.current;
    setTimeout(() = > updateCount(1), 1000);
    setTimeout(() = > button.click(), 1040); } []);return (
    <div>
      <button ref={buttonRef} onClick={onClick}>Add 2</button>
      <div style={{wordWrap: 'break-word'}} >
        {Array.from(new Array(4000)).map((v, index) => (
          <Item i={index}>{count}</Item>
        ))}
      </div>
    </div>
  );
};
Copy the code

Our page renders a button and 4000 Item function components, each with a time-consuming loop. UseEffect has two setTimout timers. The first timer executes updateCount(1) with a delay of 1000 ms and the second timer executes button.click() with a delay of 1040 ms. Note that the Render phase of the 4,000 Item component updates must be well over 40 milliseconds. Let’s look at the differences between Legacy and Concurrent modes:

Legacy

Concurrent

As you can see, the numbers change from 0 to 1 and finally to 3 in Legacy mode, and from 0 to 2 and finally to 3 in Concurrent mode.

Why is there such a difference? Let’s analyze it.

Update process

Legacy mode

We know that when updateCount(1) is executed, the update is eventually performed by starting a macro task via MessageChannel that is executed before the second timer. Because the update process (Render and Commit phases) is synchronous in Legacy mode, the browser does not have time to execute the methods in the second timer until after the first update is complete, and the second timer actually fires after 40 milliseconds after the first. So it makes sense to render 1, 2, and 3 in Legacy mode.

Concurrent mode

High-priority tasks interrupt low-priority tasks

In Concurrent mode, the previous steps are the same. When updateCount(1) is executed, a macro task is initiated via MessageChannel to perform the update, but the Render phase of the update process is performed in time slices. After a certain time slice ends, the browser calls the method button.click() in the second timer and finally executes updateCount(updateFn) to produce an update. React interrupts the previous task because updates generated by user events are of higher priority:

  if(existingCallbackNode ! = =null) {
    const existingCallbackPriority = root.callbackPriority;
    if (existingCallbackPriority === newCallbackPriority) {
      // The priority hasn't changed. We can reuse the existing task. Exit.
      return;
    }
    cancelCallback(existingCallbackNode);
  }
Copy the code

Then start a new task:

    const schedulerPriorityLevel = lanePriorityToSchedulerPriority(
      newCallbackPriority,
    );
    newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root),
    );
Copy the code

React will discard the partially built Fiber Tree and build from scratch:

.// If the root or lanes have changed, throw out the existing stack
  // and prepare a fresh one. Otherwise we'll continue where we left off.
  if(workInProgressRoot ! == root || workInProgressRootRenderLanes ! == lanes) { ... prepareFreshStack(root, lanes); . }...Copy the code

Update queue processing

When we execute useState in the function component of App in the Render phase, we will finally enter the updateReducer. As the baseQueue of the current Hook still retains the data updated last time, So we’ll enter if (baseQueue! == null) this branch:

  let baseQueue = current.baseQueue;
  // The last pending update that hasn't been processed yet.
  const pendingQueue = queue.pending;
  if(pendingQueue ! = =null) {
    // We have new updates that haven't been processed yet.
    // We'll add them to the base queue.
    if(baseQueue ! = =null) {
      // Merge the pending queue and the base queue.
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }
Copy the code

BaseQueue and pendingQueue merge baseQueue and pendingQueue

Specific to our example:

Note that baseQueue and Pending point to the end of the list; their next is the head.

React then iterates through the updates from the header and skips those that do not meet the criteria:

   do {
      const updateLane = update.lane;
      if(! isSubsetOfLanes(renderLanes, updateLane)) {// Priority is insufficient. Skip this update. If this is the first
        // skipped update, the previous update/state is the new base
        // update/state.
        const clone: Update<S, A> = {
          lane: updateLane,
          action: update.action,
          eagerReducer: update.eagerReducer,
          eagerState: update.eagerState,
          next: (null: any),
        };
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        // Update the remaining priority in the queue.
        // TODO: Don't need to accumulate this. Instead, we can remove
        // renderLanes from the original lanes.
        currentlyRenderingFiber.lanes = mergeLanes(
          currentlyRenderingFiber.lanes,
          updateLane,
        );
        markSkippedUpdateLanes(updateLane);
      } else {
        // This update does have sufficient priority.
        if(newBaseQueueLast ! = =null) {
          const clone: Update<S, A> = {
            // This update is going to be committed so we never want uncommit
            // it. Using NoLane works because 0 is a subset of all bitmasks, so
            // this will never be skipped by the check above.
            lane: NoLane,
            action: update.action,
            eagerReducer: update.eagerReducer,
            eagerState: update.eagerState,
            next: (null: any),
          };
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        // Process this update.
        if (update.eagerReducer === reducer) {
          // If this update was processed eagerly, and its reducer matches the
          // current reducer, we can use the eagerly computed state.
          newState = ((update.eagerState: any): S);
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }
      }
      update = update.next;
    } while(update ! = =null&& update ! == first);if (newBaseQueueLast === null) {
      newBaseState = newState;
    } else {
      newBaseQueueLast.next = (newBaseQueueFirst: any);
    }
    // Mark that the fiber performed work, but only if the new state is
    // different from the current state.
    if(! is(newState, hook.memoizedState)) { markWorkInProgressReceivedUpdate(); } hook.memoizedState = newState; hook.baseState = newBaseState; hook.baseQueue = newBaseQueueLast; queue.lastRenderedState = newState;Copy the code

Among them! IsSubsetOfLanes (renderLanes, updateLane) determines whether to skip the current update based on the priority of renderLanes and the priority of updateLane. For example, if the current render priority is 0B11000, updates with updateLane of 0B1000 will be processed.

The skipped updates are stored in a circular linked list with a header called newBaseQueueFirst. Updates that meet the criteria will be processed to generate a new newState, and the processed updates will also be stored in the same circular linked list, except that the lane will be assigned a value of 0, so that when low-priority updates are made later, the processed updates will still be processed to ensure that the final rendered data is correct.

Finally, the new newState, newBaseState, and newBaseQueueLast are assigned to the fields corresponding to the current hook.

For example, the change process of hook corresponding to useState in our example is as follows:

As shown, we are rendering priority 8 this time, so the action 1 update is skipped, so we end up rendering memoizedState’s value of 2.

When low-priority updates restart, since the lane of the last high-priority update has been assigned a value of 0, both updates will be processed and memoizedState will be 3 so that the final result is consistent with Legacy mode.

So how do low-priority tasks get restarted?

Restart the low-priority task

We know that the Commit stage (first render process with the React source interpretation) will eventually call commitRootImpl, with a call to ensureRootIsScheduled to initiate a new update process: ensureRootIsScheduled

  // Always call this before exiting `commitRoot`, to ensure that any
  // additional work on this root is scheduled.
  ensureRootIsScheduled(root, now());
Copy the code

conclusion

In this article, we use the React 17.0.2 source code to analyze the implementation principle of high-priority updates interrupting low-priority updates in Concurrent mode.

Welcome to pay attention to the public account “front-end tour”, let us travel in the front-end ocean together.