Lesson 17 / 20
Debugging Tools
An error boundary unmounts the broken subtree and mounts the fallback. The lesson measures that the same panel is updated when a prop changes, is unmounted when it throws, and loses its identity when unmounted.
Published Updated
In this lesson
- Measures the fate an error boundary gives to the broken subtree and to the fallback
- Shows that an updated node keeps its identity while an unmounted one loses it
- Compares a healthy update and a faulty update on the same node
- Explains that debugging tools show the tree and do not change it
Contents
The previous lesson measured when deferring the code of a screen mounts its node. The code was loaded and the tree was mounted without a problem. In a real application, not every load and not every render ends without a problem: a piece of data does not arrive in the expected form, a function receives an unexpected value, and part of the screen throws an error while being rendered. This lesson measures what that error unmounts and what it mounts in the view tree.
The course’s question: what does this update do to the node in the view tree? The answers are kept, updated, mounted, and unmounted; the sum of everything other than kept is the commands. The rule is in the core file.
// fate.mjs — classifies what an update does to each node of the view tree export const FATES = ["kept", "updated", "mounted", "unmounted"]; export function el(type, props = {}, children = [], key = null) { return { type, props, children, key }; } function segment(node) { return node.key === null ? node.type : `${node.type}[${node.key}]`; } function identity(node, index) { return node.key === null ? `#${index}` : `key:${node.key}`; } function sameProps(a, b) { const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(); return names.every((n) => JSON.stringify(a[n]) === JSON.stringify(b[n])); } function whole(node, path, fate, ops) { const here = `${path}/${segment(node)}`; ops.push({ fate, path: here }); node.children.forEach((child) => whole(child, here, fate, ops)); } export function reconcile(prev, next, path = "", ops = []) { if (prev === null) { whole(next, path, "mounted", ops); return ops; } if (next === null) { whole(prev, path, "unmounted", ops); return ops; } if (prev.type !== next.type || prev.key !== next.key) { whole(prev, path, "unmounted", ops); whole(next, path, "mounted", ops); return ops; } const here = `${path}/${segment(next)}`; ops.push({ fate: sameProps(prev.props, next.props) ? "kept" : "updated", path: here }); const before = new Map(prev.children.map((child, i) => [identity(child, i), child])); const matched = new Set(); next.children.forEach((child, i) => { const id = identity(child, i); matched.add(id); reconcile(before.has(id) ? before.get(id) : null, child, here, ops); }); prev.children.forEach((child, i) => { if (!matched.has(identity(child, i))) reconcile(child, null, here, ops); }); return ops; } export function printOps(ops) { ops.forEach((op) => console.log(`${op.fate}: ${op.path}`)); } export function countFates(ops) { for (const name of FATES) { console.log(`${name}: ${ops.filter((op) => op.fate === name).length}`); } console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`); }
The mechanism that catches the error is an error boundary. An error boundary wraps the function that renders a subtree: if the function throws, the boundary catches the error, produces a fallback in place of the subtree, and the rest of the screen keeps rendering. Without a boundary, the error would bring down the whole screen. The file below builds a panel in the model, the boundary that wraps it, and the screen. The panel throws if state.broken is true. The boundary writes the error in a single line in plain words and returns the fallback.
// screen.mjs — turns a screen into a view tree and wraps the broken panel in an error boundary import { el } from "./fate.mjs"; export function renderPanel(state) { if (state.broken) throw new Error("data could not be read"); return el("Panel", { total: state.total }, [el("Value", { total: state.total })]); } export function withBoundary(render) { return (state) => { try { return render(state); } catch (err) { console.log(`caught at the error boundary: ${err.message}`); return el("Fallback", { text: "Something went wrong" }); } }; } export function renderScreen(state) { const safe = withBoundary(renderPanel); return el("Screen", {}, [el("Header", { title: "Overview" }), safe(state)]); }
renderPanel produces a panel tree from the state; the panel also has a value node. withBoundary wraps a render function: if the function throws, it catches the error, writes the message, and returns the Fallback node. renderScreen wraps the panel with the boundary and places it next to the header.
Healthy Update
In the first measurement, the panel does not throw; only the total changes. The header stays the same, and the panel and the value node carry the new total.
// main.mjs import { reconcile, printOps } from "./fate.mjs"; import { renderScreen } from "./screen.mjs"; const before = renderScreen({ broken: false, total: 10 }); const after = renderScreen({ broken: false, total: 12 }); printOps(reconcile(before, after));
kept: /Screen kept: /Screen/Header updated: /Screen/Panel updated: /Screen/Panel/Value
The panel and the value came out updated, and two commands went out. The header and the root stayed kept. An updated node is the same node: its type and key have not changed, only its prop has. If there is any, its state, focus, and scroll position on the native side carry on.
Faulty Update
In the second measurement, the same update is made with the panel throwing. The panel throws, the boundary catches the error, and Fallback is returned. In the new tree, there is a fallback in the place of the panel.
// main.mjs import { reconcile, printOps } from "./fate.mjs"; import { renderScreen } from "./screen.mjs"; const before = renderScreen({ broken: false, total: 10 }); const after = renderScreen({ broken: true, total: 12 }); printOps(reconcile(before, after));
caught at the error boundary: data could not be read kept: /Screen kept: /Screen/Header unmounted: /Screen/Panel unmounted: /Screen/Panel/Value mounted: /Screen/Fallback
The first line is the only line the error boundary writes. The header and the root came out kept: the error did not pass to the rest of the screen. The panel and its value became unmounted, and Fallback became mounted. Three commands went out: two unmounts and one mount. Because the panel’s prop did not change, this unmounting does not come from a prop comparison; the type in the content area changed, Fallback came in place of Panel, and the rule unmounted the whole old subtree and mounted the whole new one.
In M11/K05, the question “what does this operation leave in the source” asks about the state of the source object after an operation: unchanged, changed, empty, or unspecified. The fate here classifies not the state of an object but whether a node’s identity survives the update. Updated keeps its identity, unmounted loses it. In the healthy update, the panel stayed in place and the state inside it carried on. In the faulty update, the panel disappeared; the panel’s native state, its focus, and its scroll position went away with it.
Return to Health
When the data is fixed, the tree returns to the panel. The return is not the unmounted nodes coming back; it is the rebirth of mounted nodes.
// main.mjs import { reconcile, countFates } from "./fate.mjs"; import { renderScreen } from "./screen.mjs"; const broken = renderScreen({ broken: true, total: 12 }); const healed = renderScreen({ broken: false, total: 12 }); countFates(reconcile(broken, healed));
caught at the error boundary: data could not be read kept: 2 updated: 0 mounted: 2 unmounted: 1 commands: 3
The header and the root were kept. Fallback was unmounted, and the panel and its value were mounted. Three commands went out; the new panel is not the same as the old one but a new node. The panel’s state from before the error does not come back: it disappeared when it was unmounted. The error boundary keeps the screen standing but does not carry on the life of the broken subtree.
The Error Boundary in the Source
A real error boundary is a component class that holds the error state. If an error occurs while rendering the child, the class switches to the error state and renders the fallback instead of the child. The model represents this with a try and catch. The snippet below shows the source form of the class; the model does not run it.
// Platform code: Boundary
class Boundary extends React.Component {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render() {
return this.state.failed ? <Fallback /> : this.props.children;
}
}
The class wraps its children. getDerivedStateFromError changes the state when an error arrives, and the class’s render function returns the fallback. On the screen, its use is a wrapping; only the wrapped subtree is under the protection of this boundary.
// Platform code: Summary
function Summary({ total }) {
return (
<View>
<Text>Overview</Text>
<Boundary>
<Panel total={total} />
</Boundary>
</View>
);
}
The place of the boundary determines how much of the error it will swallow. If the boundary wraps only the panel, the error stays limited to the panel; if it wraps the whole screen, an error in one panel replaces the whole screen with the fallback. The model shows this with the place of the withBoundary call inside renderScreen: the header is outside the boundary, and this is why it stayed kept.
Reading the Error Boundary Snippet Line by Line
In the first snippet, four lines do the work. state = { failed: false } says the boundary is initially without an error. getDerivedStateFromError() is called when an error occurs in the subtree and returns the new state; here it sets the state to failed: true. this.state.failed ? <Fallback /> : this.props.children chooses one of two paths: the fallback if there is an error, the wrapped children if there is not.
The withBoundary in the model makes the same decision with a try and catch. The difference is this: in the model, the decision is made again at every call, while in a real boundary the state is stored. Once an error has occurred, a real boundary keeps showing the fallback until it is reset. This is why a retry button carries a way to reset the boundary’s state.
In the second snippet, <Boundary> wraps only the <Panel /> element. <Text>Overview</Text> is outside the boundary. This is why the header in the model stayed kept: the error occurred inside the boundary and did not pass outside it.
Reading the Distinction Sentence with an Example
The question of M11/K05 and the question of this lesson look similar and can be misread, so it is useful to rebuild the distinction with an example. The panel can have two separate endings.
In the first ending, only the panel’s total changes. The panel is the same node, its prop takes a new value, and the fate is updated. The state inside the panel, for example an open detail row, stays in place. The identity has survived the update.
In the second ending, the panel’s render throws. The panel is no longer in the tree; a node of another type has come in its place. The fate is unmounted, and every piece of state inside, such as the open detail row, goes with it. When the same user looks at the screen again, they cannot find the panel’s old state; on the return to health, they see a new panel.
In both endings, it is said that “something happened to the panel”, but one keeps the identity and the other loses it. Updated keeps its identity, unmounted loses it; the fate classifies not the state of an object but the fate of this identity.
Common Misconceptions
The first misconception is that the error boundary solves the error. The boundary catches the error and keeps the screen standing, but the cause of the error stays where it is. If the data is broken, the panel throws again at the next update; the boundary catches it again. A boundary is a fuse, not a repair.
The second misconception is that the fallback is a backup of the unmounted node. The fallback is a new node; it does not carry the panel’s state. Whatever the panel held is lost with the error. An application that wants to keep the state should hold it outside the boundary, higher up.
The third misconception is that the boundary catches every error. Errors during render are caught; an error in a touch handler or in a timer is not. So instead of looking at the absence of a boundary, one looks at where the error occurred.
Tools Show the Tree
The everyday tools of debugging fall into three groups. The developer menu is a door for reloading the running application, seeing the logs, and opening the inspector. The debugging inspector shows the view tree of the running application: the type, the props, and the children of each node. The debugger, on the other hand, stops the code line by line and shows the variables.
All three show the tree; none changes it. The inspector does what printOps does in this lesson, but on the real tree: when the panel throws, it shows Fallback in its place, while the error message stays in the log. To find the error, the two are looked at together. If Fallback is seen in the tree, an error boundary has stepped in, and the source of the error is written in the log. If there is no Fallback in the tree and the screen has fallen, there is no boundary, or the error occurred in a place the boundary cannot catch.
There are also places the boundary does not catch. The boundary catches errors during render. An error that occurs in a touch handler or in a timer callback does not reach the boundary, since it did not occur during render; it has to be caught separately. The model builds only the error during render; the other cases stay in the prose.
The Bounding Measurement
The last measurement compares two updates on the same panel. In one, the panel’s prop changes; in the other, the panel throws. The rule is filtered to the panel’s path. The fate of the fallback and of the value node is not shown on this page, because the question is what happens to the panel.
// main.mjs import { reconcile, printOps } from "./fate.mjs"; import { renderScreen } from "./screen.mjs"; const before = renderScreen({ broken: false, total: 10 }); for (const broken of [false, true]) { console.log(broken ? "faulty update" : "healthy update"); const after = renderScreen({ broken, total: 12 }); const ops = reconcile(before, after); printOps(ops.filter((op) => op.path === "/Screen/Panel")); }
healthy update updated: /Screen/Panel faulty update caught at the error boundary: data could not be read unmounted: /Screen/Panel
The focus node is /Screen/Panel. In the healthy update, the output is updated: the panel kept its identity. In the faulty update, the output is unmounted: the panel lost its identity. The lesson’s fate is the second one.
The limit of this measurement is that what comes in place of the unmounted node is a fallback. The rule counts the Fallback node as mounted, but it does not know what that node shows the user. The fallback may say “something went wrong”, may carry a retry button, or may stay empty; for the rule, all three are the same record. This is also what is useful in debugging: the tree tells where the error stopped; what the error was is told by the log and the debugger.
PP4. The model builds this fate’s rule; the model is not the framework itself. In the model, the error boundary is a render function wrapped with a try and catch; the error is thrown from inside the function and the boundary writes it in a single line in plain words. A real error boundary is a class that holds the error state and renders its children; the place where the error is caught, the way the error state is reset, and how the error goes to the log are outside the model. In the model, the error is rebuilt at every update; a real boundary can carry the error state to the next update and can be reset with a retry button.
Summary
- This lesson’s fate: unmounted. The
/Screen/Panelnode was unmounted when its subtree threw;/Screen/Fallbackwas mounted in its place. - The same panel came out updated when a prop changed and kept its identity; it came out unmounted when it threw and lost its identity.
- The error did not pass to the rest of the screen: the header and the root stayed kept.
- On the return to health, the panel was mounted as a new node; the unmounted node’s state did not come back.
- Debugging tools show the tree and the error and do not change the tree; the rule does not see what the fallback shows.
Next Step
In this lesson, the error was caught by a boundary and its effect on the tree was measured. Catching an error and verifying a piece of work come from the same need: the expected form of the tree must be known. The next lesson, Testing, compares an expected list of records with the output of reconcile and measures that an update is verified.
To keep your progress and take notes, Log in
My notes
Log in to take notes.