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: "详情",