1import { Component, type ReactNode } from "react";
2import { ScrollView, View } from "react-native";
3
4import { AppText as Text } from "./AppText";
5import { MaterialButton } from "./MaterialButton";
6import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
7
8interface RenderErrorBoundaryProps {
9 readonly children: ReactNode;
10 readonly resetKeys?: ReadonlyArray<unknown>;
11 readonly renderFallback?: (props: RenderFailureProps) => ReactNode;
12}
13
14interface RenderErrorBoundaryState {
15 readonly failed: boolean;
16 readonly error: unknown;
17 readonly componentStack?: string;
18 readonly resetKeys?: ReadonlyArray<unknown>;
19}
20
21export interface RenderFailureProps {
22 readonly details: string;
23 readonly retry: () => void;
24}
25
26function errorDetails(error: unknown, componentStack?: string): string {
27 let description: string;
28 try {
29 description = error instanceof Error ? (error.stack ?? error.message) : String(error);
30 } catch {
31 description = "Unknown render error";
32 }
33 return componentStack ? `${description}\nComponent stack:\n${componentStack}` : description;
34}
35
36/** A failed subtree remounts on retry or when its identifying inputs change. */
37export class RenderErrorBoundary extends Component<
38 RenderErrorBoundaryProps,
39 RenderErrorBoundaryState
40> {
41 override state: RenderErrorBoundaryState = {
42 failed: false,
43 error: null,
44 resetKeys: this.props.resetKeys,
45 };
46
47 static getDerivedStateFromProps(
48 { resetKeys }: RenderErrorBoundaryProps,
49 state: RenderErrorBoundaryState,
50 ): Partial<RenderErrorBoundaryState> | null {
51 if (
52 resetKeys?.length !== state.resetKeys?.length ||
53 resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index]))
54 ) {
55 return { failed: false, error: null, componentStack: undefined, resetKeys };
56 }
57 return null;
58 }
59
60 static getDerivedStateFromError(error: unknown): Partial<RenderErrorBoundaryState> {
61 return { failed: true, error };
62 }
63
64 override componentDidCatch(_error: unknown, info: { componentStack?: string }) {
65 this.setState({ componentStack: info.componentStack });
66 }
67
68 private readonly retry = () => {
69 this.setState({ failed: false, error: null, componentStack: undefined });
70 };
71
72 override render() {
73 if (!this.state.failed) return this.props.children;
74 const fallback =
75 this.props.renderFallback ??
76 ((props: RenderFailureProps) => <RenderFailureView {...props} />);
77 return fallback({
78 details: errorDetails(this.state.error, this.state.componentStack),
79 retry: this.retry,
80 });
81 }
82}
83
84export function RenderFailureView(
85 props: RenderFailureProps & {
86 readonly title?: string;
87 readonly bottomInset?: number;
88 readonly exit?: { readonly label: string; readonly onPress: () => void };
89 },
90) {
91 const title = props.title ?? "This screen couldn't be displayed";
92 return (
93 <ScrollView
94 className="flex-1 bg-screen"
95 contentContainerClassName="flex-grow items-center justify-center gap-5 px-6 py-8"
96 contentContainerStyle={
97 props.bottomInset ? { paddingBottom: 32 + props.bottomInset } : undefined
98 }
99 >
100 <Text accessibilityRole="header" className="text-center text-xl font-t3-bold">
101 {title}
102 </Text>
103 <Text className="text-center text-sm text-foreground-muted">
104 Try again. If it keeps happening, copy the details for a bug report.
105 </Text>
106 <Text selectable className="text-center font-mono text-xs text-danger-foreground">
107 {props.details.split("\n", 1)[0]?.slice(0, 300)}
108 </Text>
109 <View className="w-full max-w-xs gap-2">
110 <MaterialButton label="Try again" onPress={props.retry} tone="primary" fullWidth />
111 <MaterialButton
112 label="Copy details"
113 onPress={() => copyTextWithHaptic(props.details, { target: "error details" })}
114 fullWidth
115 />
116 {props.exit ? (
117 <MaterialButton
118 label={props.exit.label}
119 onPress={props.exit.onPress}
120 tone="text"
121 fullWidth
122 />
123 ) : null}
124 </View>
125 </ScrollView>
126 );
127}