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");