Files
paseo/packages/app/src/components/root-error-boundary.tsx
T
Mohamed Boudra 4006f974fb Reload to the project picker after an app error (#4598)
* fix(app): reload to the project picker after a render error

Keep the recovery boundary above the router so Reload starts a new navigation tree at the picker instead of remounting the failed workspace. Preserve saved layouts and normal startup restoration.

* test(app): exercise reload through fixture routes
2026-09-11 08:09:05 +02:00

156 lines
4.2 KiB
TypeScript

import React, { Component, type ErrorInfo, type ReactNode } from "react";
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 {
children: ReactNode;
onReload: () => void;
}
interface RootErrorBoundaryState {
error: string | null;
}
export class RootErrorBoundary extends Component<RootErrorBoundaryProps, RootErrorBoundaryState> {
state: RootErrorBoundaryState = {
error: null,
};
static getDerivedStateFromError(error: unknown): Partial<RootErrorBoundaryState> {
return { error: formatCaughtValue(error) };
}
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
console.error("[RootErrorBoundary] Unhandled render error", {
error: formatCaughtValue(error),
componentStack: errorInfo.componentStack,
});
}
render() {
const { error } = this.state;
if (error !== null) {
return <RootErrorFallback error={error} onReload={this.props.onReload} />;
}
return this.props.children;
}
}
// A stack trace is reference material, not the screen. Cap it so the message and
// the reload action stay the shape of the page; the rest scrolls.
const DETAILS_MAX_HEIGHT = 300;
interface RootErrorFallbackProps {
error: string;
onReload: () => void;
}
export function RootErrorFallback({ error, onReload }: RootErrorFallbackProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
const reload = (
<Button variant="default" onPress={onReload} testID="root-error-boundary-reload">
{t("common.actions.reload")}
</Button>
);
return (
<View
style={[
styles.container,
{
paddingTop: insets.top,
paddingRight: insets.right,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
},
]}
testID="root-error-boundary"
>
<View style={styles.content}>
<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>
<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 : reload}
</View>
{isCompact ? <View style={styles.footer}>{reload}</View> : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
content: {
flex: 1,
alignSelf: "center",
width: "100%",
maxWidth: 420,
justifyContent: "center",
gap: theme.spacing[6],
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[8],
},
header: {
alignItems: "center",
gap: theme.spacing[2],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
body: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 20,
textAlign: "center",
},
details: {
flexShrink: 1,
gap: theme.spacing[2],
},
detailsLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
detailsSurface: {
flexShrink: 1,
},
detailsScroll: {
flexShrink: 1,
},
footer: {
alignSelf: "center",
width: "100%",
maxWidth: 420,
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[6],
},
}));