HOTFIX: useMutation.run returns MutationOutcome (no throw); sweep all 7 call sites + README convention
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,14 @@ Erwartet die laufende GerbilManagerWebAPI unter `http://localhost:5179`
|
|||||||
`src/strings/de.ts` — keine Texte direkt in Komponenten hartkodieren.
|
`src/strings/de.ts` — keine Texte direkt in Komponenten hartkodieren.
|
||||||
- API-Zugriffe laufen über `src/api/client.ts`.
|
- API-Zugriffe laufen über `src/api/client.ts`.
|
||||||
- Routen werden in `src/App.tsx` registriert, Seiten liegen in `src/pages/`.
|
- Routen werden in `src/App.tsx` registriert, Seiten liegen in `src/pages/`.
|
||||||
|
- **Mutationen** (`useMutation` aus `src/hooks/useApi.ts`): `run()` wirft NIE,
|
||||||
|
sondern liefert ein `MutationOutcome<T>` — `{ ok: true, value }` oder
|
||||||
|
`{ ok: false, error, cause }`. Aufrufer MÜSSEN verzweigen:
|
||||||
|
`const r = await m.run(x); if (r.ok) navigate(r.value.id)`. `m.error` treibt
|
||||||
|
die Inline-Anzeige; für Spezialfälle (z. B. HTTP 409) `r.cause` prüfen
|
||||||
|
(`r.cause instanceof ApiError && r.cause.status === 409`). So sind unbehandelte
|
||||||
|
Promise-Rejections an Aufrufstellen ausgeschlossen (siehe auch
|
||||||
|
`src/dev/unhandledRejectionGuard.ts`).
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -67,11 +67,25 @@ export function useApi<T>(loader: () => Promise<T>, deps: unknown[]): AsyncState
|
|||||||
return { data: resolved.data, loading, error: loading ? null : resolved.error, reload }
|
return { data: resolved.data, loading, error: loading ? null : resolved.error, reload }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wraps a mutation (create/update/delete) with pending + error state. */
|
/**
|
||||||
|
* Outcome of a mutation. Discriminated on `ok` so callers MUST branch before
|
||||||
|
* touching `value` — this makes error handling compile-time-visible and means
|
||||||
|
* `run()` NEVER throws (no more unhandled rejections from async event handlers).
|
||||||
|
* Works for void mutations too (delete): success is `{ ok: true, value: undefined }`.
|
||||||
|
*/
|
||||||
|
export type MutationOutcome<T> =
|
||||||
|
| { ok: true; value: T }
|
||||||
|
| { ok: false; error: string; cause: unknown }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a mutation (create/update/delete) with pending + error state.
|
||||||
|
* `run()` resolves to a MutationOutcome and never rejects; `error` also drives
|
||||||
|
* inline UI as before. Call sites: `const r = await m.run(x); if (r.ok) …`.
|
||||||
|
*/
|
||||||
export function useMutation<TArgs extends unknown[], TResult>(
|
export function useMutation<TArgs extends unknown[], TResult>(
|
||||||
mutator: (...args: TArgs) => Promise<TResult>,
|
mutator: (...args: TArgs) => Promise<TResult>,
|
||||||
): {
|
): {
|
||||||
run: (...args: TArgs) => Promise<TResult>
|
run: (...args: TArgs) => Promise<MutationOutcome<TResult>>
|
||||||
pending: boolean
|
pending: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
} {
|
} {
|
||||||
@@ -79,14 +93,16 @@ export function useMutation<TArgs extends unknown[], TResult>(
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
async (...args: TArgs) => {
|
async (...args: TArgs): Promise<MutationOutcome<TResult>> => {
|
||||||
setPending(true)
|
setPending(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
return await mutator(...args)
|
const value = await mutator(...args)
|
||||||
|
return { ok: true, value }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(messageFor(err))
|
const message = messageFor(err)
|
||||||
throw err
|
setError(message)
|
||||||
|
return { ok: false, error: message, cause: err }
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false)
|
setPending(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,16 +42,14 @@ export default function BeckenDetailPage() {
|
|||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
if (!window.confirm(t.delete.confirmMessage)) return
|
if (!window.confirm(t.delete.confirmMessage)) return
|
||||||
setDeleteError(null)
|
setDeleteError(null)
|
||||||
try {
|
const result = await removal.run()
|
||||||
await removal.run()
|
if (result.ok) {
|
||||||
navigate('/becken')
|
navigate('/becken')
|
||||||
} catch (err) {
|
} else if (result.cause instanceof ApiError && result.cause.status === 409) {
|
||||||
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
|
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
|
||||||
if (err instanceof ApiError && err.status === 409) {
|
setDeleteError(t.delete.conflict)
|
||||||
setDeleteError(t.delete.conflict)
|
} else {
|
||||||
} else {
|
setDeleteError(result.error)
|
||||||
setDeleteError(removal.error ?? de.api.errors.unknown)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ export default function BeckenFormPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setErrors({})
|
setErrors({})
|
||||||
const saved = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
||||||
navigate(`/becken/${saved.id}`)
|
if (result.ok) navigate(`/becken/${result.value.id}`)
|
||||||
|
// On failure mutation.error drives the inline alert; run() never throws.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
|||||||
@@ -166,13 +166,9 @@ export default function GerbilFormPage() {
|
|||||||
genotype: nn(form.genotype),
|
genotype: nn(form.genotype),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
}
|
}
|
||||||
try {
|
const result = await mutation.run(body)
|
||||||
const saved = await mutation.run(body)
|
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||||
navigate(`/rennmaeuse/${saved.id}`)
|
// On failure mutation.error drives the inline alert; run() never throws.
|
||||||
} 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 <p className="muted">{de.common.loading}</p>
|
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
|||||||
@@ -45,16 +45,14 @@ export default function KontaktDetailPage() {
|
|||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
if (!window.confirm(t.delete.confirmMessage)) return
|
if (!window.confirm(t.delete.confirmMessage)) return
|
||||||
setDeleteError(null)
|
setDeleteError(null)
|
||||||
try {
|
const result = await removal.run()
|
||||||
await removal.run()
|
if (result.ok) {
|
||||||
navigate('/kontakte')
|
navigate('/kontakte')
|
||||||
} catch (err) {
|
} else if (result.cause instanceof ApiError && result.cause.status === 409) {
|
||||||
// Backend meldet Konflikt, wenn der Kontakt noch referenziert wird.
|
// Backend meldet Konflikt, wenn der Kontakt noch referenziert wird.
|
||||||
if (err instanceof ApiError && err.status === 409) {
|
setDeleteError(t.delete.conflict)
|
||||||
setDeleteError(t.delete.conflict)
|
} else {
|
||||||
} else {
|
setDeleteError(result.error)
|
||||||
setDeleteError(removal.error ?? de.api.errors.unknown)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,12 +52,13 @@ export default function KontaktFormPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setErrors({})
|
setErrors({})
|
||||||
const saved = await mutation.run({
|
const result = await mutation.run({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
contactInfo: nn(form.contactInfo),
|
contactInfo: nn(form.contactInfo),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
})
|
})
|
||||||
navigate(`/kontakte/${saved.id}`)
|
if (result.ok) navigate(`/kontakte/${result.value.id}`)
|
||||||
|
// On failure mutation.error drives the inline alert; run() never throws.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
|||||||
@@ -116,13 +116,9 @@ export default function WurfFormPage() {
|
|||||||
expectedGoHomeDate: nn(form.expectedGoHomeDate),
|
expectedGoHomeDate: nn(form.expectedGoHomeDate),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
}
|
}
|
||||||
try {
|
const result = await mutation.run(body)
|
||||||
const saved = await mutation.run(body)
|
if (result.ok) navigate(`/wuerfe/${result.value.id}`)
|
||||||
navigate(`/wuerfe/${saved.id}`)
|
// On failure mutation.error drives the inline alert; run() never throws.
|
||||||
} catch {
|
|
||||||
// Error surfaced via mutation.error; swallow to avoid an unhandled
|
|
||||||
// rejection escaping this async submit handler.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user