| 1 | import { Component, type ReactNode } from "react"; |
| 2 | import { ScrollView, View } from "react-native"; |
| 3 | |
| 4 | import { AppText as Text } from "./AppText"; |
| 5 | import { MaterialButton } from "./MaterialButton"; |
| 6 | import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; |
| 7 | |
| 8 | interface RenderErrorBoundaryProps { |
| 9 | readonly children: ReactNode; |
| 10 | readonly resetKeys?: ReadonlyArray<unknown>; |
| 11 | readonly renderFallback?: (props: RenderFailureProps) => ReactNode; |
| 12 | } |
| 13 | |
| 14 | interface RenderErrorBoundaryState { |
| 15 | readonly failed: boolean; |
| 16 | readonly error: unknown; |
| 17 | readonly componentStack?: string; |
| 18 | readonly resetKeys?: ReadonlyArray<unknown>; |
| 19 | } |
| 20 | |
| 21 | export interface RenderFailureProps { |
| 22 | readonly details: string; |
| 23 | readonly retry: () => void; |
| 24 | } |
| 25 | |
| 26 | function 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. */ |
| 37 | export 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 | |
| 84 | export 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 | } |