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
1 change: 1 addition & 0 deletions mobileapp/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ yarn-error.*
# generated native folders
/ios
/android
mobileapp/package-lock.json
102 changes: 102 additions & 0 deletions mobileapp/__tests__/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React from "react";
import { Text } from "react-native";
import { render, fireEvent, act } from "@testing-library/react-native";
import { ErrorBoundary } from "../src/components/ErrorBoundary";
import { reportCrash } from "../src/services/crashReporter";

jest.mock("../src/services/crashReporter", () => ({
reportCrash: jest.fn().mockResolvedValue(undefined),
}));

function Bomb(): React.ReactElement {
throw new Error("boom");
}

/** Renders a crashing child until `armed` flips to false. */
function ToggleBomb({ armed }: { armed: boolean }): React.ReactElement {
if (armed) {
throw new Error("boom");
}
return <Text>Recovered</Text>;
}

describe("ErrorBoundary", () => {
const originalError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
});

it("renders children when no error is thrown", () => {
const { getByText } = render(
<ErrorBoundary>
<Text>Healthy content</Text>
</ErrorBoundary>
);
expect(getByText("Healthy content")).toBeTruthy();
});

it("catches render crashes and shows the friendly fallback", () => {
const { getByText } = render(
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
);
expect(getByText("Oops! Something went wrong")).toBeTruthy();
expect(getByText("Try Again")).toBeTruthy();
});

it("reports the crash via reportCrash with component stack", () => {
const spy = reportCrash as jest.Mock;
const { unmount } = render(
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
);
unmount();
expect(spy).toHaveBeenCalled();
const [error, componentStack] = spy.mock.calls[0];
expect(error.message).toBe("boom");
expect(componentStack).toEqual(expect.any(String));
});

it("resets the boundary state and invokes onReset when retry is pressed", async () => {
const onReset = jest.fn(() => {
void Promise.resolve();
});
function Harness(): React.ReactElement {
const [armed, setArmed] = React.useState(true);
return (
<ErrorBoundary
onReset={() => {
onReset();
setArmed(false);
}}
>
<ToggleBomb armed={armed} />
</ErrorBoundary>
);
}
const { getByText, queryByText } = render(<Harness />);
expect(getByText("Oops! Something went wrong")).toBeTruthy();

await act(async () => {
fireEvent.press(getByText("Try Again"));
});

expect(onReset).toHaveBeenCalled();
expect(getByText("Recovered")).toBeTruthy();
expect(queryByText("Oops! Something went wrong")).toBeNull();
});

it("renders a custom fallback when provided", () => {
const { getByText } = render(
<ErrorBoundary fallback={<Text>Custom fallback</Text>}>
<Bomb />
</ErrorBoundary>
);
expect(getByText("Custom fallback")).toBeTruthy();
});
});
10 changes: 6 additions & 4 deletions mobileapp/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "react-native-get-random-values";
import "react-native-url-polyfill/auto";
import React from "react";
import { Stack, useRouter, usePathname } from "expo-router";
import { Stack, useRouter, usePathname, router } from "expo-router";
import { StatusBar } from "expo-status-bar";
import {
Linking as RNLinking,
Expand Down Expand Up @@ -140,7 +140,9 @@ function LayoutContent() {
// first-time consent is asked contextually from the Home screen. This
// only silently refreshes the Expo push token for a user who has
// already granted permission in a previous session.
await registerForPushNotificationsAsync({ requestIfUndetermined: false });
await registerForPushNotificationsAsync({
requestIfUndetermined: false,
});
}
}

Expand Down Expand Up @@ -336,11 +338,11 @@ export default function Layout() {
appearance: { theme: "light" },
}}
>
<ErrorBoundary>
<ErrorBoundary onReset={() => router.replace("/(personal)/home")}>
<ToastProvider>
<LayoutContent />
</ToastProvider>
</ErrorBoundary>
</PrivyProvider>
);
}
}
9 changes: 8 additions & 1 deletion mobileapp/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import React, { Component, ReactNode } from "react";
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { COLORS } from "../constants/colors";
import { reportCrash } from "../services/crashReporter";

interface Props {
children: ReactNode;
fallback?: ReactNode;
/** Called when the user taps "Try Again" so the app can reset navigation. */
onReset?: () => void;
}

interface State {
Expand All @@ -24,11 +27,15 @@ export class ErrorBoundary extends Component<Props, State> {

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
// TODO: Send to error tracking service like Sentry
// Queue the crash (locally first, then fire-and-forget to the backend)
// so the team gets visibility into unhandled render failures. This must
// never throw — reporting failures are swallowed inside reportCrash.
void reportCrash(error, errorInfo.componentStack);
}

handleRetry = () => {
this.setState({ hasError: false, error: undefined });
this.props.onReset?.();
};

render() {
Expand Down
87 changes: 87 additions & 0 deletions mobileapp/src/services/crashReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import NetInfo from "@react-native-community/netinfo";

const CRASH_QUEUE_KEY = "zaps_crash_reports";
const API_BASE = process.env.EXPO_PUBLIC_API_URL ?? "https://api.zaps.app";
const MAX_QUEUED_REPORTS = 20;

export interface CrashReport {
message: string;
stack?: string;
componentStack?: string;
occurredAt: string;
}

function isNetworkError(error: Error): boolean {
return (
error.message.includes("Network request failed") ||
error.message.includes("Network Error")
);
}

async function readQueue(): Promise<CrashReport[]> {
try {
const raw = await AsyncStorage.getItem(CRASH_QUEUE_KEY);
return raw ? (JSON.parse(raw) as CrashReport[]) : [];
} catch {
return [];
}
}

/** Fire-and-forget POST of a crash report to the backend crash endpoint. */
export async function flushCrashReports(): Promise<void> {
const queue = await readQueue();
if (queue.length === 0) return;

try {
const res = await fetch(`${API_BASE}/api/v1/crashes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reports: queue }),
});
if (res.ok) {
await AsyncStorage.removeItem(CRASH_QUEUE_KEY);
}
} catch {
// Keep the queue for the next flush attempt (e.g. on reconnect).
}
}

/**
* Persist a crash report and attempt to send it to the backend.
*
* Reports are queued in AsyncStorage first (bounded) so that crashes recorded
* while offline are still delivered once connectivity returns.
*/
export async function reportCrash(
error: Error,
componentStack?: string | null | undefined
): Promise<void> {
try {
const queue = await readQueue();
const next: CrashReport[] = [
...queue,
{
message: error.message,
stack: error.stack,
componentStack: componentStack ?? undefined,
occurredAt: new Date().toISOString(),
},
].slice(-MAX_QUEUED_REPORTS);
await AsyncStorage.setItem(CRASH_QUEUE_KEY, JSON.stringify(next));

const state = await NetInfo.fetch();
if (!state.isConnected) return;

await flushCrashReports();
} catch {
// Crash reporting must never throw into the render path.
}
}

/** Convenience wrapper so callers can catch network failures transparently. */
export async function reportNetworkCrash(error: Error): Promise<void> {
if (isNetworkError(error)) {
await reportCrash(error);
}
}
Loading