mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 03:03:56 +02:00
* 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>
178 lines
5.9 KiB
TypeScript
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);
|
|
});
|
|
});
|