Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🌄 feat: Add RouteErrorBoundary for Improved Client Error handling #5396

Merged
merged 3 commits into from
Jan 24, 2025
Merged
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 client/package.json
Original file line number Diff line number Diff line change
@@ -79,6 +79,7 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-error-boundary": "^5.0.0",
"react-flip-toolkit": "^7.1.0",
"react-gtm-module": "^2.0.11",
"react-hook-form": "^7.43.9",
216 changes: 216 additions & 0 deletions client/src/routes/RouteErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { useRouteError } from 'react-router-dom';
import { Button } from '~/components/ui';

interface UserAgentData {
getHighEntropyValues(hints: string[]): Promise<{ platform: string; platformVersion: string }>;
}

type PlatformInfo = {
os: string;
version?: string;
};

const formatStackTrace = (stack: string) => {
return stack
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line, i) => ({
number: i + 1,
content: line,
}));
};

const getPlatformInfo = async (): Promise<PlatformInfo> => {
if ('userAgentData' in navigator) {
try {
const ua = navigator.userAgentData as UserAgentData;
const highEntropyValues = await ua.getHighEntropyValues(['platform', 'platformVersion']);
return {
os: highEntropyValues.platform,
version: highEntropyValues.platformVersion,
};
} catch (e) {
console.warn('Failed to get high entropy values:', e);
}
}

const userAgent = navigator.userAgent.toLowerCase();

if (userAgent.includes('mac')) {
return { os: 'macOS' };
}
if (userAgent.includes('win')) {
return { os: 'Windows' };
}
if (userAgent.includes('linux')) {
return { os: 'Linux' };
}
if (userAgent.includes('android')) {
return { os: 'Android' };
}
if (userAgent.includes('ios') || userAgent.includes('iphone') || userAgent.includes('ipad')) {
return { os: 'iOS' };
}

return { os: 'Unknown' };
};

const getBrowserInfo = async () => {
const platformInfo = await getPlatformInfo();
return {
userAgent: navigator.userAgent,
platform: platformInfo.os,
platformVersion: platformInfo.version,
language: navigator.language,
windowSize: `${window.innerWidth}x${window.innerHeight}`,
};
};

export default function RouteErrorBoundary() {
const typedError = useRouteError() as {
message?: string;
stack?: string;
status?: number;
statusText?: string;
data?: unknown;
};

const errorDetails = {
message: typedError.message ?? 'An unexpected error occurred',
stack: typedError.stack,
status: typedError.status,
statusText: typedError.statusText,
data: typedError.data,
};

const handleDownloadLogs = async () => {
const browser = await getBrowserInfo();
const errorLog = {
timestamp: new Date().toISOString(),
browser,
error: {
...errorDetails,
stack:
errorDetails.stack != null && errorDetails.stack.trim() !== ''
? formatStackTrace(errorDetails.stack)
: undefined,
},
};

const blob = new Blob([JSON.stringify(errorLog, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `error-log-${new Date().toISOString()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

const handleCopyStack = async () => {
if (errorDetails.stack != null && errorDetails.stack !== '') {
await navigator.clipboard.writeText(errorDetails.stack);
}
};

return (
<div
role="alert"
className="flex min-h-screen flex-col items-center justify-center bg-surface-primary bg-gradient-to-br"
>
<div className="bg-surface-primary/60 mx-4 w-11/12 max-w-4xl rounded-2xl border border-border-light p-8 shadow-2xl backdrop-blur-xl">
<h2 className="mb-6 text-center text-3xl font-medium tracking-tight text-text-primary">
Oops! Something Unexpected Occurred
</h2>

{/* Error Message */}
<div className="mb-4 rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-sm text-gray-600 dark:text-gray-200">
<h3 className="mb-2 font-medium">Error Message:</h3>
<pre className="whitespace-pre-wrap text-sm font-light leading-relaxed text-text-primary">
{errorDetails.message}
</pre>
</div>

{/* Status Information */}
{(typeof errorDetails.status === 'number' ||
typeof errorDetails.statusText === 'string') && (
<div className="mb-4 rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4 text-sm text-text-primary">
<h3 className="mb-2 font-medium">Status:</h3>
<p className="text-text-primary">
{typeof errorDetails.status === 'number' && `${errorDetails.status} `}
{typeof errorDetails.statusText === 'string' && errorDetails.statusText}
</p>
</div>
)}

{/* Stack Trace - Collapsible */}
{errorDetails.stack != null && errorDetails.stack.trim() !== '' && (
<details className="group mb-4 rounded-xl border border-border-light p-4">
<summary className="mb-2 flex cursor-pointer items-center justify-between text-sm font-medium text-text-primary">
<span>Stack Trace</span>
<div className="flex items-center">
<Button
variant="outline"
size="sm"
onClick={handleCopyStack}
className="ml-2 px-2 py-1 text-xs"
>
Copy
</Button>
</div>
</summary>
<div className="overflow-x-auto rounded-lg bg-black/5 p-4 dark:bg-white/5">
{formatStackTrace(errorDetails.stack).map(({ number, content }) => (
<div key={number} className="flex">
<span className="select-none pr-4 font-mono text-xs text-text-secondary">
{String(number).padStart(3, '0')}
</span>
<pre className="flex-1 font-mono text-xs leading-relaxed text-text-primary">
{content}
</pre>
</div>
))}
</div>
</details>
)}

{/* Additional Error Data */}
{errorDetails.data != null && (
<details className="group mb-4 rounded-xl border border-border-light p-4">
<summary className="mb-2 flex cursor-pointer items-center justify-between text-sm font-medium text-text-primary">
<span>Additional Details</span>
<span className="transition-transform group-open:rotate-90">{'>'}</span>
</summary>
<pre className="whitespace-pre-wrap text-xs font-light leading-relaxed text-text-primary">
{JSON.stringify(errorDetails.data, null, 2)}
</pre>
</details>
)}

<div className="mt-6 flex flex-col gap-4">
<p className="text-sm font-light text-text-secondary">Please try one of the following:</p>
<ul className="list-inside list-disc text-sm text-text-secondary">
<li>Refresh the page</li>
<li>Clear your browser cache</li>
<li>Check your internet connection</li>
<li>Contact the Admin if the issue persists</li>
</ul>
<div className="mt-4 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
<Button
variant="submit"
onClick={() => window.location.reload()}
className="w-full sm:w-auto"
>
Refresh Page
</Button>
<Button variant="outline" onClick={handleDownloadLogs} className="w-full sm:w-auto">
Download Error Logs
</Button>
</div>
</div>
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions client/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import {
ApiErrorWatcher,
} from '~/components/Auth';
import { AuthContextProvider } from '~/hooks/AuthContext';
import RouteErrorBoundary from './RouteErrorBoundary';
import StartupLayout from './Layouts/Startup';
import LoginLayout from './Layouts/Login';
import dashboardRoutes from './Dashboard';
@@ -27,10 +28,12 @@ export const router = createBrowserRouter([
{
path: 'share/:shareId',
element: <ShareRoute />,
errorElement: <RouteErrorBoundary />,
},
{
path: '/',
element: <StartupLayout />,
errorElement: <RouteErrorBoundary />,
children: [
{
path: 'register',
@@ -49,9 +52,11 @@ export const router = createBrowserRouter([
{
path: 'verify',
element: <VerifyEmail />,
errorElement: <RouteErrorBoundary />,
},
{
element: <AuthLayout />,
errorElement: <RouteErrorBoundary />,
children: [
{
path: '/',
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.