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
2 changes: 2 additions & 0 deletions src/components/common/SectionErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { markErrorAsCaught } from '@/utils/globalErrorHandler.utils';

interface Props {
children: ReactNode;
Expand All @@ -26,6 +27,7 @@ class SectionErrorBoundary extends Component<Props, State> {
}

public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
markErrorAsCaught(error);
console.error(`Uncaught error in section ${this.props.sectionName || 'Unknown'}:`, error, errorInfo);
}

Expand Down
223 changes: 223 additions & 0 deletions src/utils/__tests__/globalErrorHandler.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import {
initGlobalErrorHandler,
handleGlobalError,
markErrorAsCaught,
wasErrorAlreadyCaught,
emitStructuredLog,
resetErrorTracking,
} from '@/utils/globalErrorHandler.utils';

describe('Global Error Handler', () => {
beforeEach(() => {
resetErrorTracking();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('initGlobalErrorHandler', () => {
it('sets window.onerror to handleGlobalError', () => {
initGlobalErrorHandler();
expect(window.onerror).toBe(handleGlobalError);
});

it('does not throw when called multiple times', () => {
initGlobalErrorHandler();
expect(() => initGlobalErrorHandler()).not.toThrow();
expect(window.onerror).toBe(handleGlobalError);
});
});

describe('handleGlobalError', () => {
it('does not emit a structured log for errors already caught by error boundary', () => {
const error = new Error('Render error in creator list');
markErrorAsCaught(error);

const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

const result = handleGlobalError('Render error in creator list', 'LandingPage.tsx', 852, 10, error);

expect(logSpy).not.toHaveBeenCalled();
expect(result).toBe(true); // Prevent default browser handling
});

it('emits a structured log for unhandled errors outside React', () => {
const error = new Error('Timeout error in setTimeout callback');
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

const result = handleGlobalError('Timeout error in setTimeout callback', 'app.js', 15, 3, error);

expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', {
message: 'Timeout error in setTimeout callback',
source: 'app.js',
line: 15,
column: 3,
error: {
name: 'Error',
message: 'Timeout error in setTimeout callback',
stack: expect.any(String),
},
timestamp: expect.any(Number),
});
expect(result).toBe(false); // Allow default browser handling
});

it('does not emit duplicate structured logs for the same error identity', () => {
const error = new Error('Duplicate error');
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

handleGlobalError('Duplicate error', 'app.js', 10, 5, error);
handleGlobalError('Duplicate error', 'app.js', 10, 5, error);

expect(logSpy).toHaveBeenCalledTimes(1);
});

it('emits structured logs for Error events without an Error object', () => {
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

handleGlobalError('Script error', 'unknown.js');

expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', {
message: 'Script error',
source: 'unknown.js',
line: undefined,
column: undefined,
error: null,
timestamp: expect.any(Number),
});
});

it('allows both unhandled and boundary-caught errors to work simultaneously without conflict', () => {
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

// Boundary catches an error
const caughtError = new Error('Boundary caught error');
markErrorAsCaught(caughtError);

// An unrelated unhandled error occurs elsewhere
const unhandledError = new Error('Unhandled timeout error');

handleGlobalError('Unhandled timeout error', 'app.js', 15, 3, unhandledError);
handleGlobalError('Boundary caught error', 'LandingPage.tsx', 852, 10, caughtError);

// Only the unhandled error should be logged, once
expect(logSpy).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(
'[Global Error Handler]',
expect.objectContaining({
error: expect.objectContaining({
message: 'Unhandled timeout error',
}),
})
);
});

it('handles string message errors without an Error object', () => {
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

handleGlobalError('String error message');

expect(logSpy).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(
'[Global Error Handler]',
expect.objectContaining({
message: 'String error message',
error: null,
})
);
});
});

describe('emitStructuredLog', () => {
it('outputs a structured error log with all fields', () => {
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('detailed error');

emitStructuredLog({
message: 'detailed error',
source: 'test.js',
lineno: 42,
colno: 10,
error,
timestamp: 1234567890,
});

expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', {
message: 'detailed error',
source: 'test.js',
line: 42,
column: 10,
error: {
name: 'Error',
message: 'detailed error',
stack: error.stack,
},
timestamp: 1234567890,
});
});

it('handles null error gracefully', () => {
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

emitStructuredLog({
message: 'Script error',
source: 'unknown.js',
lineno: 0,
colno: 0,
error: undefined,
timestamp: 1234567890,
});

expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', {
message: 'Script error',
source: 'unknown.js',
line: 0,
column: 0,
error: null,
timestamp: 1234567890,
});
});
});

describe('markErrorAsCaught and wasErrorAlreadyCaught', () => {
it('correctly marks and identifies caught errors', () => {
const error = new Error('test error');

expect(wasErrorAlreadyCaught(error)).toBe(false);

markErrorAsCaught(error);

expect(wasErrorAlreadyCaught(error)).toBe(true);
});

it('identifies errors with same message and stack as caught', () => {
const error1 = new Error('same error');
const error2 = new Error('same error');

// They should have the same stack (same line in test)
error2.stack = error1.stack;

markErrorAsCaught(error1);

expect(wasErrorAlreadyCaught(error2)).toBe(true);
});

it('distinguishes different errors', () => {
const error1 = new Error('error one');
const error2 = new Error('error two');

markErrorAsCaught(error1);

expect(wasErrorAlreadyCaught(error2)).toBe(false);
});

it('marks errors as caught via the mock error boundary integration', () => {
const error = new Error('Error caught by SectionErrorBoundary');
markErrorAsCaught(error);

expect(wasErrorAlreadyCaught(error)).toBe(true);
});
});
});
126 changes: 126 additions & 0 deletions src/utils/globalErrorHandler.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Global error handler utilities to prevent duplicate error logging
* between React error boundaries and window.onerror.
*
* Architecture:
* - React error boundaries call `markErrorAsCaught(error)` in componentDidCatch
* - The global `window.onerror` handler checks if the error was already caught
* - If caught, the handler silently returns true (preventing default browser handling)
* - If not caught, it emits a structured log for the genuine unhandled error
* - Duplicate identity detection prevents the same error from being logged more than once
*/

interface StructuredErrorLog {
message: string | Event;
source?: string;
lineno?: number;
colno?: number;
error?: Error;
timestamp: number;
}

/** Generate a unique identity for an error based on its message and stack. */
function getErrorIdentity(error: Error): string {
return `${error.message}::${error.stack ?? ''}`;
}

/** Track error identities already caught by React error boundaries. */
const caughtErrorIds = new Set<string>();

/** Track error identities already processed by the global handler (prevents duplicates). */
const processedErrorIds = new Set<string>();

/**
* Register an error as already caught by a React error boundary.
* The global error handler will skip this error to avoid duplicate logs.
*/
export function markErrorAsCaught(error: Error): void {
caughtErrorIds.add(getErrorIdentity(error));
}

/**
* Check if an error was already caught by a React error boundary.
*/
export function wasErrorAlreadyCaught(error: Error): boolean {
return caughtErrorIds.has(getErrorIdentity(error));
}

/**
* Emit a structured error log with all relevant metadata.
* Testable in isolation by spying on console.error.
*/
export function emitStructuredLog(log: StructuredErrorLog): void {
console.error('[Global Error Handler]', {
message: log.message,
source: log.source,
line: log.lineno,
column: log.colno,
error: log.error
? {
name: log.error.name,
message: log.error.message,
stack: log.error.stack,
}
: null,
timestamp: log.timestamp,
});
}

/**
* Handle a global error event.
* - If the error was already caught by a React error boundary, skip it and return true
* - If the error is a duplicate (same identity already processed), skip it and return true
* - Otherwise, emit a structured log for the genuine unhandled error
*
* @returns true to prevent default browser error handling, false otherwise.
*/
export function handleGlobalError(
message: string | Event,
source?: string,
lineno?: number,
colno?: number,
error?: Error
): boolean {
// Skip errors already caught by React error boundaries
if (error && wasErrorAlreadyCaught(error)) {
return true;
}

// Skip duplicate errors (same identity already processed)
if (error) {
const identity = getErrorIdentity(error);
if (processedErrorIds.has(identity)) {
return true;
}
processedErrorIds.add(identity);
}

// Emit structured log for genuine unhandled errors
emitStructuredLog({
message,
source,
lineno,
colno,
error,
timestamp: Date.now(),
});

return false;
}

/**
* Initialize the global error handler on window.onerror.
* Should be called once at application startup (e.g., in main.tsx).
*/
export function initGlobalErrorHandler(): void {
window.onerror = handleGlobalError;
}

/**
* Clear all tracked error identities.
* Useful in tests to reset state between test cases.
*/
export function resetErrorTracking(): void {
caughtErrorIds.clear();
processedErrorIds.clear();
}
Loading