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:
@@ -8,10 +8,7 @@ import {
|
|||||||
TopLevelErrorFallback,
|
TopLevelErrorFallback,
|
||||||
} from "@/components/error-fallback";
|
} from "@/components/error-fallback";
|
||||||
|
|
||||||
/**
|
/** Catches render crashes OUTSIDE the router so bootstrap failures still recover. */
|
||||||
* 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) {
|
export function TopLevelErrorBoundary({ children }: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
@@ -29,8 +26,7 @@ export function TopLevelErrorBoundary({ children }: PropsWithChildren) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Catches render crashes INSIDE the router. Auto-resets when the route
|
* Route-scoped boundary. Resets on pathname change and clears the React
|
||||||
* changes (so clicking "Go home" clears the error) and resets the React
|
|
||||||
* Query error cache on retry so stale failures don't stick.
|
* Query error cache on retry so stale failures don't stick.
|
||||||
*/
|
*/
|
||||||
export function RouteErrorBoundary({ children }: PropsWithChildren) {
|
export function RouteErrorBoundary({ children }: PropsWithChildren) {
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ function ErrorCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Outside the router `useNavigate` is unavailable, so "Go home" drops the hash directly.
|
||||||
* Fallback for the boundary mounted OUTSIDE the router. `useNavigate` isn't
|
|
||||||
* available here, so "Go home" drops the hash fragment directly.
|
|
||||||
*/
|
|
||||||
export function TopLevelErrorFallback({
|
export function TopLevelErrorFallback({
|
||||||
error,
|
error,
|
||||||
resetErrorBoundary,
|
resetErrorBoundary,
|
||||||
@@ -78,10 +75,6 @@ export function TopLevelErrorFallback({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback for boundaries mounted INSIDE the router. Uses react-router's
|
|
||||||
* navigate so the browser history stays consistent.
|
|
||||||
*/
|
|
||||||
export function RouteErrorFallback({
|
export function RouteErrorFallback({
|
||||||
error,
|
error,
|
||||||
resetErrorBoundary,
|
resetErrorBoundary,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -9,6 +8,7 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Muted } from "@/components/ui/typography";
|
import { Muted } from "@/components/ui/typography";
|
||||||
import { CopyableEmail } from "@/components/copyable-email";
|
import { CopyableEmail } from "@/components/copyable-email";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toUserMessage } from "@/lib/errors";
|
||||||
import { SUPPORT_EMAIL } from "@/lib/constants";
|
import { SUPPORT_EMAIL } from "@/lib/constants";
|
||||||
import {
|
import {
|
||||||
useCreateCheckoutSession,
|
useCreateCheckoutSession,
|
||||||
@@ -172,7 +172,6 @@ function FreeBilling({
|
|||||||
const handleUpgrade = () => {
|
const handleUpgrade = () => {
|
||||||
createCheckout.mutate(cadence, {
|
createCheckout.mutate(cadence, {
|
||||||
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
||||||
onError: (err) => toast.error(err.message || "Failed to start checkout"),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -230,8 +229,6 @@ function ProBilling({
|
|||||||
const handleManage = () => {
|
const handleManage = () => {
|
||||||
createPortal.mutate(undefined, {
|
createPortal.mutate(undefined, {
|
||||||
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-3">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useNetworks } from "@/hooks/use-networks";
|
|||||||
import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-member-management";
|
import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-member-management";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { toUserMessage } from "@/lib/errors";
|
||||||
import type { Network, Invitation } from "@/api/types";
|
import type { Network, Invitation } from "@/api/types";
|
||||||
|
|
||||||
function NetworkRow({
|
function NetworkRow({
|
||||||
@@ -86,9 +87,6 @@ function InvitationRow({ invitation }: { invitation: Invitation }) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`Joined ${invitation.network_name}`);
|
toast.success(`Joined ${invitation.network_name}`);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message || "Failed to accept invitation");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,9 +136,6 @@ function CreateNetworkDialog({
|
|||||||
setName("");
|
setName("");
|
||||||
navigate(`/${network.id}/settings`);
|
navigate(`/${network.id}/settings`);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message || "Failed to create network");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -200,9 +195,12 @@ export default function NetworkSelector() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
<p className="text-destructive text-sm">Failed to load networks</p>
|
<p className="text-sm font-medium">Couldn't load your networks</p>
|
||||||
<p>{error.message}</p>
|
<p className="text-muted-foreground text-xs">{toUserMessage(error)}</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => refetch()}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,9 +79,6 @@ function InviteForm({ networkId }: { networkId: string }) {
|
|||||||
toast.success(`Invitation sent to ${trimmed}`);
|
toast.success(`Invitation sent to ${trimmed}`);
|
||||||
setEmail("");
|
setEmail("");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message || "Failed to send invitation");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,9 +116,6 @@ function PendingInvitationRow({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`Invitation to ${email} revoked`);
|
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 [searchParams] = useSearchParams();
|
||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
const network = networks?.find((n) => n.id === networkId);
|
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 currentUser = useAuthStore((s) => s.user);
|
||||||
const isAdmin = currentUser?.id === network?.admin_human.id;
|
const isAdmin = currentUser?.id === network?.admin_human.id;
|
||||||
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
|
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
|
||||||
@@ -286,6 +280,14 @@ export default function NetworkSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
<Separator />
|
<Separator />
|
||||||
<InviteForm networkId={networkId!} />
|
<InviteForm networkId={networkId!} />
|
||||||
|
{invitationsError && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<p className="text-muted-foreground px-4 py-3 text-xs">
|
||||||
|
Couldn't load pending invitations.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{pendingCount > 0 && (
|
{pendingCount > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -354,9 +356,6 @@ export default function NetworkSettingsPage() {
|
|||||||
toast.success(`Removed ${target.email}`);
|
toast.success(`Removed ${target.email}`);
|
||||||
setMemberToRemove(null);
|
setMemberToRemove(null);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message || "Failed to remove member");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
+4
-21
@@ -1,9 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { appEnv } from "@/config/env";
|
import { appEnv } from "@/config/env";
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown by the API client for any non-2xx response.
|
|
||||||
*/
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public status: number,
|
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
|
* Thrown when a free-plan network attempts to create a non-container particle
|
||||||
* after hitting its daily message limit. Callers should surface an upgrade
|
* after hitting its daily message limit. Compose UI also disables triggers
|
||||||
* prompt; compose UI should also disable triggers proactively via
|
* proactively via `useNetworkUsage` — this throw is a last-line defense.
|
||||||
* `useNetworkUsage` rather than relying on this throw.
|
|
||||||
*/
|
*/
|
||||||
export class QuotaExceededError extends Error {
|
export class QuotaExceededError extends Error {
|
||||||
constructor(public readonly networkId: string) {
|
constructor(public readonly networkId: string) {
|
||||||
@@ -33,11 +29,6 @@ function normalizeMessage(message: string): string {
|
|||||||
return message.replace(IPC_PREFIX, "").replace(/^Error:\s*/, "").trim();
|
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 {
|
export function toUserMessage(err: unknown): string {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
if (err.status === 401) return "Please sign in again.";
|
if (err.status === 401) return "Please sign in again.";
|
||||||
@@ -69,11 +60,7 @@ export function toUserMessage(err: unknown): string {
|
|||||||
|
|
||||||
type ErrorContext = Record<string, unknown>;
|
type ErrorContext = Record<string, unknown>;
|
||||||
|
|
||||||
/**
|
/** Expected-but-recordable failures. Dev logs; prod is currently a no-op. */
|
||||||
* 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 {
|
export function logError(err: unknown, context?: ErrorContext): void {
|
||||||
if (appEnv === "dev") {
|
if (appEnv === "dev") {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -81,11 +68,7 @@ export function logError(err: unknown, context?: ErrorContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Unexpected failures the user may not see. Always surfaces somewhere. */
|
||||||
* 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 {
|
export function reportError(err: unknown, context?: ErrorContext): void {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error("[error]", err, context ?? {});
|
console.error("[error]", err, context ?? {});
|
||||||
|
|||||||
@@ -8,14 +8,8 @@ import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
|
|||||||
|
|
||||||
declare module "@tanstack/react-query" {
|
declare module "@tanstack/react-query" {
|
||||||
interface Register {
|
interface Register {
|
||||||
queryMeta: {
|
queryMeta: { toastOnError?: boolean };
|
||||||
/** Show a sonner error toast automatically on query failure. */
|
mutationMeta: { suppressToast?: boolean };
|
||||||
toastOnError?: boolean;
|
|
||||||
};
|
|
||||||
mutationMeta: {
|
|
||||||
/** Opt out of the default sonner error toast (caller handles feedback). */
|
|
||||||
suppressToast?: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ type InvokeHandler = (
|
|||||||
) => Promise<unknown> | unknown;
|
) => Promise<unknown> | unknown;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps `ipcMain.handle` so handler failures log the full stack in the main
|
* Wraps `ipcMain.handle` so handler failures log with full stack in main and
|
||||||
* process and re-throw a sanitized message to the renderer. Without this,
|
* re-throw a sanitized message to the renderer.
|
||||||
* main-process exceptions silently reject the renderer promise with no trace.
|
|
||||||
*/
|
*/
|
||||||
export function safeHandle(channel: string, handler: InvokeHandler): void {
|
export function safeHandle(channel: string, handler: InvokeHandler): void {
|
||||||
ipcMain.handle(channel, async (event, ...args) => {
|
ipcMain.handle(channel, async (event, ...args) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user