preface

The background of this article is as follows: I’ve been doing front-end error monitoring for a couple of days, and then SOMEWHERE in the code, I’m leaving console.log(XXX), where XXX is an undefined variable. Expect the error boundary to catch the corresponding error, so as to render the alternate UI, of course, according to the general routine of the story, the result is definitely not what I expected, so I have this article, otherwise wouldn’t it be the end? Haha haha ~~ Then I will lead you to explore the applicable conditions and inapplicable scenes of ErrorBoundary. Without further ado, let’s start

What are Error Boundaries?

Here’s how it works:

JavaScript errors in part of the UI should not crash the entire app, and React 16 introduced a new concept called error boundaries to address this issue. The error boundary is a React component that catches JavaScript errors that occur anywhere in the child component tree and prints those errors while displaying the degraded UI without rendering the child component tree that crashed. Error bounds catch errors during rendering, in lifecycle methods, and in constructors throughout the component tree.

ErrorBoundary is essentially a class component with an instance method componentDidCatch or a static method getDerivedStateFromError.

The template code is as follows (all our subsequent examples will use the ErrorBoundary component and the subsequent code will not be posted) :

class ErrorBoundary extends Component {
  state = { error: null }
  // 1. Pass componentDidCatch
  componentDidCatch(error: any, errorInfo: any) {
    this.setState({ error })
    console.log('Error caught', error, errorInfo)
  }
  // 2. Run static getDerivedStateFromError
  //static getDerivedStateFromError(error: Error) {
  // return { error }
  / /}
  render() {
    if (this.state.error) {
      return <div>I'm the standby UI</div>
    }

    return this.props.children
  }
}

function App() {
  return (
    <ErrorBoundary>
      <Child/>
    </ErrorBoundary>
  );
}
Copy the code

Pay special attention to the bold part above. We will explore the conditions and scenarios where error boundaries apply and don’t, using multiple examples and combining the source code.

An error was reported during rendering

An error occurred during component render, such as:

  • A null pointer error is reported when an object property is read but the corresponding object is null or undefined
  • A variable that does not exist is declared, and an error is reported when the corresponding code executes
function Child() {
  // Uncaught ReferenceError: xxx is not defined
  console.log(xxx)
  return <div>child</div>;
}
function App() {
  return (
    <ErrorBoundary>
      <Child/>
    </ErrorBoundary>
  );
}
Copy the code

Simple source code parsing

The corresponding source code is in the process of constructing the component tree (essentially fiber tree) :

do {
  try {
    // The process of constructing fiber tree
    workLoopConcurrent();
    break;
  } catch(thrownValue) { handleError(root, thrownValue); }}while (true);
Copy the code

When the Child’s console.log(XXX) throws an error, it is caught and goes into handleError. HandleError contains the current Fiber, which corresponds to the above example of WIPFiber corresponding to Child (WIP stands for workInProgress). Fiber, until the parent component is a class component with a componentDidCatch or static method getDerivedStateFromError. The parent component is then ErrorBoundary.

// Error bounds are class components
case ClassComponent:
      // An error message was reported
      const errorInfo = value;
      / / ErrorBoundary class
      const ctor = workInProgress.type;
      / / ErrorBoundary instance
      const instance = workInProgress.stateNode;
      /** * 1. If the static property has getDerivedStateFromError * 2. Fiber is an error boundary, ShouldCapture flag */ if componentDidCatch * is present
      if (
        (workInProgress.flags & DidCapture) === NoFlags &&
        (typeof ctor.getDerivedStateFromError === 'function'|| (instance ! = =null &&
            typeof instance.componentDidCatch === 'function' &&
            !isAlreadyFailedLegacyErrorBoundary(instance)))
      ) {
        // ShouldCapture, then unwindWork in completeUnitOfWork identifies the fiber with the wrong boundaryworkInProgress.flags |= ShouldCapture; .// Create the error bound update that will be used by the rerender
        const update = createClassErrorUpdate(
          workInProgress,
          errorInfo,
          lane,
        );
        enqueueCapturedUpdate(workInProgress, update);
        // Return after finding the error boundary
        return;
      }
      break;
Copy the code

CreateClassErrorUpdate createClassErrorUpdate is an error update that creates a class component and may contain:

  1. The payload getDerivedStateFromError
  2. The callback componentDidCatch
function createClassErrorUpdate(fiber: Fiber, errorInfo: CapturedValue
       
        , lane: Lane,
       ) :Update<mixed> {
  const update = createUpdate(NoTimestamp, lane);
  // Update the tag with CaptureUpdate
  update.tag = CaptureUpdate;
  // Take the static attribute getDerivedStateFromError
  const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
  if (typeof getDerivedStateFromError === 'function') {
    Static getDerivedStateFromError(error) {* return {hasError: true}; *} * /
    const error = errorInfo.value;
    update.payload = () = > {
      logCapturedError(fiber, errorInfo);
      Payload {hasError: true}
      return getDerivedStateFromError(error);
    };
  }
  // Get the instance
  const inst = fiber.stateNode;
  if(inst ! = =null && typeof inst.componentDidCatch === 'function') {
    /** * If there is a componentDidCatch, for example, put it in the update callback:  * componentDidCatch(error, errorInfo) { * logErrorToMyService(error, errorInfo); *} * /
    update.callback = function callback() {

      if (typeofgetDerivedStateFromError ! = ='function') {... logCapturedError(fiber, errorInfo); }const error = errorInfo.value;
      const stack = errorInfo.stack;
      this.componentDidCatch(error, {
        componentStack: stack ! = =null ? stack : ' '}); }; }return update;
}
Copy the code

If there is a getDerivedStateFromError, get the state of return; if there is a getDerivedStateFromError, get the state of return. If you have componentDidCatch, you can setState, either of the above two methods can set error to non-null (everyone writes it differently here, you can also declare a state hasError, This. State. Error meets the condition when render again, and the alternate UI is rendered.

Above combined with the first example, incidentally explained the principle of error boundary, the following examples will not be repeated.

Life cycle error reported

ComponentDidMount, componentDidUpdate

ComponentDidMount examples:

class ClassChild extends Component {
  componentDidMount() {
    // Uncaught ReferenceError: xxx is not defined
    console.log('componentDidMount');
    console.log(xxx);
  }
  render() {
    return <div>classChild</div>}}export default function App() {
  return (
    <ErrorBoundary>
      <ClassChild />}
    </ErrorBoundary>
  );
}
Copy the code

ComponentDidUpdate examples:

class ClassChild extends Component {
  componentDidUpdate() {
    // Uncaught ReferenceError: xxx is not defined
    console.log('componentDidUpdate');
    console.log(xxx);
  }
  render() {
    return <div>classChild</div>}}export default function App() {
  const [count, addCount] = useCount();

  return (
    <ErrorBoundary>
      <div>count: {count}  <button onClick={addCount}>Click on the + 1</button></div>
      <ClassChild />
    </ErrorBoundary>
  );
}
Copy the code

ComponentDidMount and componentDidUpdate are all in the react commit Layout stage, the source code is as follows:

try {
    commitLayoutEffectOnFiber(root, current, fiber, committedLanes);
  } catch (error) {
    // If you catch a fiber error, go up to the error boundary and render the alternate UI
    captureCommitPhaseError(fiber, fiber.return, error);
  }

function commitLayoutEffectOnFiber(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
) :void {
  // Ignore extraneous code.case ClassComponent: {
    / / class components
    const instance = finishedWork.stateNode;
        // didMount or didUpdate depending on whether there is current
        if (current === null) {
            // where componentDidMount is actually called
            instance.componentDidMount();
        } else {
            // Where componentDidUpdate is actually calledinstance.componentDidUpdate( prevProps, prevState, instance.__reactInternalSnapshotBeforeUpdate, ); }}... }Copy the code

The principle of captureCommitPhaseError is basically the same as the logic of the simple source code analysis above, and is the parent component that looks up from this child component to find the error boundary.

componentWillUnmount

Examples are as follows:

class ClassChild extends Component {
  componentWillUnmount() {
     console.log('componentWillUnmount');
     console.log(xxx);
  }
  render() {
    return <div>classChild</div>}}function App() {
  const [hide, setHide] = useState(false);
  return (
    <ErrorBoundary>
      <div><button onClick={()= >SetHide (true)}> Click unmount ClassChild</button></div>{! hide &&<ClassChild />}
    </ErrorBoundary>
  );
}
Copy the code

ComponentWillUnmount is the commitMutationtation stage of the REACT commit.

/ / class components
case ClassComponent: {
  // Get the instance
  const instance = current.stateNode;
  if (typeof instance.componentWillUnmount === 'function') {
    safelyCallComponentWillUnmount(
      current,
      nearestMountedAncestor,
      instance,
    );
  }
  return;
}
function safelyCallComponentWillUnmount(
  current: Fiber,
  nearestMountedAncestor: Fiber | null,
  instance: any,
) {
  try {
    callComponentWillUnmountWithTimer(current, instance);
  } catch (error) {
    // This example catches the class component componentWillUnmount error, so look up the error boundary, find the backup UIcaptureCommitPhaseError(current, nearestMountedAncestor, error); }}const callComponentWillUnmountWithTimer = function(current, instance) { instance.props = current.memoizedProps; .// where componentWillUnmount is actually called
  instance.componentWillUnmount();

};
Copy the code

useEffect

UseEffect is scheduled asynchronously in the COMMIT phase. The useEffect is scheduled asynchronously in the COMMIT phase. The useEffect is scheduled asynchronously in the COMMIT phase.

function flushPassiveEffectsImpl() {...// Execute the destruction function first
  commitPassiveUnmountEffects(root.current);
  // Execute the callback againcommitPassiveMountEffects(root, root.current); . }Copy the code

The callback error

function Child() {
  useEffect(() = > {
    console.log('useEffect');
    console.log(xxx); } []);return <div>child</div>;
}
export default function App() {
  return (
    <ErrorBoundary>
      <Child />
    </ErrorBoundary>
  );
}
Copy the code

The destruction function failed

function Child({ count }) {
  useEffect(() = > {
    return () = > {
      console.log('useEffect destroy');
      console.log(xxx);
    }
  }, [count]);
  return <div>child</div>;
}
function App() {
  const [hide, setHide] = useState(false)
  const [count, addCount] = useCount()
  return (
    <ErrorBoundary>
      <div><button onClick={addCount}>Click on the + 1</button></div>
      <div><button onClick={()= >SetHide (true)}> Click unmount Child</button></div>{! hide &&<Child count={count}/>}
    </ErrorBoundary>
  );
}
Copy the code

The destruction function is executed when the count is increased or the Child component is unloaded:

Count increases, there is render out the standby UI

The Child component unloads and finds that the standby UI is not rendered

Why can’t the latter be caught by the error boundary? Let’s look at the destruct code:

function safelyCallDestroy(
  current: Fiber,
  nearestMountedAncestor: Fiber | null,
  destroy: () => void.) {
  try {
    destroy();
  } catch (error) {
    // This example catches the useEffect destruction function reporting an error, so look up the error boundary and render the alternate UI if you find itcaptureCommitPhaseError(current, nearestMountedAncestor, error); }}Copy the code

We found that there is a catch, but it is important to note that the Child component is unloaded because useEffect is scheduled asynchronously, Fiber’s return for the Child component has been set to null:

function commitDeletion(finishedRoot: FiberRoot, current: Fiber, nearestMountedAncestor: Fiber,) :void {... detachFiberMutation(current); }// commitMutation is called, useEffect is asynchronous,
Fiber return is empty when useEffect destroys fiber
function detachFiberMutation(fiber: Fiber) {... fiber.return =null;
}
Copy the code

So when a catch occurs, captureCommitPhaseError is called to look up the parent Fiber. DetachFiberMutation has set the return of Child Fiber to null, so the error boundary cannot be found. The useEffect destruction function is triggered during the update phase. The Child Fiber’s return exists, and the error boundary is found.

useLayoutEffect

UseLayoutEffect calls the callback in Layout phase and is executed synchronously:

try {
  commitLayoutEffectOnFiber(root, current, fiber, committedLanes);
} catch (error) {
  // This example catches the function component useLayoutEffect callback error, so look up the error boundary, found the standby UI rendering
  captureCommitPhaseError(fiber, fiber.return, error);
}

function commitLayoutEffectOnFiber(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
) :void {...// Function components
  case FunctionComponent:
        ...
        commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
        break; . }Call the useLayoutEffect callback
function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {...// The callback function
  const create = effect.create;
  // The destruction function returnedeffect.destroy = create(); . }Copy the code

The destruction function of useLayoutEffect is also executed synchronously, and its destruction function precedes detachFiberMutation(return of null fiber) :

function commitDeletion(finishedRoot: FiberRoot, current: Fiber, nearestMountedAncestor: Fiber,) :void {...// The useLayoutEffect destruction function is executed synchronously here, while fiber.current is not empty
    commitNestedUnmounts(finishedRoot, current, nearestMountedAncestor);
  // Empty fiber's return
  detachFiberMutation(current);
}
Copy the code

Since both callbacks and destructors are synchronous, they both catch errors:

function Child({count}) {
  useLayoutEffect(() = > {
    return () = > {
      console.log('useLayoutEffect destroy');
      console.log(xxx);
    }
  }, [count]);
  return <div>child</div>;
}
export default function App() {
  const [hide, setHide] = useState(false)
  const [count, addCount] = useCount()
  return (
    <ErrorBoundary>
      <div><button onClick={addCount}>Click on the + 1</button></div>
      <div><button onClick={()= >SetHide (true)}> Click unmount Child</button></div>{! hide &&<Child count={count}/>}
    </ErrorBoundary>
  );
}
Copy the code

A scenario where error boundaries do not work

We examined the conditions for the use of false boundaries above, so let’s examine the scenarios that don’t apply.

An error was reported outside the component

Here’s an example:

// child.js
console.log(xxx)

function Child() {
  return <div>child</div>
}
Copy the code

There is no catch in this case, so error bounds don’t work

Error in asynchronous code

For example, asynchronous code is used in life cycle, useEffect, and useLayoutEffect. When the callback fails, it is no longer in the scope of catch and cannot be caught

Such as:

function Child() {
  useLayoutEffect(() = > {
    setTimeout( () = > {
      console.log('useLayoutEffect');
      console.log(xxx); }}), []);return <div>child</div>;
}
export default function App() {
  return (
    <ErrorBoundary>
      <Child />
    </ErrorBoundary>
  );
}
Copy the code

No error boundaries rendered

An error was reported in the event function

Such as:

function Child() {
  // Uncaught ReferenceError: xxx is not defined
  return <div onClick={()= > xxx}>child</div>;
}
export default function App() {
  return (
    <ErrorBoundary>
      <Child />
    </ErrorBoundary>
  );
}
Copy the code

An error thrown by the error boundary itself

class ErrorBoundary extends Component {
  state = { error: null }

  componentDidCatch(error: any, errorInfo: any) {
    this.setState({ error })
    console.log('Error caught', error, errorInfo)
  }
  // static getDerivedStateFromError(error: Error) {
  // return { error }
  // }
  render() {
    // Uncaught ReferenceError: xxx is not defined
    console.log(xxx);
    if (this.state.error) {
      return <div>I'm the standby UI</div>
    }

    return this.props.children
  }
}
Copy the code

The parent component of the error boundary reported an error

According to our analysis above, a component throwing an error will look up the error boundary, but if it is the parent of the error boundary, it will not find the error boundary no matter how hard it looks up.

The function component is unloaded, triggering useEffect destruction

We analyzed this above, and in this case false boundaries don’t work either

conclusion

ErrorBoundary Is when a child component uses a try catch to catch the offending component during rendering, call lifecycle, useEffect, useLayoutEffect, etc. :

As long as the parent component is a class component and has instance attribute componentDidCatch or static attribute getSnapshotBeforeUpdate, it is considered as an error boundary. In both of these methods, you can change state to render an alternate UI without rendering the page blank.

At the same time, we analyze the applicable conditions and inapplicable scenarios of error boundary, which are as follows:

Applicable conditions:

  • During component rendering
  • The life cycle
  • UseEffect and useLayoutEffect create and destroy (excluding useEffect destroy triggered by component uninstallation)

Inapplicable condition

  • An error was reported outside the component
  • Error in asynchronous code
  • An error was reported in the event function
  • An error thrown by the error boundary itself
  • The parent component of the error boundary
  • The function component is unloaded, triggering useEffect destruction

Except for the last one, all of the scenarios that don’t apply are because you didn’t catch an error.

The last

In this article, through various examples and the corresponding source code analysis what is mistake boundary, error bounds of applicable conditions and shall not apply to the scene, hope that through this article to let everybody know more about the principle of error bounds, at the same time also want to know the error bounds is not everything, it also has the applicable scope, the use of error can lead to error bounds doesn’t work.

Thank you for leaving your footprints. If you think the article is good 😄😄, please click 😋😋, like + favorites + forward 😄

The articles

Translation translation, what is ReactDOM. CreateRoot

Translate translate, what is JSX

React Router V6 is now available.

Fasten your seat belt and take a tour of React Router v6