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:
@@ -954,19 +954,17 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
|||||||
// context and need one shared provider for sibling sheets to stack.
|
// context and need one shared provider for sibling sheets to stack.
|
||||||
function RootProviders({ children }: { children: ReactNode }) {
|
function RootProviders({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<WindowChromeProvider>
|
||||||
<WindowChromeProvider>
|
<KeyboardProvider>
|
||||||
<KeyboardProvider>
|
<KeyboardShiftProvider>
|
||||||
<KeyboardShiftProvider>
|
<ToastProvider>
|
||||||
<ToastProvider>
|
<PortalProvider>
|
||||||
<PortalProvider>
|
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
</PortalProvider>
|
||||||
</PortalProvider>
|
</ToastProvider>
|
||||||
</ToastProvider>
|
</KeyboardShiftProvider>
|
||||||
</KeyboardShiftProvider>
|
</KeyboardProvider>
|
||||||
</KeyboardProvider>
|
</WindowChromeProvider>
|
||||||
</WindowChromeProvider>
|
|
||||||
</SafeAreaProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,9 +996,11 @@ export default function RootLayout() {
|
|||||||
return (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<RootErrorBoundary>
|
<SafeAreaProvider>
|
||||||
<RootAppTree />
|
<RootErrorBoundary>
|
||||||
</RootErrorBoundary>
|
<RootAppTree />
|
||||||
|
</RootErrorBoundary>
|
||||||
|
</SafeAreaProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RootErrorFallback } from "./root-error-boundary";
|
||||||
|
|
||||||
|
const { runtime, theme } = vi.hoisted(() => ({
|
||||||
|
runtime: { breakpoint: "xs" },
|
||||||
|
theme: {
|
||||||
|
spacing: { 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||||
|
borderWidth: { 1: 1 },
|
||||||
|
borderRadius: { md: 6, lg: 8, xl: 12 },
|
||||||
|
fontFamily: { ui: "system-ui", mono: "monospace" },
|
||||||
|
fontSize: { xs: 11, code: 12, sm: 13, base: 15, xl: 20 },
|
||||||
|
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||||
|
colors: {
|
||||||
|
accent: "#369",
|
||||||
|
accentForeground: "#fff",
|
||||||
|
border: "#333",
|
||||||
|
borderAccent: "#444",
|
||||||
|
destructive: "#c33",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
surface0: "#111",
|
||||||
|
surface1: "#181818",
|
||||||
|
surface2: "#222",
|
||||||
|
surface3: "#333",
|
||||||
|
},
|
||||||
|
opacity: { 50: 0.5 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme, rt: runtime }),
|
||||||
|
withUnistyles: (component: unknown) => component,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 47, right: 3, bottom: 34, left: 5 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
vi.stubGlobal("window", dom.window);
|
||||||
|
vi.stubGlobal("document", dom.window.document);
|
||||||
|
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||||
|
vi.stubGlobal("Node", dom.window.Node);
|
||||||
|
vi.stubGlobal("navigator", dom.window.navigator);
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root?.unmount());
|
||||||
|
root = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RootErrorFallback", () => {
|
||||||
|
for (const breakpoint of ["xs", "lg"]) {
|
||||||
|
it(`keeps the safe-area header and retry action outside the scrollable details on ${breakpoint}`, () => {
|
||||||
|
runtime.breakpoint = breakpoint;
|
||||||
|
act(() => {
|
||||||
|
root?.render(<RootErrorFallback error={"failure\n".repeat(200)} onRetry={vi.fn()} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = document.querySelector('[data-testid="root-error-boundary"]');
|
||||||
|
const header = document.querySelector('[data-testid="root-error-boundary-header"]');
|
||||||
|
const details = document.querySelector('[data-testid="root-error-boundary-details"]');
|
||||||
|
const retry = document.querySelector('[data-testid="root-error-boundary-retry"]');
|
||||||
|
|
||||||
|
expect(screen).not.toBeNull();
|
||||||
|
expect(header).not.toBeNull();
|
||||||
|
expect(details).not.toBeNull();
|
||||||
|
expect(retry).not.toBeNull();
|
||||||
|
expect(screen?.getAttribute("style")).toContain("padding: 47px 3px 34px 5px");
|
||||||
|
expect(details?.contains(header)).toBe(false);
|
||||||
|
expect(details?.contains(retry)).toBe(false);
|
||||||
|
expect(screen?.contains(retry)).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import React, { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
|
import React, { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
|
||||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import type { PressableStateCallbackType, StyleProp, ViewStyle } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
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";
|
import { formatCaughtValue } from "./root-error-details";
|
||||||
|
|
||||||
interface RootErrorBoundaryProps {
|
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 {
|
interface RootErrorFallbackProps {
|
||||||
error: string;
|
error: string;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
|
export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<ScrollView
|
<View
|
||||||
style={styles.container}
|
style={[
|
||||||
contentContainerStyle={styles.contentContainer}
|
styles.container,
|
||||||
|
{
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
},
|
||||||
|
]}
|
||||||
testID="root-error-boundary"
|
testID="root-error-boundary"
|
||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<Text style={styles.kicker}>{t("rootError.kicker")}</Text>
|
<View style={styles.header} testID="root-error-boundary-header">
|
||||||
<Text style={styles.title}>{t("rootError.title")}</Text>
|
<Text style={styles.title}>{t("rootError.title")}</Text>
|
||||||
<Text style={styles.body}>{t("rootError.body")}</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>
|
</View>
|
||||||
<Pressable
|
<View style={styles.details}>
|
||||||
accessibilityRole="button"
|
<Text style={styles.detailsLabel}>{t("rootError.details")}</Text>
|
||||||
onPress={onRetry}
|
<ScrollableCodeSurface
|
||||||
style={retryButtonStyle}
|
maxHeight={isCompact ? undefined : DETAILS_MAX_HEIGHT}
|
||||||
testID="root-error-boundary-retry"
|
style={styles.detailsSurface}
|
||||||
>
|
scrollStyle={styles.detailsScroll}
|
||||||
<Text style={styles.retryButtonText}>{t("common.actions.retry")}</Text>
|
testID="root-error-boundary-details"
|
||||||
</Pressable>
|
>
|
||||||
|
{error}
|
||||||
|
</ScrollableCodeSurface>
|
||||||
|
</View>
|
||||||
|
{isCompact ? null : retry}
|
||||||
</View>
|
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
contentContainer: {
|
content: {
|
||||||
flexGrow: 1,
|
flex: 1,
|
||||||
|
alignSelf: "center",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[6],
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
paddingVertical: theme.spacing[8],
|
paddingVertical: theme.spacing[8],
|
||||||
},
|
},
|
||||||
content: {
|
header: {
|
||||||
alignSelf: "center",
|
alignItems: "center",
|
||||||
width: "100%",
|
gap: theme.spacing[2],
|
||||||
maxWidth: 520,
|
|
||||||
gap: theme.spacing[4],
|
|
||||||
},
|
|
||||||
kicker: {
|
|
||||||
color: theme.colors.destructive,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.xl,
|
fontSize: theme.fontSize.xl,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
color: theme.colors.foregroundMuted,
|
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,
|
fontSize: theme.fontSize.sm,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
retryButton: {
|
details: {
|
||||||
alignSelf: "flex-start",
|
flexShrink: 1,
|
||||||
minHeight: 40,
|
gap: theme.spacing[2],
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
backgroundColor: theme.colors.accent,
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
},
|
},
|
||||||
retryButtonPressed: {
|
detailsLabel: {
|
||||||
opacity: 0.85,
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
retryButtonText: {
|
detailsSurface: {
|
||||||
color: theme.colors.accentForeground,
|
flexShrink: 1,
|
||||||
fontSize: theme.fontSize.sm,
|
},
|
||||||
fontWeight: theme.fontWeight.semibold,
|
detailsScroll: {
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
alignSelf: "center",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
paddingBottom: theme.spacing[6],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1243,7 +1243,6 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
kicker: "حدث خطأ",
|
|
||||||
title: "واجه Paseo مشكلة.",
|
title: "واجه Paseo مشكلة.",
|
||||||
body: "جرّب مرة أخرى لإعادة تحميل التطبيق. إذا استمر حدوث ذلك، فأرفق التفاصيل أدناه عند الإبلاغ عنه.",
|
body: "جرّب مرة أخرى لإعادة تحميل التطبيق. إذا استمر حدوث ذلك، فأرفق التفاصيل أدناه عند الإبلاغ عنه.",
|
||||||
details: "التفاصيل",
|
details: "التفاصيل",
|
||||||
|
|||||||
@@ -1253,7 +1253,6 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
kicker: "Something went wrong",
|
|
||||||
title: "Paseo ran into a problem.",
|
title: "Paseo ran into a problem.",
|
||||||
body: "Try again to reload the app. If this keeps happening, include the details below when you report it.",
|
body: "Try again to reload the app. If this keeps happening, include the details below when you report it.",
|
||||||
details: "Details",
|
details: "Details",
|
||||||
|
|||||||
@@ -1283,7 +1283,6 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
kicker: "Algo salió mal",
|
|
||||||
title: "Paseo tuvo un problema.",
|
title: "Paseo tuvo un problema.",
|
||||||
body: "Vuelve a intentarlo para recargar la app. Si sigue ocurriendo, incluye los detalles de abajo al reportarlo.",
|
body: "Vuelve a intentarlo para recargar la app. Si sigue ocurriendo, incluye los detalles de abajo al reportarlo.",
|
||||||
details: "Detalles",
|
details: "Detalles",
|
||||||
|
|||||||
@@ -1286,7 +1286,6 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
kicker: "Une erreur s'est produite",
|
|
||||||
title: "Paseo a rencontré un problème.",
|
title: "Paseo a rencontré un problème.",
|
||||||
body: "Réessayez pour recharger l'application. Si cela continue, joignez les détails ci-dessous au signalement.",
|
body: "Réessayez pour recharger l'application. Si cela continue, joignez les détails ci-dessous au signalement.",
|
||||||
details: "Détails",
|
details: "Détails",
|
||||||
|
|||||||
@@ -1256,7 +1256,6 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
kicker: "問題が発生しました",
|
|
||||||
title: "Paseo で問題が発生しました。",
|
title: "Paseo で問題が発生しました。",
|
||||||
body: "アプリを再読み込みするにはもう一度お試しください。繰り返し発生する場合は、以下の詳細を添えて報告してください。",
|
body: "アプリを再読み込みするにはもう一度お試しください。繰り返し発生する場合は、以下の詳細を添えて報告してください。",
|
||||||
details: "詳細",
|
details: "詳細",
|
||||||
|
|||||||
@@ -1252,7 +1252,6 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
kicker: "문제가 발생했습니다.",
|
|
||||||
title: "Paseo에 문제가 발생했습니다.",
|
title: "Paseo에 문제가 발생했습니다.",
|
||||||
body: "앱을 다시 로드해 보세요. 이런 일이 계속 발생하면 신고할 때 아래 세부정보를 포함하세요.",
|
body: "앱을 다시 로드해 보세요. 이런 일이 계속 발생하면 신고할 때 아래 세부정보를 포함하세요.",
|
||||||
details: "세부",
|
details: "세부",
|
||||||
|
|||||||
@@ -1269,7 +1269,6 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
kicker: "Algo deu errado",
|
|
||||||
title: "O Paseo encontrou um problema.",
|
title: "O Paseo encontrou um problema.",
|
||||||
body: "Tente novamente para recarregar o app. Se isso continuar acontecendo, inclua os detalhes abaixo ao relatar o problema.",
|
body: "Tente novamente para recarregar o app. Se isso continuar acontecendo, inclua os detalhes abaixo ao relatar o problema.",
|
||||||
details: "Detalhes",
|
details: "Detalhes",
|
||||||
|
|||||||
@@ -1272,7 +1272,6 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
kicker: "Что-то пошло не так",
|
|
||||||
title: "В Paseo возникла проблема.",
|
title: "В Paseo возникла проблема.",
|
||||||
body: "Попробуйте снова перезагрузить приложение. Если это повторяется, приложите приведенные ниже подробности к отчету.",
|
body: "Попробуйте снова перезагрузить приложение. Если это повторяется, приложите приведенные ниже подробности к отчету.",
|
||||||
details: "Подробности",
|
details: "Подробности",
|
||||||
|
|||||||
@@ -1227,7 +1227,6 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
kicker: "出现问题",
|
|
||||||
title: "Paseo 遇到了问题。",
|
title: "Paseo 遇到了问题。",
|
||||||
body: "请重试以重新加载应用。如果问题持续发生,请在报告时附上下面的详细信息。",
|
body: "请重试以重新加载应用。如果问题持续发生,请在报告时附上下面的详细信息。",
|
||||||
details: "详情",
|
details: "详情",
|
||||||
|
|||||||
Reference in New Issue
Block a user