diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 1e123959d..7cdaf4b95 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -954,19 +954,17 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
// context and need one shared provider for sibling sheets to stack.
function RootProviders({ children }: { children: ReactNode }) {
return (
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
);
}
@@ -998,9 +996,11 @@ export default function RootLayout() {
return (
-
-
-
+
+
+
+
+
);
diff --git a/packages/app/src/components/root-error-boundary.test.tsx b/packages/app/src/components/root-error-boundary.test.tsx
new file mode 100644
index 000000000..de1a8225e
--- /dev/null
+++ b/packages/app/src/components/root-error-boundary.test.tsx
@@ -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("
");
+ 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();
+ });
+
+ 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);
+ });
+ }
+});
diff --git a/packages/app/src/components/root-error-boundary.tsx b/packages/app/src/components/root-error-boundary.tsx
index 97c34386b..aa70749d6 100644
--- a/packages/app/src/components/root-error-boundary.tsx
+++ b/packages/app/src/components/root-error-boundary.tsx
@@ -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 void;
}
-function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
+export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const isCompact = useIsCompactFormFactor();
+
+ const retry = (
+
+ );
return (
-
- {t("rootError.kicker")}
- {t("rootError.title")}
- {t("rootError.body")}
-
- {t("rootError.details")}
- {error}
+
+ {t("rootError.title")}
+ {t("rootError.body")}
-
- {t("common.actions.retry")}
-
+
+ {t("rootError.details")}
+
+ {error}
+
+
+ {isCompact ? null : retry}
-
+ {isCompact ? {retry} : null}
+
);
}
-function retryButtonStyle({ pressed }: PressableStateCallbackType): StyleProp {
- 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],
},
}));
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index c84a2f098..4870eef43 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -1243,7 +1243,6 @@ export const ar: TranslationResources = {
},
},
rootError: {
- kicker: "حدث خطأ",
title: "واجه Paseo مشكلة.",
body: "جرّب مرة أخرى لإعادة تحميل التطبيق. إذا استمر حدوث ذلك، فأرفق التفاصيل أدناه عند الإبلاغ عنه.",
details: "التفاصيل",
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index 67a066112..d26f6ed34 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -1253,7 +1253,6 @@ export const en = {
},
},
rootError: {
- kicker: "Something went wrong",
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.",
details: "Details",
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index 8536cc9eb..f50db606b 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -1283,7 +1283,6 @@ export const es: TranslationResources = {
},
},
rootError: {
- kicker: "Algo salió mal",
title: "Paseo tuvo un problema.",
body: "Vuelve a intentarlo para recargar la app. Si sigue ocurriendo, incluye los detalles de abajo al reportarlo.",
details: "Detalles",
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index 2d3305cbc..4b26acbcd 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -1286,7 +1286,6 @@ export const fr: TranslationResources = {
},
},
rootError: {
- kicker: "Une erreur s'est produite",
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.",
details: "Détails",
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index 20590fa3d..760f8250d 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -1256,7 +1256,6 @@ export const ja: TranslationResources = {
},
},
rootError: {
- kicker: "問題が発生しました",
title: "Paseo で問題が発生しました。",
body: "アプリを再読み込みするにはもう一度お試しください。繰り返し発生する場合は、以下の詳細を添えて報告してください。",
details: "詳細",
diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts
index c2268ed65..f3ec7246b 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -1252,7 +1252,6 @@ export const ko: TranslationResources = {
},
},
rootError: {
- kicker: "문제가 발생했습니다.",
title: "Paseo에 문제가 발생했습니다.",
body: "앱을 다시 로드해 보세요. 이런 일이 계속 발생하면 신고할 때 아래 세부정보를 포함하세요.",
details: "세부",
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index 99de443f8..c2d21c467 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -1269,7 +1269,6 @@ export const ptBR: TranslationResources = {
},
},
rootError: {
- kicker: "Algo deu errado",
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.",
details: "Detalhes",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 3d9cd2601..aa0b51082 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -1272,7 +1272,6 @@ export const ru: TranslationResources = {
},
},
rootError: {
- kicker: "Что-то пошло не так",
title: "В Paseo возникла проблема.",
body: "Попробуйте снова перезагрузить приложение. Если это повторяется, приложите приведенные ниже подробности к отчету.",
details: "Подробности",
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index 45b13f0cb..d83da6c0a 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -1227,7 +1227,6 @@ export const zhCN: TranslationResources = {
},
},
rootError: {
- kicker: "出现问题",
title: "Paseo 遇到了问题。",
body: "请重试以重新加载应用。如果问题持续发生,请在报告时附上下面的详细信息。",
details: "详情",