feat(electron): error handling foundation

Adds the infrastructure for a coherent client-side error story:

- `lib/errors.ts`: canonical ApiError + QuotaExceededError, `toUserMessage`
  (friendly strings for ApiError/ZodError/network errors, strips Electron
  IPC message prefixes), `logError` (expected), `reportError` (unexpected).
- `lib/query-client.ts`: QueryClient factory with sane retry defaults (no
  retry on 4xx except 408/429, 2 retries otherwise; 0 mutation retries),
  `QueryCache` onError logs + opts in via `meta.toastOnError`, and
  `MutationCache` onError toasts `toUserMessage(err)` by default with
  `meta.suppressToast` as the opt-out.
- `components/app-error-boundary.tsx` + `error-fallback.tsx`: two boundaries
  (top-level outside the router, route-level inside) with a Card-based
  fallback offering 'Go home' + 'Try again'. Route boundary resets on
  pathname change and clears React Query error cache on retry.
- `main/ipc-utils.ts` + main.ts migration: `safeHandle` wraps ipcMain.handle
  so main-process failures log with full stack and surface a sanitized
  message to the renderer. `link:fetch-metadata` keeps its null contract
  but now logs.
- `useCreateParticle` opts out of the global toast (compose-overlay renders
  its own quota UX) so nothing double-toasts.

Render crashes now have a recovery UI, every mutation gets a free error
toast, and silent-catch cleanup + Sentry land in follow-up PRs.
This commit is contained in:
Claude
2026-04-16 23:29:36 +00:00
parent 5e930d3c2e
commit c00a7a439a
12 changed files with 389 additions and 49 deletions
+1
View File
@@ -65,6 +65,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"react-use": "^17.6.0",
+28 -22
View File
@@ -3,10 +3,7 @@ import { HashRouter, Routes, Route, useNavigate } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query';
import SettingsPage from "@/features/settings-page";
import AudioVideoSettingsPage from "@/features/settings/audio-video-settings-page";
import NetworkSelector from "@/features/network-selector";
@@ -16,8 +13,13 @@ import Layout from "@/features/layout";
import NetworkSettingsPage from "@/features/network-settings";
import { Toaster } from "@/components/ui/sonner";
import { PusherProvider } from "@/lib/pusher-provider";
import { createQueryClient } from "@/lib/query-client";
import {
RouteErrorBoundary,
TopLevelErrorBoundary,
} from "@/components/app-error-boundary";
const queryClient = new QueryClient();
const queryClient = createQueryClient();
const App = () => {
const status = useAuthStore((s) => s.status);
@@ -62,30 +64,34 @@ function AuthenticatedApp() {
return (
<HashRouter>
<AutoplayNavigationListener />
<Routes>
<Route path="settings" element={<SettingsPage />} />
<Route path="settings/audio-video" element={<AudioVideoSettingsPage />} />
<RouteErrorBoundary>
<Routes>
<Route path="settings" element={<SettingsPage />} />
<Route path="settings/audio-video" element={<AudioVideoSettingsPage />} />
<Route path="/">
<Route index element={<Layout><NetworkSelector /></Layout>} />
<Route path=":networkId">
<Route index element={<Layout><NetworkRoot /></Layout>} />
<Route path="settings" element={<NetworkSettingsPage />} />
<Route path="*" element={<ParticleViewResolver />} />
<Route path="/">
<Route index element={<Layout><NetworkSelector /></Layout>} />
<Route path=":networkId">
<Route index element={<Layout><NetworkRoot /></Layout>} />
<Route path="settings" element={<NetworkSettingsPage />} />
<Route path="*" element={<ParticleViewResolver />} />
</Route>
</Route>
</Route>
</Routes>
</Routes>
</RouteErrorBoundary>
</HashRouter>
);
}
const AppWithProviders = () => (
<TooltipProvider>
<QueryClientProvider client={queryClient}>
<App />
<Toaster />
</QueryClientProvider>
</TooltipProvider>
<TopLevelErrorBoundary>
<TooltipProvider>
<QueryClientProvider client={queryClient}>
<App />
<Toaster />
</QueryClientProvider>
</TooltipProvider>
</TopLevelErrorBoundary>
);
export default AppWithProviders;
+3 -9
View File
@@ -1,5 +1,6 @@
import { appConfig } from "@/config/env";
import { useSessionStore } from "@/stores/session-store";
import { ApiError } from "@/lib/errors";
import type { z } from "zod";
import {
BillingStatusSchema,
@@ -27,15 +28,8 @@ import type {
SignInRequest,
} from "./types";
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
// Re-export for back-compat with existing `import { ApiError } from "@/api/client"`.
export { ApiError };
interface ApiClientConfig {
baseUrl: string;
+55
View File
@@ -0,0 +1,55 @@
import type { PropsWithChildren } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useLocation } from "react-router-dom";
import { useQueryErrorResetBoundary } from "@tanstack/react-query";
import { reportError } from "@/lib/errors";
import {
RouteErrorFallback,
TopLevelErrorFallback,
} from "@/components/error-fallback";
/**
* Catches render crashes OUTSIDE react-router. Used once at the root so a
* broken bootstrap still shows a recovery UI instead of a white screen.
*/
export function TopLevelErrorBoundary({ children }: PropsWithChildren) {
return (
<ErrorBoundary
FallbackComponent={TopLevelErrorFallback}
onError={(error, info) =>
reportError(error, {
boundary: "top",
componentStack: info.componentStack,
})
}
>
{children}
</ErrorBoundary>
);
}
/**
* Catches render crashes INSIDE the router. Auto-resets when the route
* changes (so clicking "Go home" clears the error) and resets the React
* Query error cache on retry so stale failures don't stick.
*/
export function RouteErrorBoundary({ children }: PropsWithChildren) {
const location = useLocation();
const { reset: resetQueries } = useQueryErrorResetBoundary();
return (
<ErrorBoundary
FallbackComponent={RouteErrorFallback}
onError={(error, info) =>
reportError(error, {
boundary: "route",
pathname: location.pathname,
componentStack: info.componentStack,
})
}
onReset={() => resetQueries()}
resetKeys={[location.pathname]}
>
{children}
</ErrorBoundary>
);
}
+101
View File
@@ -0,0 +1,101 @@
import type { FallbackProps } from "react-error-boundary";
import { useNavigate } from "react-router-dom";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { toUserMessage } from "@/lib/errors";
import { appEnv } from "@/config/env";
function ErrorCard({
error,
onGoHome,
onRetry,
}: {
error: unknown;
onGoHome: () => void;
onRetry: () => void;
}) {
return (
<div className="flex h-screen w-full items-center justify-center p-6">
<Card className="w-full max-w-md">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="text-destructive size-4" />
<CardTitle>Something went wrong</CardTitle>
</div>
<CardDescription>{toUserMessage(error)}</CardDescription>
</CardHeader>
{appEnv === "dev" && error instanceof Error ? (
<CardContent>
<details className="text-muted-foreground text-xs">
<summary className="cursor-pointer select-none">
Technical details
</summary>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all">
{error.stack ?? error.message}
</pre>
</details>
</CardContent>
) : null}
<CardFooter className="flex gap-2">
<Button size="sm" onClick={onGoHome}>
Go home
</Button>
<Button size="sm" variant="outline" onClick={onRetry}>
Try again
</Button>
</CardFooter>
</Card>
</div>
);
}
/**
* Fallback for the boundary mounted OUTSIDE the router. `useNavigate` isn't
* available here, so "Go home" drops the hash fragment directly.
*/
export function TopLevelErrorFallback({
error,
resetErrorBoundary,
}: FallbackProps) {
const goHome = () => {
window.location.hash = "#/";
resetErrorBoundary();
};
return (
<ErrorCard
error={error}
onGoHome={goHome}
onRetry={resetErrorBoundary}
/>
);
}
/**
* Fallback for boundaries mounted INSIDE the router. Uses react-router's
* navigate so the browser history stays consistent.
*/
export function RouteErrorFallback({
error,
resetErrorBoundary,
}: FallbackProps) {
const navigate = useNavigate();
const goHome = () => {
navigate("/");
resetErrorBoundary();
};
return (
<ErrorCard
error={error}
onGoHome={goHome}
onRetry={resetErrorBoundary}
/>
);
}
+2 -1
View File
@@ -20,6 +20,7 @@ import type { Particle } from "@/api/types";
import { PropsWithChildren, useCallback } from "react";
import { useDockBadge } from "@/hooks/use-dock-badge";
import { toast } from "sonner";
import { RouteErrorBoundary } from "@/components/app-error-boundary";
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
@@ -177,7 +178,7 @@ export default function Layout({ children }: PropsWithChildren) {
return (
<div className="flex h-screen flex-col">
<TopBar />
{children}
<RouteErrorBoundary>{children}</RouteErrorBoundary>
</div>
);
}
+6 -12
View File
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
import { QuotaExceededError } from "@/lib/errors";
import {
isUsageExhausted,
networkUsageQueryKey,
@@ -9,18 +10,8 @@ import {
useInvalidateNetworkUsage,
} from "./use-network-usage";
/**
* Thrown when a free-plan network attempts to create a non-container particle
* after hitting its daily message limit. Callers should surface an upgrade
* prompt; compose UI should also disable triggers proactively via
* `useNetworkUsage` rather than relying on this throw.
*/
export class QuotaExceededError extends Error {
constructor(public readonly networkId: string) {
super("Daily message limit reached");
this.name = "QuotaExceededError";
}
}
// Re-export for back-compat with existing `import { QuotaExceededError } from "@/hooks/use-create-particle"`.
export { QuotaExceededError };
interface CreateParticleParams<T extends ParticleType = ParticleType> {
// Path to which the new particle will be added as a child
@@ -36,6 +27,9 @@ export function useCreateParticle() {
const invalidateUsage = useInvalidateNetworkUsage();
return useMutation({
// Compose UI renders a custom quota-exceeded toast + cancels the overlay.
// Opt out of the global mutation error toast to avoid a double-toast.
meta: { suppressToast: true },
mutationFn: async (params: CreateParticleParams) => {
const { networkId } = parseParticlePath(params.path);
+92
View File
@@ -0,0 +1,92 @@
import { z } from "zod";
import { appEnv } from "@/config/env";
/**
* Thrown by the API client for any non-2xx response.
*/
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
/**
* Thrown when a free-plan network attempts to create a non-container particle
* after hitting its daily message limit. Callers should surface an upgrade
* prompt; compose UI should also disable triggers proactively via
* `useNetworkUsage` rather than relying on this throw.
*/
export class QuotaExceededError extends Error {
constructor(public readonly networkId: string) {
super("Daily message limit reached");
this.name = "QuotaExceededError";
}
}
const IPC_PREFIX = /^Error invoking remote method '[^']+':\s*/;
function normalizeMessage(message: string): string {
return message.replace(IPC_PREFIX, "").replace(/^Error:\s*/, "").trim();
}
/**
* Maps any thrown value to a short, user-facing string. Used by the global
* MutationCache toast handler, the error-boundary fallback, and any call site
* that wants to surface an error in the UI.
*/
export function toUserMessage(err: unknown): string {
if (err instanceof ApiError) {
if (err.status === 401) return "Please sign in again.";
if (err.status === 403) return "You don't have permission to do that.";
if (err.status === 404) return "Not found.";
if (err.status === 408 || err.status === 429) {
return "Please try again in a moment.";
}
if (err.status >= 500) {
return "Something went wrong on our end. Please try again.";
}
return normalizeMessage(err.message) || "Request failed.";
}
if (err instanceof z.ZodError) {
return "Received unexpected data from the server.";
}
if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
return "Network error. Check your connection.";
}
if (err instanceof Error) {
return normalizeMessage(err.message) || "Something went wrong.";
}
return "Something went wrong.";
}
type ErrorContext = Record<string, unknown>;
/**
* For expected-but-worth-recording failures (silent-catch sites). In dev,
* prints to console. In prod today this is a no-op; PR 4 wires this up to
* Sentry breadcrumbs without touching call sites.
*/
export function logError(err: unknown, context?: ErrorContext): void {
if (appEnv === "dev") {
// eslint-disable-next-line no-console
console.error("[error]", err, context ?? {});
}
}
/**
* For unexpected failures the user may not have seen (render crashes, query
* errors, background mutations). Always surfaces somewhere. PR 4 wires this
* up to `Sentry.captureException`.
*/
export function reportError(err: unknown, context?: ErrorContext): void {
// eslint-disable-next-line no-console
console.error("[error]", err, context ?? {});
}
+62
View File
@@ -0,0 +1,62 @@
import {
MutationCache,
QueryCache,
QueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
declare module "@tanstack/react-query" {
interface Register {
queryMeta: {
/** Show a sonner error toast automatically on query failure. */
toastOnError?: boolean;
};
mutationMeta: {
/** Opt out of the default sonner error toast (caller handles feedback). */
suppressToast?: boolean;
};
}
}
function shouldRetryQuery(failureCount: number, err: unknown): boolean {
if (err instanceof ApiError) {
// Retry only on transient status codes; 4xx generally won't succeed on retry.
if (err.status === 408 || err.status === 429) return failureCount < 2;
if (err.status >= 400 && err.status < 500) return false;
}
return failureCount < 2;
}
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: shouldRetryQuery,
refetchOnWindowFocus: false,
},
mutations: {
// Mutations have side effects — never auto-retry.
retry: 0,
},
},
queryCache: new QueryCache({
onError: (err, query) => {
logError(err, { scope: "query", queryKey: query.queryKey });
if (query.meta?.toastOnError) {
toast.error(toUserMessage(err));
}
},
}),
mutationCache: new MutationCache({
onError: (err, _variables, _context, mutation) => {
reportError(err, {
scope: "mutation",
mutationKey: mutation.options.mutationKey,
});
if (mutation.meta?.suppressToast) return;
toast.error(toUserMessage(err));
},
}),
});
}
+10 -5
View File
@@ -5,6 +5,7 @@ import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import type { LinkMetadata } from './lib/link-metadata';
import { appConfig } from './config/env';
import { safeHandle } from './main/ipc-utils';
if (app.isPackaged) {
updateElectronApp({
@@ -291,7 +292,7 @@ ipcMain.on('window:close-huddle', () => {
huddleWindow?.close();
});
ipcMain.handle('screen:get-sources', async () => {
safeHandle('screen:get-sources', async () => {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 320, height: 180 },
@@ -457,7 +458,11 @@ async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
metadataCache.set(url, metadata);
return metadata;
} catch {
} catch (err) {
// Metadata is a progressive enhancement — keep the null contract, but log
// so upstream failures (DNS, TLS, aborted fetches) aren't invisible.
// eslint-disable-next-line no-console
console.error('[ipc:link.fetch-metadata]', err);
return null;
}
}
@@ -470,9 +475,9 @@ ipcMain.on('app:set-dock-badge', (_event, count: number) => {
}
});
ipcMain.handle('app:get-version', () => app.getVersion());
safeHandle('app:get-version', () => app.getVersion());
ipcMain.handle('link:fetch-metadata', async (_event, url: string) => {
safeHandle('link:fetch-metadata', async (_event, url) => {
if (typeof url !== 'string') return null;
try {
new URL(url);
@@ -482,7 +487,7 @@ ipcMain.handle('link:fetch-metadata', async (_event, url: string) => {
return fetchLinkMetadata(url);
});
ipcMain.handle('link:open-external', async (_event, url: string) => {
safeHandle('link:open-external', async (_event, url) => {
if (typeof url !== 'string') return;
// Only allow http(s) URLs for security
if (!url.startsWith('http://') && !url.startsWith('https://')) return;
+24
View File
@@ -0,0 +1,24 @@
import { ipcMain, type IpcMainInvokeEvent } from "electron";
type InvokeHandler = (
event: IpcMainInvokeEvent,
...args: unknown[]
) => Promise<unknown> | unknown;
/**
* Wraps `ipcMain.handle` so handler failures log the full stack in the main
* process and re-throw a sanitized message to the renderer. Without this,
* main-process exceptions silently reject the renderer promise with no trace.
*/
export function safeHandle(channel: string, handler: InvokeHandler): void {
ipcMain.handle(channel, async (event, ...args) => {
try {
return await handler(event, ...(args as unknown[]));
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[ipc:${channel}]`, err);
const message = err instanceof Error ? err.message : "IPC error";
throw new Error(message);
}
});
}
+5
View File
@@ -8291,6 +8291,11 @@ react-dom@^19.2.4:
dependencies:
scheduler "^0.27.0"
react-error-boundary@^6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-6.1.1.tgz#491d655e86c32434ede852755bb649119fdddd89"
integrity sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==
react-markdown@^10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca"