Skip to content

Five mutation hooks that handle their own errors still replace the page with the error page on failure #1731

Closed

Description

@joshunrau

Five mutation hooks whose failures are already handled still throw them to the route error boundary, so a routine rejection replaces the whole page with the error page. The app's QueryClient sets throwOnError: true for mutations (src/services/react-query.ts), and useMutation rethrows the error during render whenever that is on, even if the caller awaited mutateAsync inside a try. The add-web-data-hook playbook (step 10) says an expected rejection needs throwOnError: false and meta.disableDefaultErrorNotification and an onError notification. useDeleteSeriesInstrumentMutation and useUpdateSeriesInstrumentArchiveMutation do all three. These five do not:

Hook Caller's intended failure behaviour What happens instead
useCreateInstrumentRepoMutation onError toast, Add Repository dialog stays open toast, then the Instrument Repos page is replaced
useSyncInstrumentRepoMutation onError toast toast, then the page is replaced
useDeleteInstrumentRepoMutation onError toast with the API's message (e.g. the 409 when a group was assigned the repo after the page loaded) toast, then the page is replaced
useCreateSeriesInstrumentMutation manage.tsx catches the error and keeps the dialog open "for retry" Manage Group page is replaced, losing its unsaved instrument selection
useUpdateUserMutation admin/users/$userId.tsx catches the error and shows "Could not save all changes…" inline (e.g. on the 403 when an admin disables their own account) generic "HTTP Request Failed" toast, then the page is replaced

#1671 (useUpdateSetupStateMutation on the login page editor) and #1687 (useInstrumentBundle in the instrument preview dialog) report the same error-page replacement in other places. This issue covers only the five hooks above.

The unit tests miss this because they build a QueryClient without the app's throwOnError: true default, and the $userId route test mocks the hook outright. useDeleteSeriesInstrumentMutation.test.ts shows the pattern that would catch it.

Where

  • apps/web/src/hooks/useCreateInstrumentRepoMutation.ts:14-44
  • apps/web/src/hooks/useSyncInstrumentRepoMutation.ts:13-53
  • apps/web/src/hooks/useDeleteInstrumentRepoMutation.ts:13-36
  • apps/web/src/hooks/useCreateSeriesInstrumentMutation.ts:16-44
  • apps/web/src/hooks/useUpdateUserMutation.ts:12-24

For example, useDeleteInstrumentRepoMutation.ts:

return useMutation({
  mutationFn: ({ id }: { id: string }) =>
    axios.delete(`/v1/instrument-repos/${id}`, { meta: { disableDefaultErrorNotification: true } }),
  onError(err) {
    addNotification({ message: getApiErrorMessage(err, /* … */), type: 'error' });
  },
  // no throwOnError: false

Reproduce

  1. As an admin, open Admin Panel → Instrument Repos → Add Repository.
  2. Enter the URL of a GitHub repository that does not exist (or a private one, without a token) and submit.

Actual: the "Failed to import repository" toast appears, and the page is replaced by the error page. Verified for useUpdateUserMutation by rendering it under the app's queryClient with a rejecting axios.patch: the error boundary rendered and the caller's catch message never did.
Expected: the toast appears and the Add Repository dialog stays open with the URL, ready to correct. Likewise, a failed series creation leaves the dialog open, and a failed user save shows the inline "Could not save all changes" alert.

Tests

For each hook, in apps/web/src/hooks/__tests__/<hook>.test.ts, render with new QueryClient({ defaultOptions: { mutations: { throwOnError: true } } }) as useDeleteSeriesInstrumentMutation.test.ts does, and add it('should report a rejection through its own notification without escaping to the route error boundary'). For useUpdateUserMutation, also assert the request sets disableDefaultErrorNotification, so the caller's inline message is the only one.

testing/src/specs/admin-instrument-repos.spec.ts: route POST **/v1/instrument-repos to a 400 with a message, submit the Add Repository form, and assert the toast is shown and the dialog is still open.

Suggested fix

Add throwOnError: false to all five hooks. In useUpdateUserMutation, also pass meta: { disableDefaultErrorNotification: true } on both requests, since its caller renders its own message.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Webapps/web (clinician-facing React SPA)
    Good First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributor
    on Oct 7, 2026
  2. added a commit that references this issue on Oct 29, 2026
    b281d60
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Webapps/web (clinician-facing React SPA)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessGood First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributorPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions