Skip to content

Commit 9e6ac9e

Browse files
NateIsernclaude
andcommitted
feat(app): root error boundary and redacted crash log
A throw anywhere in the render tree unmounted the whole app and left a black screen: no message, no way back, and for a wallet no indication of whether funds were affected. There was no error boundary anywhere and no record of what happened. - ErrorBoundary wraps the app inside the theme provider, so the fallback is themed, and offers a retry that remounts the subtree. - ErrorUtils.setGlobalHandler catches everything off the render path, chaining to the previous handler so the dev red box still appears. - Entries are capped and persisted. Redaction is a security control, not cosmetics: this is a wallet, the log lives in unencrypted key-value storage and is meant to be readable and shareable, and an error thrown while a mnemonic, WIF or xprv was in scope can quote it in its message or a stack frame. crash-policy.ts holds the pure rules so they are unit-testable without pulling React Native into the test runner, matching the pin-attempts-policy split. Also fixes two render-phase side effects surfaced while wiring this up: app/_layout.tsx kicked off async work from the render body behind a ref guard - which already errored on web with "state update on a component that hasn't mounted yet" - and hid the splash the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c654f16 commit 9e6ac9e

6 files changed

Lines changed: 468 additions & 27 deletions

File tree

app/_layout.tsx

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,6 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
2626
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
2727
import { KeyboardProvider as NativeKeyboardProvider } from "react-native-keyboard-controller";
2828
import { SafeAreaProvider } from "react-native-safe-area-context";
29-
30-
// react-native-keyboard-controller ships no web build — its KeyboardControllerView
31-
// is a native-only component that breaks the flex height chain on web and leaves
32-
// all scrollables with 0 bounded height. Web has no virtual keyboard anyway, so
33-
// we pass children straight through.
34-
const KeyboardProvider =
35-
Platform.OS === "web"
36-
? ({ children }: { children: React.ReactNode }) => <>{children}</>
37-
: NativeKeyboardProvider;
3829
import { QueryClientProvider } from "@tanstack/react-query";
3930
import { BloomThemeProvider, useBloomTheme } from "@oxyhq/bloom/theme";
4031
import type { ThemeMode } from "@oxyhq/bloom/theme";
@@ -44,6 +35,8 @@ import { useExplorerRealtime } from "../src/hooks/useExplorerRealtime";
4435
import { useWalletStore } from "../src/wallet/wallet-store";
4536
import { useLockStore } from "../src/wallet/lock-store";
4637
import { LockGate } from "../src/ui/components/LockGate";
38+
import { ErrorBoundary } from "../src/ui/components/ErrorBoundary";
39+
import { installCrashHandler } from "../src/services/crash-log";
4740
import { getAutoLockTimeout } from "../src/storage/secure-store";
4841
import { initLanguage } from "../src/i18n";
4942
import { useLanguageStore } from "../src/i18n/store";
@@ -54,6 +47,15 @@ import { startPushRegistration } from "../src/services/push-registration";
5447
import { handleIncomingPush } from "../src/services/push-handler";
5548
import { registerBackgroundSync } from "../src/services/background-sync";
5649

50+
// react-native-keyboard-controller ships no web build — its KeyboardControllerView
51+
// is a native-only component that breaks the flex height chain on web and leaves
52+
// all scrollables with 0 bounded height. Web has no virtual keyboard anyway, so
53+
// we pass children straight through.
54+
const KeyboardProvider =
55+
Platform.OS === "web"
56+
? ({ children }: { children: React.ReactNode }) => <>{children}</>
57+
: NativeKeyboardProvider;
58+
5759
// Module-level initialization. Resolves the persisted or device language,
5860
// then syncs the reactive store so React components see the correct value.
5961
const languageInitPromise = initLanguage()
@@ -65,6 +67,10 @@ const languageInitPromise = initLanguage()
6567
useLanguageStore.getState().hydrate();
6668
});
6769

70+
// Capture uncaught JS errors before anything else runs, so a crash during the
71+
// module-scope startup below is still recorded.
72+
installCrashHandler();
73+
6874
// Start watching wallet transactions for incoming-payment alerts. Must run
6975
// before any wallet state is hydrated so the subscriber sees every new tx
7076
// beyond the initial snapshot.
@@ -90,6 +96,12 @@ SplashScreen.preventAutoHideAsync().catch(() => {
9096

9197
const THEME_MODE_KEY = "fairwallet_theme_mode";
9298

99+
// Read the persisted theme at module scope, alongside `languageInitPromise`, so
100+
// the storage round-trip is already in flight before the first render instead of
101+
// being kicked off from inside it (a render-phase side effect that React 19
102+
// rejects with "state update on a component that hasn't mounted yet").
103+
const themeModePromise = getItemAsync(THEME_MODE_KEY).catch(() => null);
104+
93105
// ---------------------------------------------------------------------------
94106
// Hooks
95107
// ---------------------------------------------------------------------------
@@ -246,19 +258,22 @@ export default function RootLayout() {
246258
const [languageReady, setLanguageReady] = useState(false);
247259
const language = useLanguageStore((s) => s.language);
248260

249-
const hydrated = useRef(false);
250-
if (!hydrated.current) {
251-
hydrated.current = true;
252-
getItemAsync(THEME_MODE_KEY).then((stored) => {
261+
useEffect(() => {
262+
let active = true;
263+
themeModePromise.then((stored) => {
264+
if (!active) return;
253265
if (stored === "light" || stored === "dark" || stored === "system") {
254266
setMode(stored);
255267
}
256268
setThemeReady(true);
257269
});
258270
languageInitPromise.then(() => {
259-
setLanguageReady(true);
271+
if (active) setLanguageReady(true);
260272
});
261-
}
273+
return () => {
274+
active = false;
275+
};
276+
}, []);
262277

263278
const handleModeChange = useCallback((next: ThemeMode) => {
264279
setMode(next);
@@ -277,10 +292,15 @@ export default function RootLayout() {
277292
>
278293
<BottomSheetModalProvider>
279294
<QueryClientProvider client={queryClient}>
280-
<AppContent
281-
key={language}
282-
ready={fontsLoaded && themeReady && languageReady}
283-
/>
295+
{/* Inside the theme provider so the fallback screen is themed,
296+
and around AppContent so a throw in any screen is contained
297+
instead of unmounting the app to a black screen. */}
298+
<ErrorBoundary>
299+
<AppContent
300+
key={language}
301+
ready={fontsLoaded && themeReady && languageReady}
302+
/>
303+
</ErrorBoundary>
284304
</QueryClientProvider>
285305
</BottomSheetModalProvider>
286306
</BloomThemeProvider>
@@ -297,12 +317,14 @@ function AppContent({ ready }: { ready: boolean }) {
297317
// Overview's network stats tick live off the WebSocket.
298318
useExplorerRealtime();
299319

300-
// Hide splash screen once fonts and theme are loaded
301-
const splashHidden = useRef(false);
302-
if (ready && !splashHidden.current) {
303-
splashHidden.current = true;
304-
SplashScreen.hideAsync();
305-
}
320+
// Hide the splash once fonts and theme are loaded. `ready` only ever flips
321+
// false → true, so this runs exactly once.
322+
useEffect(() => {
323+
if (!ready) return;
324+
SplashScreen.hideAsync().catch(() => {
325+
// Already hidden, or no activity attached yet (dev-client reload).
326+
});
327+
}, [ready]);
306328

307329
return (
308330
<View style={{ flex: 1 }}>

src/i18n/index.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ const translations: Record<TranslatedLanguage, Record<string, string>> = {
5959
"common.back": "Back",
6060
"common.close": "Close",
6161
"common.retry": "Retry",
62+
63+
// ---------- Crash recovery ----------
64+
"crash.title": "Something went wrong",
65+
"crash.subtitle":
66+
"Your coins are safe — they live on the FairCoin network, not in this screen. Try again, and if it keeps happening, restart the app.",
67+
"crash.retry": "Try again",
6268
"common.edit": "Edit",
6369
"common.import": "Import",
6470
"common.create": "Create",
@@ -513,7 +519,14 @@ const translations: Record<TranslatedLanguage, Record<string, string>> = {
513519
"A Pocket is a separate balance inside your wallet. It's still yours (self-custody), just organized.",
514520
"pockets.create.nameLabel": "POCKET NAME",
515521
"pockets.create.namePlaceholder": "e.g. Savings",
516-
"pockets.create.emojiLabel": "EMOJI",
522+
"pockets.create.imageLabel": "IMAGE",
523+
"pockets.create.addImage": "Add image",
524+
"pockets.create.changeImage": "Change image",
525+
"pockets.create.removeImage": "Remove",
526+
"pockets.create.imageSourceTitle": "Choose image",
527+
"pockets.create.gallery": "Gallery",
528+
"pockets.create.camera": "Camera",
529+
"pockets.create.permissionDenied": "Permission needed to pick an image",
517530
"pockets.create.colorLabel": "COLOR",
518531
"pockets.create.goalLabel": "GOAL (OPTIONAL)",
519532
"pockets.create.goalPlaceholder": "0.00",
@@ -869,6 +882,12 @@ const translations: Record<TranslatedLanguage, Record<string, string>> = {
869882
"common.back": "Atr\u00e1s",
870883
"common.close": "Cerrar",
871884
"common.retry": "Reintentar",
885+
886+
// ---------- Recuperación tras un fallo ----------
887+
"crash.title": "Algo ha ido mal",
888+
"crash.subtitle":
889+
"Tus monedas están a salvo: viven en la red FairCoin, no en esta pantalla. Inténtalo de nuevo y, si sigue ocurriendo, reinicia la app.",
890+
"crash.retry": "Reintentar",
872891
"common.edit": "Editar",
873892
"common.import": "Importar",
874893
"common.create": "Crear",
@@ -1345,7 +1364,15 @@ const translations: Record<TranslatedLanguage, Record<string, string>> = {
13451364
"Una pocket es un saldo aparte dentro de tu wallet. Sigue siendo tuyo (self-custody), solo organizado.",
13461365
"pockets.create.nameLabel": "NOMBRE DEL BOLSILLO",
13471366
"pockets.create.namePlaceholder": "p. ej. Ahorros",
1348-
"pockets.create.emojiLabel": "EMOJI",
1367+
"pockets.create.imageLabel": "IMAGEN",
1368+
"pockets.create.addImage": "Añadir imagen",
1369+
"pockets.create.changeImage": "Cambiar imagen",
1370+
"pockets.create.removeImage": "Quitar",
1371+
"pockets.create.imageSourceTitle": "Elegir imagen",
1372+
"pockets.create.gallery": "Galería",
1373+
"pockets.create.camera": "Cámara",
1374+
"pockets.create.permissionDenied":
1375+
"Se necesita permiso para elegir una imagen",
13491376
"pockets.create.colorLabel": "COLOR",
13501377
"pockets.create.goalLabel": "OBJETIVO (OPCIONAL)",
13511378
"pockets.create.goalPlaceholder": "0.00",

src/services/crash-log.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Crash capture for FAIRWallet.
3+
*
4+
* The app had no error boundary and no global handler: a throw during render
5+
* left a black screen with no recovery path and no record of what happened.
6+
* This module is the record-keeping half — `ErrorBoundary` is the recovery
7+
* half, and `crash-policy.ts` holds the pure redaction/trimming rules.
8+
*
9+
* Two sources feed it:
10+
*
11+
* 1. `ErrorBoundary.componentDidCatch` — render/lifecycle throws.
12+
* 2. `ErrorUtils.setGlobalHandler` — everything else on the JS thread
13+
* (uncaught promise rejections surfaced by the runtime, native-module
14+
* callbacks, timers). The previous handler is always chained so the red
15+
* box still appears in development.
16+
*/
17+
18+
import { getItemAsync, setItemAsync, deleteItemAsync } from "../storage/kv-store";
19+
import {
20+
appendCrashEntry,
21+
toCrashEntry,
22+
type CrashEntry,
23+
} from "./crash-policy";
24+
25+
const CRASH_LOG_KEY = "fairwallet_crash_log";
26+
27+
// ---------------------------------------------------------------------------
28+
// Persistence
29+
// ---------------------------------------------------------------------------
30+
31+
function parseCrashLog(raw: string | null): CrashEntry[] {
32+
if (!raw) return [];
33+
try {
34+
const parsed: unknown = JSON.parse(raw);
35+
if (!Array.isArray(parsed)) return [];
36+
return parsed.filter((item): item is CrashEntry => {
37+
if (typeof item !== "object" || item === null) return false;
38+
const candidate = item as Partial<CrashEntry>;
39+
return (
40+
typeof candidate.at === "number" &&
41+
typeof candidate.name === "string" &&
42+
typeof candidate.message === "string"
43+
);
44+
});
45+
} catch {
46+
// A corrupt log must never block the app or the next crash write.
47+
return [];
48+
}
49+
}
50+
51+
export async function getCrashLog(): Promise<CrashEntry[]> {
52+
return parseCrashLog(await getItemAsync(CRASH_LOG_KEY));
53+
}
54+
55+
export async function clearCrashLog(): Promise<void> {
56+
await deleteItemAsync(CRASH_LOG_KEY);
57+
}
58+
59+
/**
60+
* Record a crash. Never throws: it runs from an error path, so a storage
61+
* failure here must not mask the original error.
62+
*/
63+
export async function recordCrash(
64+
error: unknown,
65+
fatal: boolean,
66+
): Promise<void> {
67+
try {
68+
const entry = toCrashEntry(error, fatal, Math.floor(Date.now() / 1000));
69+
const existing = await getCrashLog();
70+
await setItemAsync(
71+
CRASH_LOG_KEY,
72+
JSON.stringify(appendCrashEntry(existing, entry)),
73+
);
74+
} catch {
75+
// Best effort by design.
76+
}
77+
}
78+
79+
// ---------------------------------------------------------------------------
80+
// Global handler
81+
// ---------------------------------------------------------------------------
82+
83+
type ErrorHandler = (error: unknown, isFatal?: boolean) => void;
84+
85+
interface GlobalErrorUtils {
86+
setGlobalHandler(callback: ErrorHandler): void;
87+
getGlobalHandler(): ErrorHandler | undefined;
88+
}
89+
90+
/** React Native installs `ErrorUtils` on the global object; web has none. */
91+
function getErrorUtils(): GlobalErrorUtils | null {
92+
const holder = globalThis as { ErrorUtils?: GlobalErrorUtils };
93+
return holder.ErrorUtils ?? null;
94+
}
95+
96+
let installed = false;
97+
98+
/**
99+
* Install the global JS error handler. Idempotent, and a no-op on platforms
100+
* that do not expose `ErrorUtils` (web / Electron renderer).
101+
*/
102+
export function installCrashHandler(): void {
103+
if (installed) return;
104+
const errorUtils = getErrorUtils();
105+
if (!errorUtils) return;
106+
installed = true;
107+
108+
const previous = errorUtils.getGlobalHandler();
109+
errorUtils.setGlobalHandler((error, isFatal) => {
110+
void recordCrash(error, isFatal ?? true);
111+
// Chain so the dev red box / default fatal handling still happens.
112+
previous?.(error, isFatal);
113+
});
114+
}

0 commit comments

Comments
 (0)