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"