diff --git a/packages/core/workspace/mutations.test.tsx b/packages/core/workspace/mutations.test.tsx new file mode 100644 index 0000000000..d7e7965e8c --- /dev/null +++ b/packages/core/workspace/mutations.test.tsx @@ -0,0 +1,121 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { setApiInstance } from "../api"; +import type { ApiClient } from "../api/client"; +import type { Workspace } from "../types"; +import { useDeleteWorkspace } from "./mutations"; +import { workspaceKeys } from "./queries"; + +function createWrapper(qc: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +const makeWorkspace = (id: string, slug: string): Workspace => ({ + id, + name: slug, + slug, + description: null, + context: null, + settings: {}, + repos: [], + issue_prefix: "MUL", + avatar_url: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}); + +describe("useDeleteWorkspace", () => { + let qc: QueryClient; + let deleteWorkspace: ReturnType Promise>>; + + const seedList = () => { + qc.setQueryData(workspaceKeys.list(), [ + makeWorkspace("ws-1", "keep-me"), + makeWorkspace("ws-2", "delete-me"), + ]); + }; + + const cachedList = () => + qc.getQueryData(workspaceKeys.list()) ?? []; + + beforeEach(() => { + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + deleteWorkspace = vi.fn().mockResolvedValue(undefined); + setApiInstance({ deleteWorkspace } as unknown as ApiClient); + }); + + afterEach(() => { + qc.clear(); + vi.restoreAllMocks(); + }); + + it("removes the workspace from the list cache while the DELETE is pending", async () => { + seedList(); + // Hold the DELETE open so we can observe the pending window — the bug + // was that during this window the list cache still contained the + // workspace, so any consumer re-presented it as selectable/current. + let resolveDelete!: () => void; + deleteWorkspace.mockReturnValue( + new Promise((resolve) => { + resolveDelete = resolve; + }), + ); + + const { result } = renderHook(() => useDeleteWorkspace(), { + wrapper: createWrapper(qc), + }); + + let mutationDone: Promise; + await act(async () => { + mutationDone = result.current.mutateAsync("ws-2"); + // Let onMutate (cancelQueries + optimistic removal) run. + await Promise.resolve(); + }); + + expect(deleteWorkspace).toHaveBeenCalledWith("ws-2"); + expect(cachedList().map((w) => w.id)).toEqual(["ws-1"]); + + await act(async () => { + resolveDelete(); + await mutationDone; + }); + expect(cachedList().map((w) => w.id)).toEqual(["ws-1"]); + }); + + it("invalidates the workspace list after a successful delete", async () => { + seedList(); + const { result } = renderHook(() => useDeleteWorkspace(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current.mutateAsync("ws-2"); + }); + + expect(qc.getQueryState(workspaceKeys.list())?.isInvalidated).toBe(true); + }); + + it("rolls the list back when the DELETE fails", async () => { + seedList(); + deleteWorkspace.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useDeleteWorkspace(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await expect(result.current.mutateAsync("ws-2")).rejects.toThrow("boom"); + }); + + await waitFor(() => { + expect(cachedList().map((w) => w.id)).toEqual(["ws-1", "ws-2"]); + }); + }); +}); diff --git a/packages/core/workspace/mutations.ts b/packages/core/workspace/mutations.ts index f23bb7f964..32e9c70571 100644 --- a/packages/core/workspace/mutations.ts +++ b/packages/core/workspace/mutations.ts @@ -39,6 +39,30 @@ export function useDeleteWorkspace() { const qc = useQueryClient(); return useMutation({ mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId), + // Optimistically drop the workspace from the list cache while the + // DELETE is in flight. The delete flow navigates away BEFORE awaiting + // the mutation (see workspace-tab.tsx's navigateAwayFromCurrentWorkspace + // for the CancelledError race that forces that ordering), so during the + // pending window every list consumer — sidebar switcher, by-slug route + // resolution, post-auth destination — must already see the workspace as + // gone, or a concurrent list refetch re-presents it as selectable and + // it can be re-entered mid-delete. + onMutate: async (workspaceId) => { + // Cancel in-flight list fetches so a response that started before the + // delete can't land after the optimistic update and resurrect the row. + await qc.cancelQueries({ queryKey: workspaceKeys.list() }); + const previous = qc.getQueryData(workspaceKeys.list()); + qc.setQueryData(workspaceKeys.list(), (old) => + old?.filter((w) => w.id !== workspaceId), + ); + return { previous }; + }, + // Rollback: the server still has the workspace, so put it back in the + // list (the caller surfaces the error toast). onSettled's invalidate + // then reconciles against server truth either way. + onError: (_err, _workspaceId, ctx) => { + if (ctx?.previous) qc.setQueryData(workspaceKeys.list(), ctx.previous); + }, onSettled: () => { qc.invalidateQueries({ queryKey: workspaceKeys.list() }); },