React source code analysis series - render exception handling mechanism of react

Posted by Eamonn on Thu, 17 Feb 2022 03:42:42 +0100

Series article directory (synchronous update)

This series is all about react V17 0.0-alpha source code

Error boundaries

Before explaining the internal implementation of React, I want to start with a React API - error boundaries, which is the "tip of the iceberg" of the React exception handling mechanism.

What is the error boundary

Before React 16, React did not provide developers with API s to handle exceptions thrown during component rendering:

  • The "component rendering process" here actually refers to the jsx code segment;
  • Because of the imperative code, you can use try/catch to handle exceptions;
  • However, the React component is "declarative", and developers cannot directly use try/catch to handle exceptions in the component.

React 16 brings a new concept of error boundary, which provides developers with an ability to handle the exceptions thrown by the component dimension more finely.

The error boundary is like a firewall (it is also a bit similar in naming). We can "place" several such "firewalls" in the whole component tree. Once an exception occurs to a component, the exception will be blocked by the nearest error boundary to avoid affecting other branches of the component tree; We can also render a more "user-friendly" UI interface through error boundaries.

What components can be called error boundaries

The error boundary is also a component (only class components are supported at present), so we can insert any number of error boundaries to any position in the component tree.

Error boundary includes two API s: class component static method getDerivedStateFromError and class component member method componentDidCatch (which can also be understood as life cycle method). As long as a class component (ClassComponent) contains one or both of them, this class component will become an error boundary.

An example of posting an official React document:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state to enable the next rendering to display the degraded UI
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // You can also report the error log to the server
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // You can customize the degraded UI and render it
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children; 
  }
}

What effect can wrong boundaries achieve

In earlier versions, there was only one API for componentDidCatch, and then getDerivedStateFromError was added. These two APIs perform their respective functions.

getDerivedStateFromError

The main function of getDerivedStateFromError is to return the latest state of the current component after an exception is caught. As in the above example, set a hasError switch to control whether to display "error prompt" or normal sub component tree; This is still important, because at this time, a sub component in the sub component tree is abnormal and must be excluded from the page, otherwise it will affect the rendering of the whole component tree.

static getDerivedStateFromError(error) {
  // Update state to enable the next rendering to display the degraded UI
  return { hasError: true };
}

Since getDerivedStateFromError will be called in the render phase, no side effects should be done here; If necessary, perform the corresponding operation in the componentDidCatch life cycle method.

componentDidCatch

componentDidCatch will be called in the commit phase, so it can be used to perform side-effect operations, such as reporting error logs.

When the API getDerivedStateFromError was not available in the early stage, the state needs to be updated through the setState method in the life cycle of componentDidCatch, but it is not recommended at all now, because it has been handled in the render stage through getDerivedStateFromError. Why wait until the commit stage? This content will be described in detail below.

render exception handling mechanism of React

The reason why we give priority to the introduction of "error boundary" is that on the one hand, it is a direct developer oriented API, which can be better understood; On the other hand, React makes the render exception handling mechanism more complex in order to achieve such capability. Otherwise, it is very simple and rough to catch exceptions directly with try/catch and handle them uniformly.

How are exceptions generated

As mentioned above, the error boundary handles the exceptions thrown during component rendering. In fact, this is essentially determined by the render exception handling mechanism of React; Other asynchronous methods such as event callback method and setTimeout/setInterval will not affect the rendering of React component tree, so they are not the target of render exception handling mechanism.

What kind of exception will be caught by the render exception handling mechanism

In short, code such as render methods and function components of class components that will be executed synchronously in the render stage will be caught by the exception handling mechanism of render once an exception is thrown (whether there is an error boundary or not). Take a scenario that is often encountered in actual development:

function App() {
    return (
        <div>
            <ErrorComponent />
        </div>
    );
}

function ErrorComponent(props) {
    // If the parent component does not pass the option parameter, an exception will be thrown:
    // Uncaught TypeError: Cannot read properties of undefined (reading 'text')
    return <p>{props.option.text}</p>;
}

React.render(<App />, document.getElementById('app'));

In the render process of React, the above two function components will be executed successively, and when it is executed to props foo. Text will throw an exception. The following is the executable js code formed after the jsx code of < errorcomponent / > is translated:

function ErrorComponent(props) {
  return /*#__PURE__*/Object(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__["jsxDEV"])("p", {
    children: props.foo.text // Throw exception
  }, void 0, false, { // debug related, can be ignored
    fileName: _jsxFileName,
    lineNumber: 35,
    columnNumber: 10
  }, this);
}

The specific location of the exception thrown by the component itself

The following content requires you to have a certain understanding of the render process of React. Please read the "React source code analysis series - render phase of React (II): beginWork" first

stay beginWork Method, if it is judged that the current Fiber node cannot be bailout (pruning), then the Fiber child node will be created / updated:

switch (workInProgress.tag) {
  case IndeterminateComponent: 
    // ... ellipsis
  case LazyComponent: 
    // ... ellipsis
  case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
  case ClassComponent: {
    const Component = workInProgress.type;
    const unresolvedProps = workInProgress.pendingProps;
    const resolvedProps =
      workInProgress.elementType === Component
        ? unresolvedProps
        : resolveDefaultProps(Component, unresolvedProps);
    return updateClassComponent(
      current,
      workInProgress,
      Component,
      resolvedProps,
      renderLanes,
    );
  }
  case HostRoot:
    // ... ellipsis
  case HostComponent:
    // ... ellipsis
  case HostText:
    // ... ellipsis
  // ... Omit other types
}
Location of ClassComponent throwing exception

From the above code segment beginWork, we can see that the updateclclasscomponent method is executed and a parameter named Component is passed in. This parameter is actually the class of the class Component. At this time, the render method has not been executed, so no exception has been thrown.

Follow updateClassComponent , we can see that finishClassComponent is executed to create Fiber child nodes:

function updateClassComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes,
) {
    // ellipsis
    const nextUnitOfWork = finishClassComponent(
      current,
      workInProgress,
      Component,
      shouldUpdate,
      hasContext,
      renderLanes,
    );
    // ellipsis
}

stay finishClassComponent In, we can see nextChildren = instance Render(), where instance is the instantiated class object. After calling the render member method, you get the ReactElement object nextChildren.

In the subsequent process, React will create / update the Fiber child node according to this ReactElement object, but this is not the concern of this article; What we are concerned about is that if the render member method is executed here, it is possible to throw the exception targeted by the React exception handling mechanism.

Location of exception thrown by FunctionComponent

Next, let's locate the location of throwing exceptions with functional component: with the experience of ClassComponent, we follow it all the way updateFunctionComponent reach renderWithHooks , in this method, we can see let children = Component(props, secondArg), The Component here is the function itself of the function Component, and children is the ReactElement object obtained after the function Component is executed.

How to catch render exceptions

When we are asked "how to catch exceptions", we instinctively answer "try/catch". How does React catch the exceptions thrown during the rendering process of this component?

  • In the production environment, React uses try/catch to catch exceptions
  • In the development environment, React does not use try/catch, but implements a more elaborate capture mechanism

Why not use try/catch directly

React originally directly used try/catch to catch render exceptions. As a result, it received a large number of requests issue , the details are as follows:

  • Chrome devtools has a function called Pause on exceptions, which can quickly locate the code where exceptions are thrown, and the effect is equivalent to hitting a breakpoint on the line of code; However, only uncapped exceptions can be located using this method
  • Developers complained that they could not locate the code throwing exceptions during the rendering of React components through Chrome devtools
  • Someone found that as long as you open pause on cause exceptions, you can locate the code location where the exception is thrown; After this function is enabled, even if the exception is captured, it can be located to the target location, so it can be judged that React has "swallowed" the exception

To solve this problem, React needs to provide a set of exception capture schemes that meet the following conditions:

  • After the exception is captured, it still needs to be handled by the boundary
  • Do not use try/catch to avoid affecting the Pause on exceptions function of Chrome devtools

How to catch render exceptions without using try/catch

When JavaScript runtime errors (including syntax errors) occur, window will trigger an error event of the erroreevent interface and execute window onerror() .

The above description comes from MDN GlobalEventHandlers.onerror This is the method of catching exceptions except try/catch: we can attach a callback processing method to the error event of the window object, so as long as any javascript code on the page throws an exception, we can catch it.

But if you do so, don't you also catch many exceptions that have nothing to do with the rendering process of React components? In fact, we only need to listen to the error event before rendering the React component, and cancel listening to the event after rendering the component:

function mockTryCatch(renderFunc, handleError) {
    window.addEventListener('error', handleError); // handleError can be understood as an entry method to enable "error boundary"
    renderFunc();
    window.removeEventListener('error', handleError); // Eliminate side effects
}

Is that how it's done? wait a moment! The original intention of this scheme is to replace the previously used try/catch code in the development environment, but now there is an important feature of try/catch that has not been restored: after catching exceptions, try/catch will continue to execute code other than try/catch:

try {
    throw 'abnormal';
} catch () {
    console.log('Exception caught!')
}
console.log('Continue normal operation');

// The result of running the above code is:
// Exception caught!
// Continue normal operation

If you use the above mockTryCatch to try to replace try/catch:

mockTryCatch(() => {
    throw 'abnormal';
}, () => {
    console.log('Exception caught!')
});
console.log('Continue normal operation');

// The result of running the above code is:
// Exception caught!

Obviously, mockTryCatch cannot completely replace try/catch, because after mockTryCatch throws an exception, the execution of subsequent synchronization code will be forcibly terminated.

How to not affect subsequent code execution like try/catch

There are always various routines in the front-end field, which really makes React developers find such a way: EventTarget.dispatchEvent ; So why does dispatchEvent simulate and replace try/catch?

dispatchEvent can execute code synchronously

Unlike browser native events, native events are dispatched by DOM and call the event handler asynchronously through event loop, while dispatchEvent () calls the event handler synchronously. After calling dispatchEvent(), all event handlers listening to this event will execute and return before the code continues.

The above is from the MDN document of dispatchEvent. It can be seen that dispatchEvent can execute code synchronously, which means that the subsequent code execution of dispatchEvent can be blocked before the execution of event processing method is completed. At the same time, this is also one of the characteristics of try/catch.

The exception thrown by dispatchEvent does not bubble

These event handlers run in a nested call stack: they block calls until they are processed, but exceptions do not bubble.

To be precise, it is: the event callback method triggered by dispatchEvent will not bubble; This means that even if an exception is thrown, it will only terminate the execution of the event callback method itself, and the code in the context of dispatchEvent() will not be affected. Write a DEMO to verify this feature:

function cb() {
  console.log('Start callback');
  throw 'dispatchEvent The event handler of threw an exception';
  console.log('You can't get here');
}

/* Prepare a virtual event */
const eventType = 'this-is-a-custom-event';
const fakeEvent = document.createEvent('Event');
fakeEvent.initEvent(eventType, false, false);

/* Prepare a virtual DOM node */
const fakeNode = document.createElement('fake');
fakeNode.addEventListener(eventType, cb, false); // mount 

console.log('dispatchEvent Before execution');
fakeNode.dispatchEvent(fakeEvent);
console.log('dispatchEvent After execution');

// The result of running the above code is:
// Before dispatchEvent execution
// Start callback
// The event handler of Uncaught dispatchEvent threw an exception
// After dispatchEvent is executed

It can be seen from the above DEMO that although the event handler of dispatchEvent threw an exception, it can still continue to execute the subsequent code of dispatchEvent (i.e. console.log() in DEMO).

Implement a simple version of render exception catcher

Next, let's put globaleventhandlers Onerror and EventTarget By combining dispatchEvent, a simple version of render exception catcher can be implemented:

function exceptionCatcher(func) {
    /* Prepare a virtual event */
    const eventType = 'this-is-a-custom-event';
    const fakeEvent = document.createEvent('Event');
    fakeEvent.initEvent(eventType, false, false);

    /* Prepare a virtual DOM node */
    const fakeNode = document.createElement('fake');
    fakeNode.addEventListener(eventType, excuteFunc, false); // mount 

    window.addEventListener('error', handleError);
    fakeNode.dispatchEvent(fakeEvent); // Trigger execution target method
    window.addEventListener('error', handleError); // Eliminate side effects
    
    function excuteFunc() {
        func();
        fakeNode.removeEventListener(evtType, excuteFunc, false); 
    }
    
    function handleError() {
        // Leave the exception to the error boundary for handling
    }
}
}

How to catch render exception in React source code

The above describes the principle of capturing render exceptions, and also implements a simple version of DEMO. Now you can analyze the React source code in detail.

Capture target: beginWork

As mentioned above, the exception of the render dimension of the React component is thrown in the beginWork phase, so the goal of catching the exception is obviously beginWork Yes.

Packaging beginWork

React encapsulates the beginWork method for the development environment and adds the function of capturing exceptions:

  1. Before executing bginWork, first "back up" the attributes of the current Fiber node (unitOfWork) and copy it to a Fiber node specially used for "backup".
  2. Execute beginWork and use try/catch to catch exceptions; You may be puzzled when you see this. Don't you say you don't need to try/catch to catch exceptions? How can this be used again? Let's keep looking down.
  3. If beginWork throws an exception, it will be caught naturally, and then execute the code snippet of catch:

    1. Restore the current Fiber node (unitOfWork) from the backup to the state before beginWork.
    2. In the current Fiber node, call the invokeGuardedCallback method to perform beginWork again. This invokeGuardedCallback method will apply the globaleventhandlers mentioned above Onerror and EventTarget DispatchEvent combines methods to catch exceptions.
    3. Re throw the caught exception, and you can handle the exception later; Although an exception is thrown here and will be caught by the outer try/catch, this will not affect the Pause on exceptions function, because the exception generated in the invokeGuardedCallback method is not caught by the outer try/catch.

let beginWork;
if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
    // Development environment will come to this branch
    beginWork = (current, unitOfWork, lanes) => {
        /*
            Copy all the attributes of the current Fiber node (unitOfWork) to an additional Fiber node (dummy Fiber)
            This dummy Fiber node is only used as a backup and will never be inserted into the Fiber tree
         */
        const originalWorkInProgressCopy = assignFiberPropertiesInDEV(
          dummyFiber,
          unitOfWork,
        );
        try {
          return originalBeginWork(current, unitOfWork, lanes); // Execute the real beginWork method
        } catch (originalError) {
            // ... ellipsis
            
            // Restore the current Fiber node (unitOfWork) from the backup to the state before beginWork
            assignFiberPropertiesInDEV(unitOfWork, originalWorkInProgressCopy);
            
            // ... ellipsis
            
            // Execute beginWork on the current Fiber node again. Here is the focus of this article on catching exceptions
            invokeGuardedCallback(
              null,
              originalBeginWork,
              null,
              current,
              unitOfWork,
              lanes,
            );

            // Re throw the caught exception. You can handle the exception later, as described below
        }
    };
} else {
    // Production environment will come to this branch
    beginWork = originalBeginWork;
}
invokeGuardedCallback

Next, let's look at the invokeGuardedCallback method. This method is not the core. It is similar to its reactorrutils Other methods in JS file form a tool for "saving / retrieving" exceptions. The core of our attention is the invokeGuardedCallbackImpl method.

let hasError: boolean = false;
let caughtError: mixed = null;

const reporter = {
  onError(error: mixed) {
    hasError = true;
    caughtError = error;
  },
};

export function invokeGuardedCallback<A, B, C, D, E, F, Context>(
  name: string | null,
  func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,
  context: Context,
  a: A,
  b: B,
  c: C,
  d: D,
  e: E,
  f: F,
): void {
  hasError = false;
  caughtError = null;
  invokeGuardedCallbackImpl.apply(reporter, arguments);
}
invokeGuardedCallbackImpl

The invokeGuardedCallbackImpl is also divided into the implementation of production environment and development environment. We only look at the implementation of development environment:

invokeGuardedCallbackImpl = function invokeGuardedCallbackDev<
  A,
  B,
  C,
  D,
  E,
  F,
  Context,
>(
  name: string | null,
  func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,
  context: Context,
  a: A,
  b: B,
  c: C,
  d: D,
  e: E,
  f: F,
) {
  // Omit
  
  const evt = document.createEvent('Event'); // Create custom event

  // Omit

  const windowEvent = window.event;

  // Omit

  function restoreAfterDispatch() {
    fakeNode.removeEventListener(evtType, callCallback, false); // Cancel listening for custom events and eliminate side effects
    // Omit
  }

  const funcArgs = Array.prototype.slice.call(arguments, 3); // Get the parameters that need to be passed to beginWork
  function callCallback() {
    // Omit
    restoreAfterDispatch();
    func.apply(context, funcArgs); // Execute beginWork
    // Omit
  }

  function handleWindowError(event) {
    error = event.error; // Exception caught
    // Omit
  }

  // Custom event name
  const evtType = `react-${name ? name : 'invokeguardedcallback'}`;

  window.addEventListener('error', handleWindowError);
  fakeNode.addEventListener(evtType, callCallback, false);

  evt.initEvent(evtType, false, false); // Initialize a custom event
  fakeNode.dispatchEvent(evt); // Triggering a custom event can also be considered as triggering the synchronous execution of beginWork

  // Omit
  this.onError(error); // Send the caught exception to the outer layer for processing

  window.removeEventListener('error', handleWindowError); // Cancel the listening of error event and eliminate side effects
};

The above is my simplified invokeGuardedCallbackImpl method. Is it almost the same as the simple React exception catcher we implemented above? Of course, this method also includes the handling of many exceptions. These exceptions are proposed by issues and then handled in a "patching" way, such as missing document s in the test environment, or encountering cross origin errors, which will not be described in detail here.

Handling exceptions

The above describes how exceptions are generated and how exceptions are captured. Here is a brief introduction to how exceptions are handled after they are captured:

  1. Start from the Fiber node throwing the exception and traverse to the root node to find the error boundary that can handle this exception; If it cannot be found, it can only be handed over to the root node to handle the exception.
  2. If the exception is handled by the error boundary, create an update task with payload as the state value returned after the execution of getDerivedStateFromError method and callback as componentDidCatch; If the root node handles the exception, an update task is created to unload the entire component tree.
  3. Enter the render process of the node handling the exception (i.e. performnunitofwork), in which the update task just created will be executed.
  4. Finally, if the exception is handled by the error boundary, the sub component tree with the exception Fiber node will be unloaded according to the change of the error boundary state, and the UI interface with friendly exception prompt will be rendered instead; If the root node handles the exception, the entire component tree will be unloaded, resulting in a white screen.

Source code implementation of exception handling in React

As mentioned above, in the encapsulated beginWork, the exception caught by invokeGuardedCallback will be thrown again. Where will this exception be intercepted? The answer is renderRootSync:

do {
  try {
    workLoopSync(); // beginWork will be called in workLoopSync
    break;
  } catch (thrownValue) {
    handleError(root, thrownValue); // Handling exceptions
  }
} while (true);
handleError

Let's introduce it handleError :

  • handleError is another commonly used do in React While (true) is an endless loop structure, so what conditions can be met to exit the loop?
  • In the loop body, there is a try/catch code segment. Once the exception thrown by the code segment in the try is stopped by the catch, it will fall back to the parent node of the current node (the old routine of React) and continue to try; If no exception is thrown during an execution, the loop can be ended, that is, the whole handleError method can be ended.
  • In the try code segment, three sections of logic are mainly executed:

    1. Judge whether the current node or the parent node of the current node is null. If so, it indicates that it may be in the Fiber root node, and there can be no error boundary. It can handle exceptions directly as fatal exceptions and end the current method.
    2. Execute the throwException method and traverse to find a node (error boundary) that can handle the current exception, which will be described in detail below.
    3. Execute completeUnitOfWork, which is one of the most important methods in the render process, but here is mainly to execute the code branch on exception handling, which will be described in detail below.

function handleError(root, thrownValue): void {
  do {
    let erroredWork = workInProgress;
    try {
      // Reset some states modified in the render process, omit

      if (erroredWork === null || erroredWork.return === null) {
        // If you go to this branch, it indicates that you may be at the Fiber root node, and there is no error boundary. You can handle exceptions directly as fatal exceptions
        workInProgressRootExitStatus = RootFatalErrored;
        workInProgressRootFatalError = thrownValue;
        workInProgress = null;
        return;
      }

      // Omit
      
      // throwException is the key, which is described below
      throwException(
        root,
        erroredWork.return,
        erroredWork,
        thrownValue, // Exception itself
        workInProgressRootRenderLanes,
      );
      completeUnitOfWork(erroredWork); // Fiber node handling exception (erroredWork)
    } catch (yetAnotherThrownValue) {
      // If the above code segment still fails to handle the current abnormal Fiber node (erroredWork) - or throws an exception, try to handle it with the Fiber parent node of the abnormal node (erroredWork)
      // This is a circular process, which traverses the parent node upward until it finds the Fiber node that can handle the exception, or reaches the Fiber root node (it is determined that the error free boundary can handle the current exception)
      thrownValue = yetAnotherThrownValue;
      if (workInProgress === erroredWork && erroredWork !== null) {
        erroredWork = erroredWork.return;
        workInProgress = erroredWork;
      } else {
        erroredWork = workInProgress;
      }
      continue;
    }
    return;
  } while (true);
}
throwException

Let's introduce it throwException , throwException mainly does the following things:

  • Mark the Fiber node that throws the exception with the Incomplete EffectTag, and then go to the code branch of exception handling according to the Incomplete ID.
  • Start from the parent node of the current Fiber node throwing exceptions, traverse to the root node, and find a node that can handle exceptions; At present, only the error boundary and Fiber root node can handle exceptions; According to the traversal direction, if there is an error boundary in the traversal path, the error boundary will be found first, that is, give priority to the error boundary to handle exceptions.

    • "Criteria for judging error boundary" can be reflected here: it must be a ClassComponent and contain getDerivedStateFromError and componentDidCatch or one of them.
  • After finding the node that can handle exceptions, different code branches will be executed according to different types, but the general idea is the same:

    1. Mark the node with the EffectTag of ShouldCapture, and then go to the code branch of exception handling according to this EffectTag.
    2. Create a new update task for the current exception, and find a lane with the highest priority for the update task to ensure that it will be executed during this render; The error boundary will call the createRootErrorUpdate method to create the update task, while the root node will call the createRootErrorUpdate method to create the update task. These two methods will be described in detail below.
function throwException(
  root: FiberRoot,
  returnFiber: Fiber,
  sourceFiber: Fiber,
  value: mixed, // Exception itself
  rootRenderLanes: Lanes,
) {
  // Mark the Fiber node of the current exception with the Incomplete EffectTag, and then go to the code branch of exception handling according to the Incomplete ID
  sourceFiber.effectTag |= Incomplete;
  sourceFiber.firstEffect = sourceFiber.lastEffect = null;

  // A large section of processing for the suspend scene, omitting

  renderDidError(); // Set workInProgressRootExitStatus to RootErrored

  value = createCapturedValue(value, sourceFiber); // Obtain the complete node path from the Fiber root node to the exception node and mount it on the exception node for subsequent printing
  /*
    Try to traverse the direction of the parent node of the exception node to find an error boundary that can handle the exception. If it cannot be found, it can only be handed over to the root node
    Note that this is not from the exception node, so even if the exception node itself is the error boundary, it cannot handle the current exception
   */
  let workInProgress = returnFiber;
  do {
    switch (workInProgress.tag) {
      case HostRoot: {
        // Entering this code branch means that we can't find an error boundary that can handle this exception. We can only let the Fiber root node handle the exception
        // Mark the node with ShouldCapture's EffectTag, and then go to the code branch of exception handling according to this EffectTag
        const errorInfo = value;
        workInProgress.effectTag |= ShouldCapture;
        // Create a new update task for the current exception, and find a lane with the highest priority for the update task to ensure that it will be executed during this render
        const lane = pickArbitraryLane(rootRenderLanes);
        workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
        const update = createRootErrorUpdate(workInProgress, errorInfo, lane); // The key points are described below
        enqueueCapturedUpdate(workInProgress, update);
        return;
      }
      case ClassComponent:
        const errorInfo = value;
        const ctor = workInProgress.type;
        const instance = workInProgress.stateNode;
        
        // Judge whether the node is an error boundary
        if (
          (workInProgress.effectTag & DidCapture) === NoEffect &&
          (typeof ctor.getDerivedStateFromError === 'function' ||
            (instance !== null &&
              typeof instance.componentDidCatch === 'function' &&
              !isAlreadyFailedLegacyErrorBoundary(instance)))
        ) {
          // Determine that the node is an error boundary
          // Mark the node with ShouldCapture's EffectTag, and then go to the code branch of exception handling according to this EffectTag
          workInProgress.effectTag |= ShouldCapture; 
          // Create a new update task for the current exception, and find a lane with the highest priority for the update task to ensure that it will be executed during this render
          const lane = pickArbitraryLane(rootRenderLanes);
          workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
          const update = createClassErrorUpdate( // The key points are described below
            workInProgress,
            errorInfo,
            lane,
          );
          enqueueCapturedUpdate(workInProgress, update);
          return;
        }
        break;
      default:
        break;
    }
    workInProgress = workInProgress.return;
  } while (workInProgress !== null);
}
createRootErrorUpdate and createClassErrorUpdate

Called when a fatal exception that can be handled by an error free boundary is encountered createRootErrorUpdate Method to create a status update task, which will set the root node to null, that is, unload the whole react component tree. React officials believe that instead of rendering an abnormal interface to mislead users, it is better to directly display the white screen; I cannot deny the official idea, but I affirm the importance of the wrong boundary.

function createRootErrorUpdate(
  fiber: Fiber,
  errorInfo: CapturedValue<mixed>,
  lane: Lane,
): Update<mixed> {
  const update = createUpdate(NoTimestamp, lane, null);
  update.tag = CaptureUpdate;
  // Set the root node to null, that is, uninstall the whole React component tree
  update.payload = {element: null};
  const error = errorInfo.value;
  update.callback = () => {
    // Print error message
    onUncaughtError(error);
    logCapturedError(fiber, errorInfo);
  };
  return update;
}

When the current boundary is found, it can be handled when there is an exception createClassErrorUpdate Method to create a status update task. The payload of the update task is the result returned after the execution of getDerivedStateFromError. In the callback of the update task, the componentDidCatch method is executed (usually used to perform some operations with side effects).

function createClassErrorUpdate(
  fiber: Fiber,
  errorInfo: CapturedValue<mixed>,
  lane: Lane,
): Update<mixed> {
  const update = createUpdate(NoTimestamp, lane, null);
  update.tag = CaptureUpdate;
  // Note that getDerivedStateFromError here is a static method that takes the class component itself
  const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
  if (typeof getDerivedStateFromError === 'function') {
    const error = errorInfo.value;
    // In the newly created state update task, set state to the result returned after the getDerivedStateFromError method is executed
    update.payload = () => {
      logCapturedError(fiber, errorInfo);
      return getDerivedStateFromError(error);
    };
  }

  const inst = fiber.stateNode;
  if (inst !== null && typeof inst.componentDidCatch === 'function') {
    // Set the callback of the update task
    update.callback = function callback() {
      // Omit
      if (typeof getDerivedStateFromError !== 'function') {
        // Compatible with earlier versions of error boundary, there was no API getDerivedStateFromError at that time
        // Omit
      }
      const error = errorInfo.value;
      const stack = errorInfo.stack;
      // The componentDidCatch member method of the execution class component is usually used to perform some operations with side effects
      this.componentDidCatch(error, {
        componentStack: stack !== null ? stack : '',
      });
      // Omit
    };
  }
  // Omit
  return update;
}
completeUnitOfWork

Having finished the throwException above, let's continue to look at the last step in the handleError method - completeUnitOfWork. This method will handle the Fiber node of the exception. In the exception scenario, the only parameter of this method is the Fiber node that throws the exception.

function handleError(root, thrownValue): void {
  do {
    let erroredWork = workInProgress;
    try {
      // Omit
      
      // throwException is the key, which is described below
      throwException(
        root,
        erroredWork.return,
        erroredWork,
        thrownValue, // Exception itself
        workInProgressRootRenderLanes,
      );
      completeUnitOfWork(erroredWork); // The Fiber node that threw the exception (erroredWork)
    } catch (yetAnotherThrownValue) {
        // Omit
    }
    return;
  } while (true);
}

In the previous article, we have introduced completeUnitOfWork Method, but it introduces the normal process, which directly ignores the exception handling process. Let's add this one below:

  • completeUnitOfWork is a bit like the throwException described above. It traverses from the current Fiber node (in the exception scenario, it refers to the node throwing exceptions) to the root node to find a node that can handle exceptions; Because completeUnitOfWork includes both normal process and exception handling process, it enters the code branch of exception handling through the EffectTag of Incomplete.
  • Once a Fiber node that can handle exceptions is found, it will be set as the loop body (workInProgres) of the next round of work (performnitofwork), and then the completeUnitOfWork method will be terminated immediately; Later, we will return to performinutofwork and enter the beginWork phase of the Fiber node (which can handle exceptions).
  • In the process of traversal, if it is found that the current node cannot handle exceptions, it will also mark Incomplete for the parent node of the current node to ensure that the parent node will also enter the code branch of exception handling.
  • The logic for the sibling node in completeUnitOfWork does not distinguish whether it is a normal process, which I am a little surprised: if the current node has an exception, even if its sibling node is normal, it will be re rendered in the subsequent exception handling process. At this time, why render its sibling node; But on the contrary, this will not cause problems, because when the sibling node returns to the parent node in completeUnitOfWork, because the parent node has been set to Incomplete, it will still follow the exception handling process.

Here's another question: why re render nodes that can handle exceptions? We can actually guess what React does without looking at the subsequent operations: assuming that the node that can handle exceptions is an error boundary, in the throwException described above, an update task has been created according to the state value returned after getDerivedStateFromError is executed, then we only need to update the state of the error boundary, According to the state, unload the components that throw exceptions and render the components with error prompts. Isn't this a normal render process.

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork; // unitOfWork here refers to the Fiber node that throws an exception
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    // Judge whether the current Fiber node is marked with the EffectTag of Incomplete
    if ((completedWork.effectTag & Incomplete) === NoEffect) {
    // Normal Fiber node processing flow, omit
    } else {
      // Whether the current Fiber node is marked with the effect tag of Incomplete, that is, the current Fiber node failed to complete the render process due to an exception and tried to enter the process of handling the exception

      // Judge whether the current Fiber node (completeWork) can handle exceptions, and assign it to the next variable if possible
      const next = unwindWork(completedWork, subtreeRenderLanes);

      // Because this fiber did not complete, don't reset its expiration time.

      if (next !== null) {
        // It is found that the current Fiber node can handle exceptions and set it as the loop body (workInProgres) of the next round of work (performinunitofwork),
        // Then immediately terminate the current completeWork phase, and then enter the beginWork phase of the current Fiber node (the "delivery" phase of render)
        next.effectTag &= HostEffectMask;
        workInProgress = next;
        return;
      }

      // Omit

      // Going to this branch means that the current Fiber node (completeWork) cannot handle exceptions,
      // Therefore, the Fiber parent node is also marked with Incomplete EffectTag. Later, we will continue to try to enter the process of handling exceptions
      if (returnFiber !== null) {
        // Mark the parent fiber as incomplete and clear its effect list.
        returnFiber.firstEffect = returnFiber.lastEffect = null;
        returnFiber.effectTag |= Incomplete;
      }
    }

    // Processing the sibling node of the current Fiber node can normally enter the beginWork phase of the sibling node
    // Subsequently, we will continue to judge whether the exception can be handled by fallback to the parent node through the completeUnitOfWork of the sibling node
    
    // Go back to the parent node in the current loop and continue to try to enter the process of handling exceptions
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
  // Omit
}
unwindWork

Here is how the unwindWork method judges whether the current Fiber node (completeWork) can handle exceptions:

  • According to completework Tag refers to the Fiber node type. Only four types of Fiber node types, classcomponent / hostroot / suspendecomponent / dehydredsuspendecomponent, can handle exceptions
  • According to completework To judge whether the EffectTag contains ShouldCapture, the EffectTag is described above throwException Method.

In the unwindWork method, once it is judged that the current Fiber node can handle exceptions, its ShouldCapture will be cleared and the EffectTag of DidCapture will be added, which will also become the judgment standard for subsequent exception handling.

function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
  switch (workInProgress.tag) {
    case ClassComponent: {
      // Omit
      const effectTag = workInProgress.effectTag;
      // Judge whether the EffectTag ShouldCapture is included
      if (effectTag & ShouldCapture) {
        // Determine whether the current Fiber node can handle exceptions, that is, determine the error boundary
        // Clear the ShouldCapture of the current Fiber node and add the EffectTag of DidCapture 
        workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
        // Omit
        return workInProgress;
      }
      return null;
    }
    case HostRoot: {
      // Entering the current code branch means that there is no error boundary in the current Fiber tree that can handle this exception
      // Therefore, it is left to the Fiber root node to handle exceptions uniformly
      // Omit
      const effectTag = workInProgress.effectTag;
      // Omit
      // Clear ShouldCapture of Fiber root node and add EffectTag of DidCapture 
      workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
      return workInProgress;
    }
    // Omit
    default:
      return null;
  }
}
Re render error boundary Fiber node

In the completeUnitOfWork method, we use do The while loop cooperates with the unwindWork method to find the error boundary node that has been marked in the throwException method to handle the current exception; Assuming that there is such an error boundary node, the completeUnitOfWork method will be ended and then enter the second render of the node:

workLoopSync --> performUnitOfWork --> beginWork --> updateClassComponent -> updateClassInstance / finishClassComponent

The above is a normal process of render ing a ClassComponent. First of all, we need to pay attention to it updateClassInstance , in this method, the state of the node will be updated according to the update task of the current node; Remember in createClassErrorUpdate Is an update task created according to the state value returned by the static method getDerivedStateFromError of the class component in? The update task is also given the highest priority: pickarbitrrylane (rootrenderlanes). Therefore, the update classinstance will update the state according to the update task (that is, the state value returned by getDerivedStateFromError).

Then we enter finishClassComponent In the logic of the method, this method actually does two things for exception handling:

  1. API compatible with old version error boundary

    • The basis for judging whether it is an old version error boundary is: whether there is a static method such as getDerivedStateFromError in the ClassComponent of the current node; In the error boundary of the old version, there is no API getDerivedStateFromError. The unified method is to initiate setState() in componentDidCatch to modify the state,
    • The compatible method is: in the process of this render, set nextChildren to null, that is, unload all child nodes, so as to avoid throwing exceptions in this render; In the commit phase, the callback of the update task, that is, componentDidCatch, will be executed, and a new round of render can be initiated at that time.
  2. The forced re creation of child nodes is not different from the normal logical call reconcileChildren, but some small steps are made to prohibit the reuse of child nodes in the current tree, which will be described in detail below.
function finishClassComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  shouldUpdate: boolean,
  hasContext: boolean,
  renderLanes: Lanes,
) {
  // Omit
  // Judge whether there is an EffectTag of DidCapture. If there is an EffectTag, it indicates that the current Fiber node is the error boundary for handling exceptions
  const didCaptureError = (workInProgress.effectTag & DidCapture) !== NoEffect;

  // Normal render process code branch, omit

  const instance = workInProgress.stateNode;
  ReactCurrentOwner.current = workInProgress;
  let nextChildren;
  if (
    didCaptureError &&
    typeof Component.getDerivedStateFromError !== 'function'
  ) {
    // If it is currently the error boundary for handling exceptions, but the getDerivedStateFromError method is not defined, enter the branch of this code
    // This code branch is mainly to be compatible with the API of the old version of the error boundary. In the old version of the error boundary, the componentDidCatch initiates setState() to modify the state
    // The compatible method is to set nextChildren to null in this render process, that is, unload all child nodes, so as to avoid throwing exceptions in this render
    nextChildren = null;
    // Omit
  } else {
    // Normal render process code branch, omit
  }

  // Omit
  
  if (current !== null && didCaptureError) {
    // Force the re creation of child nodes, and prohibit the reuse of child nodes on the current tree;
    forceUnmountCurrentAndReconcile(
      current,
      workInProgress,
      nextChildren,
      renderLanes,
    );
  } else {
    // Normal render process code branch
    reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  }

  // Omit

  return workInProgress.child;
}
How to force a child node to be re rendered

When introducing finishClassComponent, we mentioned that you can use forceUnmountCurrentAndReconcile The method in childrens is called twice, but it is very similar to that in childrens:

  1. When reconcileChildFibers is called for the first time, the parameter that should be passed to the "child node ReactElement object" will be changed to null, which is equivalent to unloading all child nodes; In this way, all child nodes on the current tree will be marked with "deleted" EffectTag.
  2. When reconcileChildFibers is called the second time, the parameter that should have been passed to "corresponding child node on current tree" will be changed to null; In this way, it can be ensured that all child nodes of the current node (error boundary) are newly created after this render, and the current tree node will not be reused

As for why we should do this, the official explanation of React is that "conceptually, two sets of UI are different when handling exceptions and normal rendering, and any child node should not be reused (even if the characteristics of the node - key/props, etc.) are the same"; To understand it simply, it is "one size fits all" to avoid reuse to abnormal Fiber nodes.

function forceUnmountCurrentAndReconcile(
  current: Fiber,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  // Only when render handles the error boundary of the exception can it enter the current method; Of course, reconcileChildFibers will be executed under normal logic
  // When handling exceptions, you should refuse to reuse the corresponding current child nodes in the current tree to avoid reusing to the abnormal child nodes; reconcileChildFibers is called twice for this purpose
  
  // When reconcileChildFibers is called for the first time, the parameters that should be passed to the child node ReactElement object will be changed to null
  // In this way, all child nodes in the current tree will be marked with "deleted" EffectTag
  workInProgress.child = reconcileChildFibers(
    workInProgress,
    current.child,
    null,
    renderLanes,
  );

  // The second call to reconcileChildFibers will change the parameters that should have been passed to the corresponding child nodes in the current tree to null
  // This will ensure that all child nodes after this render are newly created and will not be reused
  workInProgress.child = reconcileChildFibers(
    workInProgress,
    null,
    nextChildren,
    renderLanes,
  );
}

Write at the end

The above is the introduction to the exception handling mechanism of React render. Through this article, we have filled the omissions in the previous articles on render (the previous article only introduces the normal process of render). Let's "know" the exception handling in the development process, and quickly add some error boundaries to your application (laugh).

Topics: Javascript Front-end React source code