fix(app): keep the crash screen readable and its retry reachable (#3043)
The root error fallback was one unbounded scroller, so on a phone the title rendered under the status bar and a long stack trace pushed Retry off the bottom of the screen — the one action that recovers the app was unreachable on exactly the errors that need it most. The screen is now a fixed shell: a safe-area message block, an independently scrolling details region, and a Retry that stays put. `SafeAreaProvider` moved above the boundary in the root tree because the fallback renders when the app tree below it has already failed, and insets have to be available there. The hand-rolled Pressable and bordered message box are replaced by the shared `<Button>` and `<ScrollableCodeSurface>`, which gives the trace a monospace face and horizontal scrolling instead of wrapping mid-token. On desktop the trace is capped and the action sits with the message; a button pinned to the bottom of a tall window reads as detached from the content it belongs to. Dropped the "Something went wrong" kicker — it restated the title one line above it.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import React, { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import type { PressableStateCallbackType, StyleProp, ViewStyle } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatCaughtValue } from "./root-error-details";
|
||||
|
||||
interface RootErrorBoundaryProps {
|
||||
@@ -48,110 +51,113 @@ export class RootErrorBoundary extends Component<RootErrorBoundaryProps, RootErr
|
||||
}
|
||||
}
|
||||
|
||||
// A stack trace is reference material, not the screen. Cap it so the message and
|
||||
// the retry action stay the shape of the page; the rest scrolls.
|
||||
const DETAILS_MAX_HEIGHT = 300;
|
||||
|
||||
interface RootErrorFallbackProps {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
|
||||
export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
|
||||
const retry = (
|
||||
<Button variant="default" onPress={onRetry} testID="root-error-boundary-retry">
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
]}
|
||||
testID="root-error-boundary"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.kicker}>{t("rootError.kicker")}</Text>
|
||||
<Text style={styles.title}>{t("rootError.title")}</Text>
|
||||
<Text style={styles.body}>{t("rootError.body")}</Text>
|
||||
<View style={styles.messageBox}>
|
||||
<Text style={styles.messageLabel}>{t("rootError.details")}</Text>
|
||||
<Text style={styles.message}>{error}</Text>
|
||||
<View style={styles.header} testID="root-error-boundary-header">
|
||||
<Text style={styles.title}>{t("rootError.title")}</Text>
|
||||
<Text style={styles.body}>{t("rootError.body")}</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onRetry}
|
||||
style={retryButtonStyle}
|
||||
testID="root-error-boundary-retry"
|
||||
>
|
||||
<Text style={styles.retryButtonText}>{t("common.actions.retry")}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.details}>
|
||||
<Text style={styles.detailsLabel}>{t("rootError.details")}</Text>
|
||||
<ScrollableCodeSurface
|
||||
maxHeight={isCompact ? undefined : DETAILS_MAX_HEIGHT}
|
||||
style={styles.detailsSurface}
|
||||
scrollStyle={styles.detailsScroll}
|
||||
testID="root-error-boundary-details"
|
||||
>
|
||||
{error}
|
||||
</ScrollableCodeSurface>
|
||||
</View>
|
||||
{isCompact ? null : retry}
|
||||
</View>
|
||||
</ScrollView>
|
||||
{isCompact ? <View style={styles.footer}>{retry}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function retryButtonStyle({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> {
|
||||
return [styles.retryButton, pressed ? styles.retryButtonPressed : null];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentContainer: {
|
||||
flexGrow: 1,
|
||||
content: {
|
||||
flex: 1,
|
||||
alignSelf: "center",
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[6],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingVertical: theme.spacing[8],
|
||||
},
|
||||
content: {
|
||||
alignSelf: "center",
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
kicker: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
header: {
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xl,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
textAlign: "center",
|
||||
},
|
||||
body: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
messageBox: {
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
messageLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
message: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
retryButton: {
|
||||
alignSelf: "flex-start",
|
||||
minHeight: 40,
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.accent,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
details: {
|
||||
flexShrink: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
retryButtonPressed: {
|
||||
opacity: 0.85,
|
||||
detailsLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: theme.colors.accentForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
detailsSurface: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
detailsScroll: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
footer: {
|
||||
alignSelf: "center",
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user