diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 2290cfecf..12e179838 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -37,6 +37,7 @@ import { LeftSidebar } from "@/components/left-sidebar"; import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host"; import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProviderSettingsHost } from "@/components/provider-settings-host"; +import { RootErrorBoundary } from "@/components/root-error-boundary"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; @@ -998,31 +999,27 @@ function RuntimeProviders({ children }: { children: ReactNode }) { ); } -// PortalProvider must stay inside normal app-wide context providers here. +// PortalProvider must stay inside normal app-wide context providers. // `@gorhom/portal` renders portaled children at the host's location in the // tree, so any context a portaled sheet might consume (QueryClient, theme, -// auth, settings, …) must wrap PortalProvider — not be wrapped by it. +// auth, settings, ...) must wrap PortalProvider, not be wrapped by it. // BottomSheetModalProvider is the exception: Gorhom modals consume portal // context and need one shared provider for sibling sheets to stack. function RootProviders({ children }: { children: ReactNode }) { return ( - - - - - - - {children} - - - - - - + + + + + {children} + + + + ); } -export default function RootLayout() { +function RootAppTree() { return ( @@ -1036,6 +1033,18 @@ export default function RootLayout() { ); } +export default function RootLayout() { + return ( + + + + + + + + ); +} + const layoutStyles = StyleSheet.create((theme) => ({ surfaceFill: { flex: 1, diff --git a/packages/app/src/components/root-error-boundary.tsx b/packages/app/src/components/root-error-boundary.tsx new file mode 100644 index 000000000..97c34386b --- /dev/null +++ b/packages/app/src/components/root-error-boundary.tsx @@ -0,0 +1,157 @@ +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 { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { formatCaughtValue } from "./root-error-details"; + +interface RootErrorBoundaryProps { + children: ReactNode; +} + +interface RootErrorBoundaryState { + error: string | null; + resetKey: number; +} + +export class RootErrorBoundary extends Component { + state: RootErrorBoundaryState = { + error: null, + resetKey: 0, + }; + + static getDerivedStateFromError(error: unknown): Partial { + return { error: formatCaughtValue(error) }; + } + + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { + console.error("[RootErrorBoundary] Unhandled render error", { + error: formatCaughtValue(error), + componentStack: errorInfo.componentStack, + }); + } + + retry = () => { + this.setState(({ resetKey }) => ({ + error: null, + resetKey: resetKey + 1, + })); + }; + + render() { + const { error, resetKey } = this.state; + if (error !== null) { + return ; + } + + return {this.props.children}; + } +} + +interface RootErrorFallbackProps { + error: string; + onRetry: () => void; +} + +function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) { + const { t } = useTranslation(); + + return ( + + + {t("rootError.kicker")} + {t("rootError.title")} + {t("rootError.body")} + + {t("rootError.details")} + {error} + + + {t("common.actions.retry")} + + + + ); +} + +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, + justifyContent: "center", + 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, + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xl, + fontWeight: theme.fontWeight.semibold, + }, + 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, + }, + retryButton: { + alignSelf: "flex-start", + minHeight: 40, + justifyContent: "center", + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.accent, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[2], + }, + retryButtonPressed: { + opacity: 0.85, + }, + retryButtonText: { + color: theme.colors.accentForeground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, +})); diff --git a/packages/app/src/components/root-error-details.test.ts b/packages/app/src/components/root-error-details.test.ts new file mode 100644 index 000000000..0daf52136 --- /dev/null +++ b/packages/app/src/components/root-error-details.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { formatCaughtValue } from "./root-error-details"; + +describe("formatCaughtValue", () => { + it("preserves details for Error values", () => { + class RouteRenderError extends Error { + code = "E_ROUTE_RENDER"; + cause = "workspace route"; + + constructor() { + super("route render exploded"); + this.name = "RouteRenderError"; + this.stack = "RouteRenderError: route render exploded\n at WorkspaceRoute"; + } + } + + const details = formatCaughtValue(new RouteRenderError()); + + expect(details).toContain("Name: RouteRenderError"); + expect(details).toContain("Message: route render exploded"); + expect(details).toContain("Stack:"); + expect(details).toContain("RouteRenderError: route render exploded"); + expect(details).toContain("Cause:"); + expect(details).toContain("workspace route"); + expect(details).toContain("E_ROUTE_RENDER"); + }); + + it("does not duplicate aggregate errors as custom fields", () => { + const error = new AggregateError([new Error("first failure")], "multiple failures"); + const details = formatCaughtValue(error); + + expect(details).toContain("Errors:"); + expect(details).toContain("first failure"); + expect(details).not.toContain("Fields:"); + }); + + it("preserves null aggregate error values", () => { + class ErrorWithNullableErrors extends Error { + errors = null; + } + + const details = formatCaughtValue(new ErrorWithNullableErrors("nullable errors")); + + expect(details).toContain("Errors:\nnull"); + expect(details).not.toContain("Fields:"); + }); + + it("does not throw for malformed Error text fields", () => { + const error = new Error("fallback"); + Object.defineProperties(error, { + name: { configurable: true, value: null }, + message: { configurable: true, value: 42 }, + stack: { configurable: true, value: { frame: "bad stack" } }, + }); + + const details = formatCaughtValue(error); + + expect(details).toContain("Name: null"); + expect(details).toContain("Message: 42"); + expect(details).toContain('"frame": "bad stack"'); + }); + + it("marks recursive Error causes", () => { + const error = new Error("self cause"); + Object.defineProperty(error, "cause", { configurable: true, value: error }); + + const details = formatCaughtValue(error); + + expect(details).toContain("Cause:\n[Circular Error]"); + }); + + it("returns fallback details when Error properties throw", () => { + const error = new Error("fallback"); + Object.defineProperty(error, "message", { + configurable: true, + get() { + throw new Error("bad message getter"); + }, + }); + + const details = formatCaughtValue(error); + + expect(details).toContain("[Unserializable value]"); + expect(details).toContain("Details unavailable:"); + expect(details).toContain("Error: bad message getter"); + }); + + it("renders string thrown values as the string", () => { + expect(formatCaughtValue("plain failure")).toBe("plain failure"); + }); + + it("preserves empty string thrown values", () => { + expect(formatCaughtValue("")).toBe(""); + }); + + it("renders numeric thrown values without extra category text", () => { + const details = formatCaughtValue(42); + + expect(details).toBe("42"); + expect(details).not.toContain("non-Error"); + }); + + it("renders circular objects as JSON with circular markers", () => { + const value: { label: string; self?: unknown } = { label: "loop" }; + value.self = value; + + expect(formatCaughtValue(value)).toBe('{\n "label": "loop",\n "self": "[Circular]"\n}'); + }); +}); diff --git a/packages/app/src/components/root-error-details.ts b/packages/app/src/components/root-error-details.ts new file mode 100644 index 000000000..451981747 --- /dev/null +++ b/packages/app/src/components/root-error-details.ts @@ -0,0 +1,155 @@ +export function formatCaughtValue(value: unknown): string { + try { + return formatCaughtValueWithSeenErrors(value, new WeakSet()); + } catch (formattingError) { + return formatFormattingFailure(value, formattingError); + } +} + +function formatCaughtValueWithSeenErrors(value: unknown, seenErrors: WeakSet): string { + if (value instanceof Error) { + return formatError(value, seenErrors); + } + + if (typeof value === "string") { + return value; + } + + if (value === null || value === undefined) { + return safeString(value); + } + + if (typeof value !== "object" && typeof value !== "function") { + return safeString(value); + } + + return stringifyJson(value, seenErrors) ?? safeString(value); +} + +function formatError(error: Error, seenErrors: WeakSet): string { + if (seenErrors.has(error)) { + return "[Circular Error]"; + } + + seenErrors.add(error); + const sections: string[] = []; + const name = formatErrorTextProperty(Reflect.get(error, "name"), seenErrors); + const message = formatErrorTextProperty(Reflect.get(error, "message"), seenErrors); + const stack = formatErrorTextProperty(Reflect.get(error, "stack"), seenErrors); + + if (name) { + sections.push(`Name: ${name}`); + } + if (message) { + sections.push(`Message: ${message}`); + } + if (stack) { + sections.push(`Stack:\n${stack}`); + } + + const errorCause = getErrorCause(error); + if (errorCause.hasCause) { + sections.push(`Cause:\n${formatCaughtValueWithSeenErrors(errorCause.value, seenErrors)}`); + } + + const aggregateErrors = getAggregateErrors(error); + if (aggregateErrors.hasErrors) { + sections.push(`Errors:\n${formatCaughtValueWithSeenErrors(aggregateErrors.value, seenErrors)}`); + } + + const fields = getErrorFields(error); + if (fields !== null) { + sections.push(`Fields:\n${stringifyJson(fields, seenErrors) ?? safeString(fields)}`); + } + + seenErrors.delete(error); + return sections.join("\n\n") || safeString(error); +} + +function formatErrorTextProperty(value: unknown, seenErrors: WeakSet): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || null; + } + if (value === undefined) { + return null; + } + return stringifyJson(value, seenErrors) ?? safeString(value); +} + +function getErrorCause(error: Error): { hasCause: boolean; value: unknown } { + if (!Reflect.has(error, "cause")) { + return { hasCause: false, value: null }; + } + return { hasCause: true, value: Reflect.get(error, "cause") }; +} + +function getAggregateErrors(error: Error): { hasErrors: boolean; value: unknown } { + if (!Reflect.has(error, "errors")) { + return { hasErrors: false, value: null }; + } + return { hasErrors: true, value: Reflect.get(error, "errors") }; +} + +function getErrorFields(error: Error): Record | null { + const fields: Record = {}; + for (const key of Object.keys(error)) { + if ( + key === "name" || + key === "message" || + key === "stack" || + key === "cause" || + key === "errors" + ) { + continue; + } + fields[key] = Reflect.get(error, key); + } + + return Object.keys(fields).length > 0 ? fields : null; +} + +function stringifyJson(value: unknown, seenErrors: WeakSet): string | null { + const seen = new WeakSet(); + try { + const serialized = JSON.stringify( + value, + (_key, nestedValue: unknown) => { + if (nestedValue instanceof Error) { + return formatError(nestedValue, seenErrors); + } + if (typeof nestedValue === "bigint") { + return String(nestedValue); + } + if (nestedValue !== null && typeof nestedValue === "object") { + if (seen.has(nestedValue)) { + return "[Circular]"; + } + seen.add(nestedValue); + } + return nestedValue; + }, + 2, + ); + return typeof serialized === "string" ? serialized : null; + } catch { + return null; + } +} + +function safeString(value: unknown): string { + try { + return String(value); + } catch { + return "[Unserializable value]"; + } +} + +function formatFormattingFailure(value: unknown, formattingError: unknown): string { + const valueText = safeString(value); + const formattingErrorText = safeString(formattingError); + if (formattingErrorText === "[Unserializable value]") { + return valueText; + } + return `${valueText}\n\nDetails unavailable:\n${formattingErrorText}`; +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index fda26dd98..bbcf09d41 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1048,6 +1048,12 @@ export const ar: TranslationResources = { }, }, }, + rootError: { + kicker: "حدث خطأ", + title: "واجه Paseo مشكلة.", + body: "جرّب مرة أخرى لإعادة تحميل التطبيق. إذا استمر حدوث ذلك، فأرفق التفاصيل أدناه عند الإبلاغ عنه.", + details: "التفاصيل", + }, startup: { errorTitle: "حدث خطأ ما", errorDescription: diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3448c930f..b7dbe7b3a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1055,6 +1055,12 @@ 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", + }, startup: { errorTitle: "Something went wrong", errorDescription: diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 5f800ef22..16c73dc65 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1084,6 +1084,12 @@ 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", + }, startup: { errorTitle: "algo salió mal", errorDescription: diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index dd6b4ca1f..8aff3dbe1 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1086,6 +1086,12 @@ 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", + }, startup: { errorTitle: "Quelque chose s'est mal passé", errorDescription: diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index ab65eb302..5f58a6b6a 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1062,6 +1062,12 @@ export const ja: TranslationResources = { }, }, }, + rootError: { + kicker: "問題が発生しました", + title: "Paseo で問題が発生しました。", + body: "アプリを再読み込みするにはもう一度お試しください。繰り返し発生する場合は、以下の詳細を添えて報告してください。", + details: "詳細", + }, startup: { errorTitle: "問題が発生しました", errorDescription: diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2ffd7118c..b29003ec6 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1070,6 +1070,12 @@ 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", + }, startup: { errorTitle: "Algo deu errado", errorDescription: diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 62c15fa14..5450eec77 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1074,6 +1074,12 @@ export const ru: TranslationResources = { }, }, }, + rootError: { + kicker: "Что-то пошло не так", + title: "В Paseo возникла проблема.", + body: "Попробуйте снова перезагрузить приложение. Если это повторяется, приложите приведенные ниже подробности к отчету.", + details: "Подробности", + }, startup: { errorTitle: "Что- то пошло не так", errorDescription: diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 4eac0e9ca..542fc835d 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1034,6 +1034,12 @@ export const zhCN: TranslationResources = { }, }, }, + rootError: { + kicker: "出现问题", + title: "Paseo 遇到了问题。", + body: "请重试以重新加载应用。如果问题持续发生,请在报告时附上下面的详细信息。", + details: "详情", + }, startup: { errorTitle: "出现问题", errorDescription: "本地服务器启动失败。如果持续发生,请在 GitHub 报告问题并附上下方日志。",