Show a recovery screen for app render errors (#1924)
* fix(app): show recovery screen for render failures Render a retryable fallback with useful error details when the routed app tree throws during render instead of leaving the app blank. * test(app): cover root error details without mounting Move caught-value formatting into a pure module, cover AggregateError fields without duplicate output, and remove the component-mounted test. * fix(app): keep empty error details caught * fix(app): preserve nullable aggregate errors * fix(app): guard malformed error details * fix(app): handle recursive error causes * fix(app): guard error detail formatting failures * fix(app): localize root error fallback * fix(app): keep root error fallback on existing i18n
This commit is contained in:
@@ -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<RootErrorBoundaryProps, RootErrorBoundaryState> {
|
||||
state: RootErrorBoundaryState = {
|
||||
error: null,
|
||||
resetKey: 0,
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
retry = () => {
|
||||
this.setState(({ resetKey }) => ({
|
||||
error: null,
|
||||
resetKey: resetKey + 1,
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { error, resetKey } = this.state;
|
||||
if (error !== null) {
|
||||
return <RootErrorFallback error={error} onRetry={this.retry} />;
|
||||
}
|
||||
|
||||
return <Fragment key={resetKey}>{this.props.children}</Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
interface RootErrorFallbackProps {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
testID="root-error-boundary"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.kicker}>{t("rootError.kicker")}</Text>
|
||||
<Text style={styles.title}>{t("rootError.title")}</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>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onRetry}
|
||||
style={retryButtonStyle}
|
||||
testID="root-error-boundary-retry"
|
||||
>
|
||||
<Text style={styles.retryButtonText}>{t("common.actions.retry")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function retryButtonStyle({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> {
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
export function formatCaughtValue(value: unknown): string {
|
||||
try {
|
||||
return formatCaughtValueWithSeenErrors(value, new WeakSet<Error>());
|
||||
} catch (formattingError) {
|
||||
return formatFormattingFailure(value, formattingError);
|
||||
}
|
||||
}
|
||||
|
||||
function formatCaughtValueWithSeenErrors(value: unknown, seenErrors: WeakSet<Error>): 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<Error>): 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<Error>): 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<string, unknown> | null {
|
||||
const fields: Record<string, unknown> = {};
|
||||
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<Error>): string | null {
|
||||
const seen = new WeakSet<object>();
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user