From 5570debc5b94d19e5f499432356cb65a4019c8de Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 9 Nov 2026 17:43:34 -0800 Subject: [PATCH] fix(ui): stop reporting cancelled queries to Sentry (#20430) React Query's CancelledError keeps the default Error name, so the name-based abort check never matched it. A query cancelled with revert: false (for example by the persisted cache when an issue read is denied or the account changes) reached the query cache onError as an "unknown" error and was sent to Sentry. Recognize it by class. Co-Authored-By: Claude Opus 5.5 --- ui/src/api/errors.test.ts | 2 ++ ui/src/api/errors.ts | 7 +++++-- ui/src/lib/query-client.test.ts | 11 +++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ui/src/api/errors.test.ts b/ui/src/api/errors.test.ts index c3d6a0c196e..79ce5a6f006 100644 --- a/ui/src/api/errors.test.ts +++ b/ui/src/api/errors.test.ts @@ -1,3 +1,4 @@ +import { CancelledError } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { ApiError } from "./api-error"; import { @@ -37,6 +38,7 @@ describe("classifyError", () => { ["422", apiError(422), "invalid"], ["500", apiError(500, { error: "boom" }), "unknown"], ["an abort", new DOMException("Aborted", "AbortError"), "aborted"], + ["a React Query cancellation", new CancelledError({ revert: false }), "aborted"], ["a programming TypeError", new TypeError("Cannot read properties of undefined"), "unknown"], ["a plain Error", new Error("nope"), "unknown"], ["undefined", undefined, "unknown"], diff --git a/ui/src/api/errors.ts b/ui/src/api/errors.ts index e4ce2f2029f..677b2d965b4 100644 --- a/ui/src/api/errors.ts +++ b/ui/src/api/errors.ts @@ -12,6 +12,8 @@ * by hand-rolled fetches (health, uploads) classify the same way. */ +import { CancelledError } from "@tanstack/react-query"; + export type ErrorKind = | "transient" | "auth" @@ -181,8 +183,9 @@ function readableServerMessage(err: unknown): string | null { // --- Classification --------------------------------------------------------------- function isAbortError(err: unknown): boolean { - const name = record(err)?.name; - return name === "AbortError" || name === "CancelledError"; + // React Query's `CancelledError` keeps the default `name` ("Error"), so it + // can only be recognized by class. + return record(err)?.name === "AbortError" || err instanceof CancelledError; } function isNetworkTypeError(err: unknown): boolean { diff --git a/ui/src/lib/query-client.test.ts b/ui/src/lib/query-client.test.ts index 951aa1600f3..0c0c2cb4dd1 100644 --- a/ui/src/lib/query-client.test.ts +++ b/ui/src/lib/query-client.test.ts @@ -278,6 +278,17 @@ describe("createAppQueryClient", () => { unsubscribe(); }); + it("does not report a query that was cancelled without reverting", async () => { + const queryFn = vi.fn(() => new Promise(() => {})); + const observer = new QueryObserver(client, { queryKey: ["issues", "detail", "PAP-1"], queryFn }); + const unsubscribe = observer.subscribe(() => {}); + await vi.advanceTimersByTimeAsync(0); + await client.cancelQueries({ queryKey: ["issues", "detail"] }, { revert: false }); + expect(observer.getCurrentResult().error?.message).toBe("CancelledError"); + expect(reportError).not.toHaveBeenCalled(); + unsubscribe(); + }); + it("leaves a cache-only alias stale instead of refetching it without a queryFn", async () => { client.setQueryData(["issues", "detail", "issue-uuid"], { id: "issue-uuid", title: "Seeded" }); const queryFn = vi.fn(async () => "fetched");