diff --git a/js/package.json b/js/package.json index 45da921..a75b43a 100644 --- a/js/package.json +++ b/js/package.json @@ -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", diff --git a/js/src/App.tsx b/js/src/App.tsx index 09999e1..be7e21d 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -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 ( - - } /> - } /> + + + } /> + } /> - - } /> - - } /> - } /> - } /> + + } /> + + } /> + } /> + } /> + - - + + ); } const AppWithProviders = () => ( - - - - - - + + + + + + + + ); export default AppWithProviders; diff --git a/js/src/api/client.ts b/js/src/api/client.ts index 938355d..7040cfa 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -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; diff --git a/js/src/components/app-error-boundary.tsx b/js/src/components/app-error-boundary.tsx new file mode 100644 index 0000000..7f8a0b6 --- /dev/null +++ b/js/src/components/app-error-boundary.tsx @@ -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 ( + + reportError(error, { + boundary: "top", + componentStack: info.componentStack, + }) + } + > + {children} + + ); +} + +/** + * 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 ( + + reportError(error, { + boundary: "route", + pathname: location.pathname, + componentStack: info.componentStack, + }) + } + onReset={() => resetQueries()} + resetKeys={[location.pathname]} + > + {children} + + ); +} diff --git a/js/src/components/error-fallback.tsx b/js/src/components/error-fallback.tsx new file mode 100644 index 0000000..4769d5d --- /dev/null +++ b/js/src/components/error-fallback.tsx @@ -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 ( +
+ + +
+ + Something went wrong +
+ {toUserMessage(error)} +
+ {appEnv === "dev" && error instanceof Error ? ( + +
+ + Technical details + +
+                {error.stack ?? error.message}
+              
+
+
+ ) : null} + + + + +
+
+ ); +} + +/** + * 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 ( + + ); +} + +/** + * 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 ( + + ); +} diff --git a/js/src/features/layout.tsx b/js/src/features/layout.tsx index 7aa3d45..b5ae8da 100644 --- a/js/src/features/layout.tsx +++ b/js/src/features/layout.tsx @@ -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 (
- {children} + {children}
); } diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts index b49767e..c08bc8b 100644 --- a/js/src/hooks/use-create-particle.ts +++ b/js/src/hooks/use-create-particle.ts @@ -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 { // 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); diff --git a/js/src/lib/errors.ts b/js/src/lib/errors.ts new file mode 100644 index 0000000..87883e9 --- /dev/null +++ b/js/src/lib/errors.ts @@ -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; + +/** + * 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 ?? {}); +} diff --git a/js/src/lib/query-client.ts b/js/src/lib/query-client.ts new file mode 100644 index 0000000..e49addc --- /dev/null +++ b/js/src/lib/query-client.ts @@ -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)); + }, + }), + }); +} diff --git a/js/src/main.ts b/js/src/main.ts index e56453f..16fca8a 100644 --- a/js/src/main.ts +++ b/js/src/main.ts @@ -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 { 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; diff --git a/js/src/main/ipc-utils.ts b/js/src/main/ipc-utils.ts new file mode 100644 index 0000000..de53265 --- /dev/null +++ b/js/src/main/ipc-utils.ts @@ -0,0 +1,24 @@ +import { ipcMain, type IpcMainInvokeEvent } from "electron"; + +type InvokeHandler = ( + event: IpcMainInvokeEvent, + ...args: unknown[] +) => 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. + */ +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); + } + }); +} diff --git a/js/yarn.lock b/js/yarn.lock index bd78187..ca86d6c 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -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"