Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 24 additions & 21 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { initConnectivityService } from "../src/features/network/connectivitySer
import { initSyncManager, triggerSync } from "../src/features/sync/syncManager";
import { mutationReplayer } from "../src/lib/mutationReplayer";
import { ErrorBoundary } from "../src/components/ErrorBoundary";
import { ScreenErrorBoundary } from "../src/components/ScreenErrorBoundary";
import { SyncCorrectionOverlay } from "../src/components/SyncCorrectionOverlay";
import { SyncStatusBanner } from "../src/components/SyncStatusBanner";
import { OfflineBanner } from "../src/components/OfflineBanner";
Expand Down Expand Up @@ -41,7 +42,7 @@ export default function RootLayout() {
const colorScheme = useColorScheme();

return (
<ErrorBoundary>
<ErrorBoundary context="app-root">
<SensitiveStorageMigrationGate>
<SecurityInit />
<EmbeddedWalletProvider>
Expand All @@ -66,26 +67,28 @@ export default function RootLayout() {
<View className="flex-1 bg-background dark:bg-slate-900">
<WalletConnectProvider>
<DeepLinkHandler />
<Stack
screenOptions={{
headerShown: false,
contentStyle: {
backgroundColor: colorScheme === "dark" ? "#0f172a" : "#f8fafc",
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="onboarding" />
<Stack.Screen name="profile" />
<Stack.Screen name="guilds" />
<Stack.Screen name="guilds/[guildId]" />
<Stack.Screen name="access-check" />
<Stack.Screen name="access-scanner" />
<Stack.Screen name="settings" />
<Stack.Screen name="push-notification-setup" />
<Stack.Screen name="pending-changes" options={{ presentation: "modal" }} />
<Stack.Screen name="deep-link-error" />
</Stack>
<ScreenErrorBoundary screenName="app-stack">
<Stack
screenOptions={{
headerShown: false,
contentStyle: {
backgroundColor: colorScheme === "dark" ? "#0f172a" : "#f8fafc",
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="onboarding" />
<Stack.Screen name="profile" />
<Stack.Screen name="guilds" />
<Stack.Screen name="guilds/[guildId]" />
<Stack.Screen name="access-check" />
<Stack.Screen name="access-scanner" />
<Stack.Screen name="settings" />
<Stack.Screen name="push-notification-setup" />
<Stack.Screen name="pending-changes" options={{ presentation: "modal" }} />
<Stack.Screen name="deep-link-error" />
</Stack>
</ScreenErrorBoundary>
<SyncCorrectionOverlay />
<SyncStatusBanner />
<OfflineBanner />
Expand Down
104 changes: 85 additions & 19 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,113 @@
import React, { Component, ErrorInfo, ReactNode } from "react";
import { View, Text, ScrollView } from "react-native";
import { Button } from "./Button";
import { logError, ErrorCategory, ErrorSeverity, DiagnosticInfo } from "../lib/errorLogger";

interface Props {
children: ReactNode;
context?: string;
fallback?: ReactNode;
onError?: (diagnostic: DiagnosticInfo) => void;
}

interface State {
hasError: boolean;
error: Error | null;
diagnostic: DiagnosticInfo | null;
}

/**
* App-wide error boundary that catches unexpected rendering errors
* and presents a safe recovery screen instead of crashing.
* App-wide error boundary that catches unexpected rendering errors,
* logs structured diagnostic information, and presents a safe recovery
* screen instead of crashing.
*
* Supports:
* - Automatic error classification (render, network, storage, etc.)
* - Structured diagnostic logging
* - Custom fallback UI per boundary
* - Recovery actions (retry, go home)
* - Error callback for external reporting
*
* Does not log wallet addresses, private keys, or other sensitive user data.
*/
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
this.state = { hasError: false, diagnostic: null };
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
return { hasError: true, diagnostic: null };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// In development, log the error and component stack for debugging.
// We intentionally avoid logging any user-specific data.
if (__DEV__) {
console.error("[ErrorBoundary] Render error:", error.message);
console.error("[ErrorBoundary] Component stack:", errorInfo.componentStack);
}
const diagnostic = logError(
error,
{ componentStack: errorInfo.componentStack ?? undefined },
this.props.context ?? "render",
);

this.setState({ diagnostic });

this.props.onError?.(diagnostic);
}

handleRetry = (): void => {
this.setState({ hasError: false, error: null });
this.setState({ hasError: false, diagnostic: null });
};

getErrorMessage(): string {
if (__DEV__ && this.state.error) {
return this.state.error.message;
private getErrorMessage(): string {
if (__DEV__ && this.state.diagnostic) {
return this.state.diagnostic.message;
}
return "An unexpected error occurred. Please try again.";
}

private getCategoryLabel(): string {
if (!this.state.diagnostic) return "Error";
switch (this.state.diagnostic.category) {
case ErrorCategory.NETWORK:
return "Network Error";
case ErrorCategory.STORAGE:
return "Storage Error";
case ErrorCategory.WALLET:
return "Wallet Error";
case ErrorCategory.SYNC:
return "Sync Error";
case ErrorCategory.RENDER:
return "Rendering Error";
default:
return "Something went wrong";
}
}

private getRecoveryHint(): string | null {
if (!this.state.diagnostic) return null;
if (!this.state.diagnostic.recoverable) {
return "This error requires the app to be restarted.";
}
switch (this.state.diagnostic.category) {
case ErrorCategory.NETWORK:
return "Check your internet connection and try again.";
case ErrorCategory.STORAGE:
return "Local storage encountered an issue. Retrying may help.";
case ErrorCategory.WALLET:
return "A wallet operation failed. You may need to reconnect.";
case ErrorCategory.SYNC:
return "Sync encountered an issue. Retrying may help.";
default:
return null;
}
}

render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}

const recoveryHint = this.getRecoveryHint();
const isUnrecoverable = this.state.diagnostic?.recoverable === false;

return (
<View className="flex-1 bg-background" testID="error-boundary-fallback">
<ScrollView
Expand All @@ -60,16 +119,23 @@ export class ErrorBoundary extends Component<Props, State> {
}}
>
<Text className="text-error text-xl font-bold text-center mb-3">
Something went wrong
{this.getCategoryLabel()}
</Text>
<Text className="text-text-muted text-center mb-8 text-sm">
<Text className="text-text-muted text-center mb-2 text-sm">
{this.getErrorMessage()}
</Text>
{recoveryHint && (
<Text className="text-text-muted text-center mb-8 text-xs italic">
{recoveryHint}
</Text>
)}
{!recoveryHint && <View className="mb-8" />}
<Button
title="Try Again"
title={isUnrecoverable ? "Restart App" : "Try Again"}
onPress={this.handleRetry}
variant="primary"
variant={isUnrecoverable ? "danger" : "primary"}
testID="error-boundary-retry"
accessibilityHint="Attempt to recover from the error"
/>
</ScrollView>
</View>
Expand Down
110 changes: 110 additions & 0 deletions src/components/ErrorFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from "react";
import { View, Text } from "react-native";
import { Button } from "./Button";
import { ErrorCategory, ErrorSeverity } from "../lib/errorLogger";

interface ErrorFallbackProps {
category: ErrorCategory;
severity: ErrorSeverity;
message?: string;
onRetry?: () => void;
onGoHome?: () => void;
testID?: string;
}

const CATEGORY_MESSAGES: Record<ErrorCategory, { title: string; description: string }> = {
[ErrorCategory.RENDER]: {
title: "Display Error",
description: "A component failed to render. This has been logged for investigation.",
},
[ErrorCategory.NETWORK]: {
title: "Connection Lost",
description: "Unable to reach the server. Please check your internet connection.",
},
[ErrorCategory.STORAGE]: {
title: "Storage Error",
description:
"Local data storage encountered an issue. Some features may be temporarily unavailable.",
},
[ErrorCategory.WALLET]: {
title: "Wallet Error",
description: "A wallet operation failed. You may need to reconnect your wallet.",
},
[ErrorCategory.SYNC]: {
title: "Sync Error",
description: "Data synchronization encountered an issue. Your local data is safe.",
},
[ErrorCategory.UNKNOWN]: {
title: "Something Went Wrong",
description: "An unexpected error occurred. Please try again.",
},
};

export const ErrorFallback = ({
category,
severity,
message,
onRetry,
onGoHome,
testID,
}: ErrorFallbackProps) => {
const config = CATEGORY_MESSAGES[category];

return (
<View
className="flex-1 justify-center items-center p-6 bg-background"
testID={testID ?? "error-fallback"}
accessibilityLabel={`${config.title}: ${message ?? config.description}`}
>
{severity === ErrorSeverity.CRITICAL && (
<View className="bg-error/10 rounded-full w-16 h-16 justify-center items-center mb-4">
<Text className="text-error text-2xl font-bold">!</Text>
</View>
)}

<Text
className={`text-xl font-bold text-center mb-2 ${
severity === ErrorSeverity.CRITICAL ? "text-error" : "text-text-primary"
}`}
>
{config.title}
</Text>

<Text className="text-text-muted text-center mb-8 text-sm leading-5">
{message ?? config.description}
</Text>

<View className="w-full gap-3" testID="error-fallback-actions">
{onRetry && (
<Button
title="Try Again"
onPress={onRetry}
variant="primary"
testID="error-fallback-retry"
accessibilityHint="Attempt to recover from this error"
/>
)}
{onGoHome && (
<Button
title="Go to Home"
onPress={onGoHome}
variant="outline"
testID="error-fallback-home"
accessibilityHint="Navigate back to the home screen"
/>
)}
</View>

{__DEV__ && (
<View
className="mt-6 px-4 py-3 bg-slate-100 rounded-lg w-full"
testID="error-fallback-diagnostics"
>
<Text className="text-xs text-text-muted font-mono">
[{category}:{severity}] {message ?? "No additional details"}
</Text>
</View>
)}
</View>
);
};
Loading