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:
- The payload getDerivedStateFromError
- 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