From cf9db29b07ef57a5ec1bbd10b5ecb592728a3660 Mon Sep 17 00:00:00 2001 From: Ryan Cartwright Date: Wed, 11 Nov 2026 14:14:17 +1100 Subject: [PATCH] fix(web): clear a left workspace from the app instead of refetching it Leaving invalidated every query, so the screens on the workspace just left asked again and got 403s, and the page fell to "No such workspace here". It now leaves the list like a deleted one, opens /, and drops what was cached under its id. --- packages/web/src/lib/workspace.ts | 22 +++++++++++++++++----- packages/web/src/shell/shell.test.tsx | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/web/src/lib/workspace.ts b/packages/web/src/lib/workspace.ts index 070c6fd8..83f21ebf 100644 --- a/packages/web/src/lib/workspace.ts +++ b/packages/web/src/lib/workspace.ts @@ -13,7 +13,7 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; -import { useParams } from "@tanstack/react-router"; +import { useNavigate, useParams } from "@tanstack/react-router"; import { Effect, Schema } from "effect"; import { useSyncExternalStore } from "react"; import { client } from "@/api.ts"; @@ -189,17 +189,29 @@ export function useCancelWorkspaceInvitation(workspaceId: string) { } /** - * Leaving takes the whole client with it: every pod, agent and conversation on - * screen belonged to a workspace this person is no longer in, so the cache is - * cleared rather than refetched. + * Leaving takes the workspace out of the client rather than refetching it: the + * API answers anything asked about it with a 403 once this person is gone. + * + * Like deleting, the workspace leaves the list at once and `/` opens another, + * or onboarding when there is none. Once its screens have closed, what is + * cached under its id goes too. Pods and conversations are cached by their own + * ids, so theirs expires unread with the cache's `gcTime` instead. */ export function useLeaveWorkspace(workspaceId: string) { const queries = useQueryClient(); + const navigate = useNavigate(); return useMutation({ mutationFn: () => Effect.runPromise(client.api.workspaces.leave({ params: { workspace: workspaceId } })), onSuccess: async () => { - await queries.invalidateQueries(); + queries.setQueryData(workspacesQuery.queryKey, (workspaces) => + workspaces?.filter((workspace) => workspace.id !== workspaceId), + ); + await navigate({ to: "/", replace: true }); + queries.removeQueries({ + type: "inactive", + predicate: (query) => query.queryKey.includes(workspaceId), + }); }, }); } diff --git a/packages/web/src/shell/shell.test.tsx b/packages/web/src/shell/shell.test.tsx index 7669fd39..ab13282a 100644 --- a/packages/web/src/shell/shell.test.tsx +++ b/packages/web/src/shell/shell.test.tsx @@ -2066,6 +2066,29 @@ describe("workspace settings", () => { }); }); + it("opens another workspace once you leave, asking nothing more of the one you left", async () => { + const other = { ...workspace, id: "0199a3a0-0000-7000-8000-0000000000f9", slug: "nitric" }; + client.api.workspaces.list.mockReturnValue(Effect.succeed([workspace, other])); + client.api.workspaces.leave.mockImplementation(() => { + client.api.workspaces.list.mockReturnValue(Effect.succeed([other])); + client.api.pods.list.mockClear(); + client.api.workspaceAccess.mockClear(); + return Effect.void; + }); + const router = mount(samPage); + + fireEvent.click(await screen.findByRole("button", { name: "Leave workspace" })); + await screen.findByRole("heading", { name: "Leave this workspace?" }); + fireEvent.click(screen.getByRole("button", { name: "Leave" })); + + await waitFor(() => expect(router.state.location.pathname).toMatch(/^\/nitric\//)); + const askedOfTheLeftWorkspace = [ + ...client.api.pods.list.mock.calls, + ...client.api.workspaceAccess.mock.calls, + ].filter(([request]) => request.params.workspace === workspace.id); + expect(askedOfTheLeftWorkspace).toEqual([]); + }); + it("lists an invitation that has not been accepted, and can withdraw it", async () => { mount("/suga/settings/members");