Files
multica/packages/core/workspace/mutations.test.tsx
Naiyuan Qing 93aa959239 fix(workspace): await delete before navigating; suppress self-initiated realtime relocate (MUL-4129) (#4983)
* fix(workspace): drop workspace from list cache while delete is pending (MUL-4129)

The delete-workspace flow navigates away before awaiting the DELETE
(required ordering — see navigateAwayFromCurrentWorkspace's
CancelledError notes), but useDeleteWorkspace left the workspace in the
list cache until onSettled. During the pending window any list refetch
re-presented the deleting workspace as a selectable/current option.

Optimistically remove it in onMutate (after cancelling in-flight list
fetches), roll the snapshot back in onError so a failed delete restores
the workspace alongside the existing error toast, and keep the
onSettled invalidate as the server-truth reconcile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(workspace): own storage cleanup on delete success and tombstone pending deletes (MUL-4129)

Address final-review blockers on #4980:

1. The realtime workspace:deleted handler reverse-looks-up the slug from
   the list cache to clear the ${key}:${slug} persisted namespace; the
   optimistic removal empties that row on the initiating client, so the
   lookup misses and cleanup was silently skipped. Capture the slug in
   onMutate before removal and clear storage in onSuccess only — a
   failed DELETE rolls back and must not touch persisted state.

2. cancelQueries only covered fetches already in flight at onMutate.
   Add a pending-delete tombstone (marked onMutate, lifted onSettled
   before the reconcile invalidate) filtered inside workspaceListOptions'
   queryFn, so invalidation/reconnect/fetchQuery refetches that land
   mid-pending cannot write the not-yet-committed row back into cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs(claude-md): scope optimistic updates to same-screen field patches

Replace the blanket "mutations optimistic by default" state rule with
three scoped rules: optimistic only for predictable same-screen field
patches; navigating/confirming flows (create/delete/leave) await the
server first; chat send uses the pending-message pattern.

Aligned with TanStack Query maintainer guidance and React Router's
pending-UI criteria; the old blanket rule is what steered the original
MUL-4129 fix toward optimistic entity removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(workspace): await delete before navigating; drop optimistic removal (MUL-4129)

Rework of the previous approach on this branch. The optimistic removal
emptied the workspace list cache at click time, while the settings page
was still mounted on the old slug — useWorkspaceId (URL slug + list
lookup) then threw 'no workspace selected'. Root cause of the original
navigate-first ordering was dual ownership of delete handling between
the initiating flow and the realtime workspace:deleted handler.

- useDeleteWorkspace: no optimistic removal, no rollback; onMutate only
  marks the delete self-initiated and captures the slug; onSuccess owns
  storage cleanup; onSettled invalidates.
- pending-delete.ts: repurposed from tombstone filter to self-initiated
  marker; kept on success (suppresses the WS echo), lifted on failure.
- use-realtime-sync: workspace:deleted no-ops for self-initiated
  deletes; it now only serves deletes initiated elsewhere.
- workspace-tab: confirm dialog stays open in loading state, navigate
  only after the DELETE succeeds; failure leaves the user in place with
  nothing to roll back. Replace throwing useWorkspaceId with
  workspace?.id + enabled gating (independent crash on external
  deletes of the current workspace).

Known debt: useLeaveWorkspace still navigates before awaiting
(member:removed has no self-initiated marker yet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 13:45:49 +08:00

178 lines
5.9 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } 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 { defaultStorage } from "../platform/storage";
import type { Workspace } from "../types";
import { useDeleteWorkspace } from "./mutations";
import { workspaceKeys } from "./queries";
import {
isWorkspaceDeletePending,
unmarkWorkspaceDeletePending,
} from "./pending-delete";
function createWrapper(qc: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
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<typeof vi.fn<(id: string) => Promise<void>>>;
let listWorkspaces: ReturnType<typeof vi.fn<() => Promise<Workspace[]>>>;
const serverList = () => [
makeWorkspace("ws-1", "keep-me"),
makeWorkspace("ws-2", "delete-me"),
];
const seedList = () => {
qc.setQueryData<Workspace[]>(workspaceKeys.list(), serverList());
};
const cachedList = () =>
qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
deleteWorkspace = vi.fn().mockResolvedValue(undefined);
listWorkspaces = vi.fn().mockResolvedValue(serverList());
setApiInstance({ deleteWorkspace, listWorkspaces } as unknown as ApiClient);
});
afterEach(() => {
qc.clear();
// The self-initiated marker is module state and is intentionally KEPT
// after a successful delete (it suppresses the WS echo); reset it so
// tests stay independent.
unmarkWorkspaceDeletePending("ws-2");
localStorage.clear();
vi.restoreAllMocks();
});
it("leaves the list cache untouched while the DELETE is pending (no optimistic removal)", async () => {
seedList();
// Hold the DELETE open to observe the pending window. The flow awaits
// the mutation with the dialog in a loading state, so the cache must
// keep reflecting server truth: the workspace still exists.
let resolveDelete!: () => void;
deleteWorkspace.mockReturnValue(
new Promise<void>((resolve) => {
resolveDelete = resolve;
}),
);
const { result } = renderHook(() => useDeleteWorkspace(), {
wrapper: createWrapper(qc),
});
let mutationDone: Promise<void>;
await act(async () => {
mutationDone = result.current.mutateAsync("ws-2");
await Promise.resolve();
});
expect(deleteWorkspace).toHaveBeenCalledWith("ws-2");
expect(cachedList().map((w) => w.id)).toEqual(["ws-1", "ws-2"]);
await act(async () => {
resolveDelete();
await mutationDone;
});
});
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("clears the deleted slug's workspace-scoped storage on success", async () => {
seedList();
// The realtime `workspace:deleted` handler skips self-initiated deletes,
// so the mutation owns this cleanup; the slug is captured from the list
// cache before the mutation fires.
defaultStorage.setItem("multica_issue_draft:delete-me", "draft");
defaultStorage.setItem("multica_issue_draft:keep-me", "draft");
const { result } = renderHook(() => useDeleteWorkspace(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync("ws-2");
});
expect(defaultStorage.getItem("multica_issue_draft:delete-me")).toBeNull();
expect(defaultStorage.getItem("multica_issue_draft:keep-me")).toBe("draft");
});
it("leaves storage and cache untouched when the DELETE fails", async () => {
seedList();
deleteWorkspace.mockRejectedValue(new Error("boom"));
defaultStorage.setItem("multica_issue_draft:delete-me", "draft");
const { result } = renderHook(() => useDeleteWorkspace(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await expect(result.current.mutateAsync("ws-2")).rejects.toThrow("boom");
});
// No optimistic write happened, so there is nothing to roll back.
expect(defaultStorage.getItem("multica_issue_draft:delete-me")).toBe("draft");
expect(cachedList().map((w) => w.id)).toEqual(["ws-1", "ws-2"]);
});
it("keeps the self-initiated marker after success and lifts it after failure", async () => {
seedList();
const { result } = renderHook(() => useDeleteWorkspace(), {
wrapper: createWrapper(qc),
});
// Success: the id is gone for good; the kept marker suppresses the WS
// echo of our own delete whenever it arrives.
await act(async () => {
await result.current.mutateAsync("ws-2");
});
expect(isWorkspaceDeletePending("ws-2")).toBe(true);
// Failure: the workspace still exists, so a later external delete of
// the same id must be handled by the realtime handler again.
unmarkWorkspaceDeletePending("ws-2");
deleteWorkspace.mockRejectedValue(new Error("boom"));
await act(async () => {
await expect(result.current.mutateAsync("ws-2")).rejects.toThrow("boom");
});
expect(isWorkspaceDeletePending("ws-2")).toBe(false);
});
});