forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathErrorBoundary.tsx
More file actions
123 lines (114 loc) · 5.07 KB
/
Copy pathErrorBoundary.tsx
File metadata and controls
123 lines (114 loc) · 5.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// The thing that stops one broken screen becoming a broken app.
//
// React's default for an error thrown in render, in an effect, or in an
// effect's CLEANUP is to unmount the entire root - not the component that
// threw, the whole tree, tab bar included. #131 did exactly that: a stale
// `removeControl` threw during cleanup on every tab switch away from the map,
// and what the hiker saw was a white page with no navigation on it. The
// reported symptom was "the download tab shows nothing"; the cause was three
// lines away in mapChrome.ts, and the distance between those two facts is what
// this file exists to close.
//
// Three decisions are baked in here, and all three are choices rather than
// defaults. They are recorded in TECHNICAL_ARCHITECTURE.md's client section
// ("What happens when a screen throws"), which is where to change them; the
// summaries below exist so someone reading this file knows they were decided
// rather than fallen into.
//
// **The tab bar stays under the fallback.** A fallback you cannot navigate out
// of is a white screen with words on it. Whatever else has gone wrong, the map
// has to be one tap away.
//
// **No reload button.** A reload is the obvious thing to offer and the wrong
// thing here: it happens with no signal, against a service worker, on the
// battery that gets someone home. Switching tabs and coming back remounts the
// screen anyway, costs nothing, and is what the tab bar below already does.
//
// **Nothing is recorded.** There is no telemetry in this client, and adding
// some carries its own privacy weight (features/IDENTITY_AND_PRIVACY.md), so
// this is a deliberate no rather than an oversight. The error still reaches
// console.error, which is where a developer looks and a hiker never does.
import { Component, type ErrorInfo, type ReactNode } from 'react'
export interface ErrorBoundaryProps {
children: ReactNode
/**
* What replaces the subtree when it throws. A render prop rather than an
* element so the shell can keep its own chrome - the tab bar in particular -
* around whatever this says.
*/
fallback: (error: Error) => ReactNode
/**
* Changing this resets the boundary, so a hiker who navigates away and back
* gets a real attempt rather than the fallback again. Without it a screen
* that threw once stays broken for the life of the app.
*/
resetKey?: unknown
}
interface ErrorBoundaryState {
error: Error | null
/** The key the current error belongs to, so a change to it can be seen. */
resetKey: unknown
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null, resetKey: this.props.resetKey }
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { error }
}
/**
* Clears the error when the reset key changes, during render rather than
* after it - the same update that navigates away drops the error, so the
* fallback never renders a frame for a screen the hiker has already left.
*
* The comparison is against the key held in state, not a previous prop:
* clearing on every update would re-render the subtree that just threw,
* which throws again, forever.
*/
static getDerivedStateFromProps(
props: ErrorBoundaryProps,
state: ErrorBoundaryState,
): Partial<ErrorBoundaryState> | null {
if (props.resetKey === state.resetKey) return null
return { error: null, resetKey: props.resetKey }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// Logged, not sent. See the note at the top of this file.
console.error('A screen failed and was replaced with a fallback.', error, info)
}
render(): ReactNode {
const { error } = this.state
return error === null ? this.props.children : this.props.fallback(error)
}
}
/**
* What a broken screen says.
*
* Written to be true rather than reassuring: something is wrong, the app knows
* it, and the rest of it still works. No apology, no "unexpected error", and
* no detail a hiker cannot act on - HIKER_SAFETY.md's rule is that the app does
* not mislead, and "Something went wrong" with a shrug is misleading about how
* much is still available.
*/
export function ScreenFailed({
what,
recovery = 'The rest of the app is fine. Switching tabs and coming back will start this screen again.',
}: {
what: string
/**
* The way out, stated to fit where this fallback is actually standing. The
* default assumes a tab bar is rendered under it, which is true inside the
* shell and FALSE at the root - main.tsx has to say something else, because
* "switch tabs" on a screen with no tabs is an instruction that cannot be
* followed, read at the worst possible moment to discover that.
*/
recovery?: string
}) {
return (
<div className="screen-failed" role="alert">
<h1 className="screen-failed__title">{what} stopped working</h1>
<p className="screen-failed__body">{recovery}</p>
<p className="screen-failed__body screen-failed__body--quiet">
Your downloaded map and anything waiting in your outbox are untouched.
</p>
</div>
)
}