refactor(errors): defer mutation errors to global handler

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).
This commit is contained in:
Claude
2026-04-17 02:52:45 +00:00
parent c00a7a439a
commit 8632c98725
8 changed files with 29 additions and 70 deletions
+2 -5
View File
@@ -1,6 +1,5 @@
import { useState } from "react";
import { ExternalLink } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -9,6 +8,7 @@ import { Separator } from "@/components/ui/separator";
import { Muted } from "@/components/ui/typography";
import { CopyableEmail } from "@/components/copyable-email";
import { cn } from "@/lib/utils";
import { toUserMessage } from "@/lib/errors";
import { SUPPORT_EMAIL } from "@/lib/constants";
import {
useCreateCheckoutSession,
@@ -172,7 +172,6 @@ function FreeBilling({
const handleUpgrade = () => {
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 (
<div className="px-4 py-3">
<Muted className="text-sm">Failed to load billing.</Muted>
<Muted className="text-sm">Couldn't load billing: {toUserMessage(error)}</Muted>
</div>
);
}
+7 -9
View File
@@ -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 (
<div className="flex h-full items-center justify-center">
<p className="text-destructive text-sm">Failed to load networks</p>
<p>{error.message}</p>
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-sm font-medium">Couldn't load your networks</p>
<p className="text-muted-foreground text-xs">{toUserMessage(error)}</p>
<Button size="sm" variant="outline" onClick={() => refetch()}>
Try again
</Button>
</div>
);
}
+9 -10
View File
@@ -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<Human | null>(null);
@@ -286,6 +280,14 @@ export default function NetworkSettingsPage() {
/>
<Separator />
<InviteForm networkId={networkId!} />
{invitationsError && (
<>
<Separator />
<p className="text-muted-foreground px-4 py-3 text-xs">
Couldn't load pending invitations.
</p>
</>
)}
{pendingCount > 0 && (
<>
<Separator />
@@ -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={() => {