From c6699a9592e0bd10e588d81e6ec2523b25ea53dd Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 00:36:14 +0200 Subject: [PATCH] FEAT-3: fix unhandled ApiError rejections (catch in form submits + dev unhandledrejection guard, ApiError carries path) Co-Authored-By: Claude Opus 4.8 (1M context) --- gerbil-manager-web/src/api/client.ts | 9 ++++--- .../src/dev/unhandledRejectionGuard.ts | 25 +++++++++++++++++++ gerbil-manager-web/src/main.tsx | 3 +++ .../src/pages/GerbilFormPage.tsx | 9 +++++-- gerbil-manager-web/src/pages/WurfFormPage.tsx | 9 +++++-- 5 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 gerbil-manager-web/src/dev/unhandledRejectionGuard.ts diff --git a/gerbil-manager-web/src/api/client.ts b/gerbil-manager-web/src/api/client.ts index c9cfbca..8f6eab6 100644 --- a/gerbil-manager-web/src/api/client.ts +++ b/gerbil-manager-web/src/api/client.ts @@ -10,11 +10,14 @@ export const API_BASE_URL: string = export class ApiError extends Error { readonly status: number | null + /** The request path that failed (for diagnostics/logging). */ + readonly path?: string - constructor(message: string, status: number | null) { + constructor(message: string, status: number | null, path?: string) { super(message) this.name = 'ApiError' this.status = status + this.path = path } } @@ -32,11 +35,11 @@ async function request(path: string, init?: RequestInit): Promise { ...init, }) } catch { - throw new ApiError(de.api.errors.network, null) + throw new ApiError(de.api.errors.network, null, path) } if (!response.ok) { - throw new ApiError(errorMessageFor(response.status), response.status) + throw new ApiError(errorMessageFor(response.status), response.status, path) } if (response.status === 204) { diff --git a/gerbil-manager-web/src/dev/unhandledRejectionGuard.ts b/gerbil-manager-web/src/dev/unhandledRejectionGuard.ts new file mode 100644 index 0000000..5fca25f --- /dev/null +++ b/gerbil-manager-web/src/dev/unhandledRejectionGuard.ts @@ -0,0 +1,25 @@ +import { ApiError } from '../api/client' + +/** + * Dev-only safety net for unhandled promise rejections. + * + * Call sites should still handle their own errors (useApi error UI, try/catch + * around mutations). This guard exists so that any STRAY rejection — typically + * an ApiError from an endpoint that hasn't shipped yet — is logged with its + * failing path instead of spamming the console with "Uncaught (in promise)". + * It surfaces regressions (which endpoint, which status) without turning the + * page into a dead end. No-op in production builds. + */ +export function installUnhandledRejectionGuard(): void { + if (!import.meta.env.DEV) return + window.addEventListener('unhandledrejection', (event) => { + const reason = event.reason + if (reason instanceof ApiError) { + const where = reason.path ? ` [${reason.path}]` : '' + console.warn(`[API] Unbehandelte Ablehnung${where} (${reason.status ?? 'Netzwerk'}): ${reason.message}`) + event.preventDefault() + } else { + console.warn('[Unbehandelte Promise-Ablehnung]', reason) + } + }) +} diff --git a/gerbil-manager-web/src/main.tsx b/gerbil-manager-web/src/main.tsx index bef5202..3461d9e 100644 --- a/gerbil-manager-web/src/main.tsx +++ b/gerbil-manager-web/src/main.tsx @@ -2,6 +2,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { installUnhandledRejectionGuard } from './dev/unhandledRejectionGuard' + +installUnhandledRejectionGuard() createRoot(document.getElementById('root')!).render( diff --git a/gerbil-manager-web/src/pages/GerbilFormPage.tsx b/gerbil-manager-web/src/pages/GerbilFormPage.tsx index 4bb3b27..ed659a9 100644 --- a/gerbil-manager-web/src/pages/GerbilFormPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilFormPage.tsx @@ -166,8 +166,13 @@ export default function GerbilFormPage() { genotype: nn(form.genotype), notes: nn(form.notes), } - const saved = await mutation.run(body) - navigate(`/rennmaeuse/${saved.id}`) + try { + const saved = await mutation.run(body) + navigate(`/rennmaeuse/${saved.id}`) + } catch { + // Error is surfaced via mutation.error; swallow so the rejected promise + // from this async submit handler doesn't become an unhandled rejection. + } } if (isEdit && existing.loading) return

{de.common.loading}

diff --git a/gerbil-manager-web/src/pages/WurfFormPage.tsx b/gerbil-manager-web/src/pages/WurfFormPage.tsx index 3b58bab..423fe11 100644 --- a/gerbil-manager-web/src/pages/WurfFormPage.tsx +++ b/gerbil-manager-web/src/pages/WurfFormPage.tsx @@ -116,8 +116,13 @@ export default function WurfFormPage() { expectedGoHomeDate: nn(form.expectedGoHomeDate), notes: nn(form.notes), } - const saved = await mutation.run(body) - navigate(`/wuerfe/${saved.id}`) + try { + const saved = await mutation.run(body) + navigate(`/wuerfe/${saved.id}`) + } catch { + // Error surfaced via mutation.error; swallow to avoid an unhandled + // rejection escaping this async submit handler. + } } if (isEdit && existing.loading) return

{de.common.loading}