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.
63 lines
1.7 KiB
TypeScript
63 lines
1.7 KiB
TypeScript
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));
|
|
},
|
|
}),
|
|
});
|
|
}
|