From 8632c98725479b436a366cdf6f6a2b95793e0911 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 02:52:45 +0000 Subject: [PATCH] refactor(errors): defer mutation errors to global handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that MutationCache toasts via toUserMessage by default, the per-hook onError duplicates drop away. Also normalizes inline query-error UI and surfaces a previously-silent failure. Mutations — removed redundant onError toasts: - network-selector: useAcceptInvitation, createNetwork (inline) - network-settings: useInviteMembers, useRevokeInvitation, useRemoveMember - network-billing: useCreateCheckoutSession, useCreatePortalSession (onSuccess toasts stay — they carry domain context like network name) Queries — consistent inline error UX via toUserMessage: - network-selector: failed-to-load state gets a "Try again" button - network-billing: "Couldn't load billing" includes friendly reason - network-settings: useNetworkInvitations failure now surfaces a hint (previously rendered as "0 pending" — silently wrong) Trimmed noisy JSDoc from PR 1 files (errors.ts, query-client.ts, app-error-boundary.tsx, error-fallback.tsx, ipc-utils.ts). --- js/src/components/app-error-boundary.tsx | 8 ++------ js/src/components/error-fallback.tsx | 9 +-------- js/src/features/network-billing.tsx | 7 ++----- js/src/features/network-selector.tsx | 16 +++++++-------- js/src/features/network-settings.tsx | 19 +++++++++--------- js/src/lib/errors.ts | 25 ++++-------------------- js/src/lib/query-client.ts | 10 ++-------- js/src/main/ipc-utils.ts | 5 ++--- 8 files changed, 29 insertions(+), 70 deletions(-) diff --git a/js/src/components/app-error-boundary.tsx b/js/src/components/app-error-boundary.tsx index 7f8a0b6..1265a01 100644 --- a/js/src/components/app-error-boundary.tsx +++ b/js/src/components/app-error-boundary.tsx @@ -8,10 +8,7 @@ import { 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. - */ +/** Catches render crashes OUTSIDE the router so bootstrap failures still recover. */ export function TopLevelErrorBoundary({ children }: PropsWithChildren) { return ( { createCheckout.mutate(cadence, { onSuccess: ({ url }) => window.electronLink.openExternal(url), - onError: (err) => toast.error(err.message || "Failed to start checkout"), }); }; @@ -230,8 +229,6 @@ function ProBilling({ const handleManage = () => { createPortal.mutate(undefined, { onSuccess: ({ url }) => window.electronLink.openExternal(url), - onError: (err) => - toast.error(err.message || "Failed to open billing portal"), }); }; @@ -342,7 +339,7 @@ function AdminBillingControls({ networkId }: { networkId: string }) { if (error) { return (
- Failed to load billing. + Couldn't load billing: {toUserMessage(error)}
); } diff --git a/js/src/features/network-selector.tsx b/js/src/features/network-selector.tsx index 01e1b86..381dfb6 100644 --- a/js/src/features/network-selector.tsx +++ b/js/src/features/network-selector.tsx @@ -22,6 +22,7 @@ import { useNetworks } from "@/hooks/use-networks"; import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-member-management"; import { apiClient } from "@/api/client"; import { Progress } from "@/components/ui/progress"; +import { toUserMessage } from "@/lib/errors"; import type { Network, Invitation } from "@/api/types"; function NetworkRow({ @@ -86,9 +87,6 @@ function InvitationRow({ invitation }: { invitation: Invitation }) { onSuccess: () => { toast.success(`Joined ${invitation.network_name}`); }, - onError: (err) => { - toast.error(err.message || "Failed to accept invitation"); - }, }); }; @@ -138,9 +136,6 @@ function CreateNetworkDialog({ setName(""); navigate(`/${network.id}/settings`); }, - onError: (err) => { - toast.error(err.message || "Failed to create network"); - }, }); const handleSubmit = (e: React.FormEvent) => { @@ -200,9 +195,12 @@ export default function NetworkSelector() { if (error) { return ( -
-

Failed to load networks

-

{error.message}

+
+

Couldn't load your networks

+

{toUserMessage(error)}

+
); } diff --git a/js/src/features/network-settings.tsx b/js/src/features/network-settings.tsx index 81edf50..4a4ba62 100644 --- a/js/src/features/network-settings.tsx +++ b/js/src/features/network-settings.tsx @@ -79,9 +79,6 @@ function InviteForm({ networkId }: { networkId: string }) { toast.success(`Invitation sent to ${trimmed}`); setEmail(""); }, - onError: (err) => { - toast.error(err.message || "Failed to send invitation"); - }, }); }; @@ -119,9 +116,6 @@ function PendingInvitationRow({ onSuccess: () => { toast.success(`Invitation to ${email} revoked`); }, - onError: (err) => { - toast.error(err.message || "Failed to revoke invitation"); - }, }); }; @@ -188,7 +182,7 @@ export default function NetworkSettingsPage() { const [searchParams] = useSearchParams(); const { data: networks } = useNetworks(); const network = networks?.find((n) => n.id === networkId); - const { data: invitations } = useNetworkInvitations(networkId!); + const { data: invitations, error: invitationsError } = useNetworkInvitations(networkId!); const currentUser = useAuthStore((s) => s.user); const isAdmin = currentUser?.id === network?.admin_human.id; const [memberToRemove, setMemberToRemove] = useState(null); @@ -286,6 +280,14 @@ export default function NetworkSettingsPage() { /> + {invitationsError && ( + <> + +

+ Couldn't load pending invitations. +

+ + )} {pendingCount > 0 && ( <> @@ -354,9 +356,6 @@ export default function NetworkSettingsPage() { toast.success(`Removed ${target.email}`); setMemberToRemove(null); }, - onError: (err) => { - toast.error(err.message || "Failed to remove member"); - }, }); }} onClose={() => { diff --git a/js/src/lib/errors.ts b/js/src/lib/errors.ts index 87883e9..459af06 100644 --- a/js/src/lib/errors.ts +++ b/js/src/lib/errors.ts @@ -1,9 +1,6 @@ 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, @@ -16,9 +13,8 @@ export class ApiError extends Error { /** * 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. + * after hitting its daily message limit. Compose UI also disables triggers + * proactively via `useNetworkUsage` — this throw is a last-line defense. */ export class QuotaExceededError extends Error { constructor(public readonly networkId: string) { @@ -33,11 +29,6 @@ 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."; @@ -69,11 +60,7 @@ export function toUserMessage(err: unknown): string { type ErrorContext = Record; -/** - * 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. - */ +/** Expected-but-recordable failures. Dev logs; prod is currently a no-op. */ export function logError(err: unknown, context?: ErrorContext): void { if (appEnv === "dev") { // eslint-disable-next-line no-console @@ -81,11 +68,7 @@ export function logError(err: unknown, context?: ErrorContext): void { } } -/** - * 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`. - */ +/** Unexpected failures the user may not see. Always surfaces somewhere. */ export function reportError(err: unknown, context?: ErrorContext): void { // eslint-disable-next-line no-console console.error("[error]", err, context ?? {}); diff --git a/js/src/lib/query-client.ts b/js/src/lib/query-client.ts index e49addc..e5e5ac1 100644 --- a/js/src/lib/query-client.ts +++ b/js/src/lib/query-client.ts @@ -8,14 +8,8 @@ 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; - }; + queryMeta: { toastOnError?: boolean }; + mutationMeta: { suppressToast?: boolean }; } } diff --git a/js/src/main/ipc-utils.ts b/js/src/main/ipc-utils.ts index de53265..901aecc 100644 --- a/js/src/main/ipc-utils.ts +++ b/js/src/main/ipc-utils.ts @@ -6,9 +6,8 @@ type InvokeHandler = ( ) => Promise | 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. + * Wraps `ipcMain.handle` so handler failures log with full stack in main and + * re-throw a sanitized message to the renderer. */ export function safeHandle(channel: string, handler: InvokeHandler): void { ipcMain.handle(channel, async (event, ...args) => {