Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/add-error-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@darkresearch/mallory-client": minor
---

Add React Error Boundaries to prevent app crashes

Implemented error boundaries at three levels (root, feature, component) to provide graceful error handling and prevent the app from crashing to a blank screen. Added `react-error-boundary` package and created reusable ErrorBoundary components with fallback UI.
48 changes: 48 additions & 0 deletions apps/client/__tests__/unit/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Unit Tests for ErrorBoundary Component Structure
*
* Note: Full rendering tests are skipped because Bun test runner
* doesn't support React Native's Flow syntax out of the box.
* We verify structure and imports instead.
*/

import { describe, test, expect } from 'bun:test';
import fs from 'fs';
import path from 'path';

describe('ErrorBoundary Component', () => {
const componentPath = path.join(__dirname, '../../components/ui/ErrorBoundary.tsx');
const fallbackPath = path.join(__dirname, '../../components/ui/ErrorFallback.tsx');

test('should exist', () => {
expect(fs.existsSync(componentPath)).toBe(true);
expect(fs.existsSync(fallbackPath)).toBe(true);
});

test('ErrorBoundary should import react-error-boundary', () => {
const content = fs.readFileSync(componentPath, 'utf-8');
expect(content).toContain('react-error-boundary');
expect(content).toContain('ErrorBoundary');
expect(content).toContain('ErrorFallback');
});

test('ErrorBoundary should handle different levels', () => {
const content = fs.readFileSync(componentPath, 'utf-8');
expect(content).toContain("level = 'component'");
expect(content).toContain("name = 'Unknown'");
expect(content).toContain('console.error'); // Should log errors
});

test('ErrorFallback should have retry button', () => {
const content = fs.readFileSync(fallbackPath, 'utf-8');
expect(content).toContain('TouchableOpacity');
expect(content).toContain('onPress={resetErrorBoundary}');
expect(content).toContain('Try Again');
});

test('ErrorFallback should handle root level', () => {
const content = fs.readFileSync(fallbackPath, 'utf-8');
expect(content).toContain("level === 'root'");
expect(content).toContain('Reload App');
});
});
64 changes: 33 additions & 31 deletions apps/client/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import { ActiveConversationProvider } from '../contexts/ActiveConversationContex
import { initializeComponentRegistry } from '../components/registry';
import { DataPreloader } from '../components/DataPreloader';
import { ChatManager } from '../components/chat/ChatManager';
import 'react-native-url-polyfill/auto';
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/react';
import { ErrorBoundary } from '../components/ui/ErrorBoundary';

// Keep the splash screen visible while we fetch resources
SplashScreen.preventAutoHideAsync();
Expand All @@ -39,7 +39,7 @@ export default function RootLayout() {
// Initialize component registry for dynamic UI
console.log('🔧 Initializing component registry...');
initializeComponentRegistry();

// Set up status bar for light theme
StatusBar.setBarStyle('dark-content');
if (Platform.OS === 'android') {
Expand All @@ -59,34 +59,36 @@ export default function RootLayout() {
}

return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: '#FFEFE3' }}>
<SafeAreaProvider style={{ flex: 1, backgroundColor: '#FFEFE3' }}>
<AuthProvider>
<GridProvider>
<WalletProvider>
<ActiveConversationProvider>
<DataPreloader />
<ChatManager />
<View style={{ flex: 1, backgroundColor: '#FFEFE3', minHeight: '100vh' as any, overflow: 'hidden' }}>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#FFEFE3' },
animation: 'fade',
}}
/>
{Platform.OS === 'web' && (
<>
<Analytics />
<SpeedInsights />
</>
)}
</View>
</ActiveConversationProvider>
</WalletProvider>
</GridProvider>
</AuthProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
<ErrorBoundary level="root" name="RootLayout">
<GestureHandlerRootView style={{ flex: 1, backgroundColor: '#FFEFE3' }}>
<SafeAreaProvider style={{ flex: 1, backgroundColor: '#FFEFE3' }}>
<AuthProvider>
<GridProvider>
<WalletProvider>
<ActiveConversationProvider>
<DataPreloader />
<ChatManager />
<View style={{ flex: 1, backgroundColor: '#FFEFE3', minHeight: '100vh' as any, overflow: 'hidden' }}>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#FFEFE3' },
animation: 'fade',
}}
/>
{Platform.OS === 'web' && (
<>
<Analytics />
<SpeedInsights />
</>
)}
</View>
</ActiveConversationProvider>
</WalletProvider>
</GridProvider>
</AuthProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
</ErrorBoundary>
);
}
18 changes: 15 additions & 3 deletions apps/client/components/DataPreloader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useAuth } from '../contexts/AuthContext';
import { useChatHistoryData } from '../hooks/useChatHistoryData';
import { ErrorBoundary } from './ui/ErrorBoundary';

/**
* DataPreloader Component
Expand All @@ -13,13 +14,24 @@ import { useChatHistoryData } from '../hooks/useChatHistoryData';
* - Real-time subscriptions keep cache fresh
* - Doesn't render anything (invisible to user)
*/
export function DataPreloader() {
function DataPreloaderInner() {
const { user } = useAuth();

// Pre-load chat history data in background
// This populates the module-level cache in useChatHistoryData
useChatHistoryData(user?.id);

// Don't render anything
return null;
}

/**
* DataPreloader with Error Boundary
*/
export function DataPreloader() {
return (
<ErrorBoundary level="feature" name="DataPreloader">
<DataPreloaderInner />
</ErrorBoundary>
);
}
Loading
Loading