From a8783f8acebdb50b30cc8118e9d20da18d04fd3b Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 30 Oct 2026 20:44:52 -0500 Subject: [PATCH] fix(ui): invalidate pending startup work on sign-out Keep session-readiness failures inside the existing bounded retry policy. Co-Authored-By: Paperclip --- ui/src/hooks/useDurableAutosave.test.tsx | 69 ++++++++++++++++++++++++ ui/src/hooks/useDurableAutosave.ts | 10 +++- ui/src/hooks/useSignOut.ts | 3 ++ ui/src/lib/account-scope.ts | 6 +++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/ui/src/hooks/useDurableAutosave.test.tsx b/ui/src/hooks/useDurableAutosave.test.tsx index be690a90301..7f2816593c2 100644 --- a/ui/src/hooks/useDurableAutosave.test.tsx +++ b/ui/src/hooks/useDurableAutosave.test.tsx @@ -4,6 +4,8 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "@/api/client"; +import { authApi } from "@/api/auth"; +import { useSignOut } from "./useSignOut"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { queryKeys } from "@/lib/queryKeys"; import { @@ -244,6 +246,73 @@ describe("useDurableAutosave", () => { }, ); + it("invalidates an unknown-account detached draft through the real sign-out reset", async () => { + store.setBrowserOnline(false); + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } }); + client.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "authenticated" }); + const lookup = vi.spyOn(authApi, "getSession").mockReturnValue(new Promise(() => {})); + const signOutApi = vi.spyOn(authApi, "signOut").mockResolvedValue({ success: true }); + const save = vi.fn().mockResolvedValue(undefined); + let signOut!: ReturnType; + function SignOut() { signOut = useSignOut(); return null; } + try { + act(() => root.render( + + )); + await act(async () => { await current.run("Private title before sign-out"); }); + act(() => { root.unmount(); root = createRoot(container); }); + act(() => root.render()); + await act(async () => { await signOut.mutateAsync(); }); + expect(signOutApi).toHaveBeenCalledOnce(); + expect(client.getQueryData(queryKeys.auth.session)).toBeUndefined(); + act(() => client.setQueryData(queryKeys.auth.session, { user: { id: "next-account" } })); + await act(async () => { store.setBrowserOnline(true); store.reportSuccess(); }); + await advance(2_000); + expect(save).not.toHaveBeenCalled(); + expect(store.getSnapshot().pendingWrites).toBe(0); + } finally { client.clear(); lookup.mockRestore(); signOutApi.mockRestore(); } + }); + + it.each([403, 404])("retains detached startup work after a definitive session lookup error (%s)", async status => { + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } }); + const lookup = vi.spyOn(authApi, "getSession").mockRejectedValue(new ApiError("Session lookup unavailable", status, {})); + const save = vi.fn().mockRejectedValueOnce(outage()).mockResolvedValue(undefined); + try { + act(() => root.render( + + )); + await act(async () => { await current.run("Retained title"); }); + act(() => { root.unmount(); root = createRoot(container); }); + await advance(0); + expect(lookup).toHaveBeenCalled(); + expect(save).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().pendingWrites).toBe(1); + act(() => client.setQueryData(queryKeys.auth.session, { user: { id: "first-account" } })); + await advance(30_000); + expect(save).toHaveBeenCalledTimes(2); + expect(save).toHaveBeenLastCalledWith("Retained title"); + expect(store.getSnapshot().pendingWrites).toBe(0); + } finally { client.clear(); lookup.mockRestore(); } + }); + + it("bounds detached session discovery when the first identity never becomes available", async () => { + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } }); + const lookup = vi.spyOn(authApi, "getSession").mockRejectedValue(new ApiError("Session unavailable", 403, {})); + const save = vi.fn().mockRejectedValue(outage()); + try { + act(() => root.render( + + )); + await act(async () => { await current.run("Retained title"); }); + act(() => { root.unmount(); root = createRoot(container); }); + for (let attempt = 0; attempt < 10; attempt++) await advance(30_000); + expect(lookup.mock.calls.length).toBeGreaterThan(1); + expect(lookup.mock.calls.length).toBeLessThanOrEqual(9); + expect(save).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().pendingWrites).toBe(0); + } finally { client.clear(); lookup.mockRestore(); } + }); + it("bounds retries after an editor unmounts", async () => { const save = vi.fn().mockRejectedValue(outage()); render({ save }); diff --git a/ui/src/hooks/useDurableAutosave.ts b/ui/src/hooks/useDurableAutosave.ts index 98e062b77e3..85a994d9fd7 100644 --- a/ui/src/hooks/useDurableAutosave.ts +++ b/ui/src/hooks/useDurableAutosave.ts @@ -248,7 +248,15 @@ export function useDurableAutosave(options: DurableAutosaveOptions): Durab return { kind: "saved" }; }; try { - if (!mounted.current && job.account && !job.account.isCurrentAccount()) await job.account.whenAccountReady(); + if (!mounted.current && job.account && !job.account.isCurrentAccount()) { + try { await job.account.whenAccountReady(); } + catch { + // Session discovery has not attempted the write. Keep its draft within + // the existing retry budget unless an account transition invalidated it. + if (!stillOwned()) remove(); + return { kind: "waiting" }; + } + } if (!stillOwned()) { remove(); return { kind: "waiting" }; } await job.save(job.draft); return done(); diff --git a/ui/src/hooks/useSignOut.ts b/ui/src/hooks/useSignOut.ts index 7d54525c406..ff016a8a776 100644 --- a/ui/src/hooks/useSignOut.ts +++ b/ui/src/hooks/useSignOut.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { authApi } from "@/api/auth"; import { navigateTopLevel } from "@/lib/browserNavigation"; import { queryKeys } from "@/lib/queryKeys"; +import { invalidateAccountScope } from "@/lib/account-scope"; import { useCloudInstance } from "./useCloudInstance"; const CLOUD_SIGN_OUT_PATH = "/cloud/logout"; @@ -47,6 +48,7 @@ export function useSignOut({ onSignedOut }: UseSignOutOptions = {}) { return useMutation({ mutationFn: async () => { if (cloud) { + invalidateAccountScope(queryClient); onSignedOut?.(); navigateTopLevel(CLOUD_SIGN_OUT_PATH); return "cloud" as const; @@ -58,6 +60,7 @@ export function useSignOut({ onSignedOut }: UseSignOutOptions = {}) { onSuccess: async (target) => { if (target === "cloud") return; + invalidateAccountScope(queryClient); onSignedOut?.(); // Drop every account-scoped cache entry, rather than invalidating a diff --git a/ui/src/lib/account-scope.ts b/ui/src/lib/account-scope.ts index 0e7d050e502..eeadd51fc4c 100644 --- a/ui/src/lib/account-scope.ts +++ b/ui/src/lib/account-scope.ts @@ -11,6 +11,12 @@ function currentOwner(client: QueryClient): string | undefined { return session === undefined ? undefined : session?.user?.id ?? session?.session?.userId ?? "signed-out"; } +/** A successful sign-out ends pending work even if the first session never loaded. */ +export function invalidateAccountScope(client: QueryClient) { + const account = accounts.get(client); + if (account) account.generation += 1; +} + export function bindAccount(client: QueryClient) { let account = accounts.get(client); if (!account) {