From 1ae50826b170f974d42c78fe194383c62146ad3d Mon Sep 17 00:00:00 2001 From: Lucien <164857476+dakjdakd@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:26:48 +0800 Subject: [PATCH] fix(frontend): isolate workspace query caches by authenticated owner (#7146) * fix(frontend): isolate query caches by authenticated owner * fix(frontend): share auth owner across isolation boundaries * docs(frontend): keep agent guidance within size budget --------- Co-authored-by: Willem Jiang --- frontend/AGENTS.md | 17 +- frontend/README.md | 2 + .../src/components/query-client-provider.tsx | 25 +- .../workspace/gateway-offline-banner.tsx | 14 +- frontend/src/core/auth/AuthProvider.tsx | 25 +- frontend/src/core/auth/query-scope.ts | 8 + .../query-client-provider.dom.test.tsx | 338 ++++++++++++++++++ .../gateway-offline-banner.dom.test.tsx | 75 +++- .../unit/core/auth/AuthProvider.dom.test.tsx | 72 ++++ 9 files changed, 559 insertions(+), 17 deletions(-) create mode 100644 frontend/src/core/auth/query-scope.ts create mode 100644 frontend/tests/unit/components/query-client-provider.dom.test.tsx diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index f3c831c306..de6d1b6ec3 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -108,16 +108,15 @@ Users create **threads** (conversations), send messages, set thread-scoped `/goa ### Source Layout (`src/`) -- `app/` — Next.js routes: chat/workspace, agents, auth, showcase, artifact views, - localized docs/blog, and API handlers. -- `components/` — generated `ui/` and `ai-elements/`; application components under - `workspace/`, `landing/`, and `docs/`. -- `core/` — domain logic, API clients, state/hooks, i18n, and Markdown renderers. -- `hooks/`, `lib/`, `typings/` — shared hooks, utilities, and ambient types. -- `content/`, `styles/` — MDX content and Tailwind styles. -- `env.js`, `mdx-components.ts` — environment validation and MDX component map. +`AuthProvider` publishes the query owner (user ID, confirmed logout, or unknown +auth). `QueryClientProvider` owns a mount-local client and remounts its consumers +when that owner changes, clearing the old cache and cancelling old queries. +Confirmed logout unmounts query consumers immediately; public and unknown-auth +routes still render with an independent client. Keep auth outside the query +provider. Superseded auth refreshes cannot restore an earlier owner. -Read [src/AGENTS.md](src/AGENTS.md) for component and domain ownership. +See [project structure](README.md#project-structure) for the directory map and +[src/AGENTS.md](src/AGENTS.md) for component and domain ownership. ## Code Style diff --git a/frontend/README.md b/frontend/README.md index f6bfa087eb..ed89081da9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -2,6 +2,8 @@ Like the original DeerFlow 1.0, we would love to give the community a minimalistic and easy-to-use web interface with a more modern and flexible architecture. +Switching accounts reloads projects, custom agents, models, and other cached workspace data for the new account. Logging out immediately hides the previous account's data; refreshing the same account preserves its cache. + ## Tech Stack - **Framework**: [Next.js 16](https://nextjs.org/) with [App Router](https://nextjs.org/docs/app) diff --git a/frontend/src/components/query-client-provider.tsx b/frontend/src/components/query-client-provider.tsx index d10dd23f13..0c0da2dedd 100644 --- a/frontend/src/components/query-client-provider.tsx +++ b/frontend/src/components/query-client-provider.tsx @@ -4,14 +4,37 @@ import { QueryClient, QueryClientProvider as TanStackQueryClientProvider, } from "@tanstack/react-query"; +import { useContext, useEffect, useState } from "react"; -const queryClient = new QueryClient(); +import { AuthQueryScopeContext } from "@/core/auth/query-scope"; export function QueryClientProvider({ children, }: Readonly<{ children: React.ReactNode; }>) { + const owner = useContext(AuthQueryScopeContext); + // A confirmed logout must not start fresh reads with the old cookie while + // the logout POST is still pending. Public/unknown-auth routes still render. + if (owner === null) return null; + + // Remount consumers as well as the client, so no observer/local state can + // retain an old account's data for the new account's first render. + return ( + + {children} + + ); +} + +function ScopedQueryClientProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [queryClient] = useState(() => new QueryClient()); + useEffect(() => () => queryClient.clear(), [queryClient]); + return ( {children} diff --git a/frontend/src/components/workspace/gateway-offline-banner.tsx b/frontend/src/components/workspace/gateway-offline-banner.tsx index 31e3a7426b..626bb215bd 100644 --- a/frontend/src/components/workspace/gateway-offline-banner.tsx +++ b/frontend/src/components/workspace/gateway-offline-banner.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useContext, useEffect, useRef } from "react"; import { useAuth } from "@/core/auth/AuthProvider"; +import { AuthQueryScopeContext } from "@/core/auth/query-scope"; import { userSchema, type User } from "@/core/auth/types"; import { useI18n } from "@/core/i18n/hooks"; @@ -27,6 +28,7 @@ export function GatewayOfflineBanner({ }: GatewayOfflineBannerProps) { const { t } = useI18n(); const { user, applyUser, refreshUser, logout } = useAuth(); + const authScope = useContext(AuthQueryScopeContext); // Guard against piling up probe calls while the gateway is still slow. const inFlightRef = useRef(false); // Count consecutive 401s so we can distinguish "transient warm-up 401" @@ -35,11 +37,14 @@ export function GatewayOfflineBanner({ useEffect(() => { if (!gatewayUnavailable) return; + // Logout from unknown auth keeps user=null, but changes this scope. + if (authScope === null) return; // Once AuthProvider has a user again the banner has served its // purpose; tear down the polling so we don't keep probing every 10s // for the entire lifetime of the page (gatewayUnavailable is a // server-rendered prop and stays true until a full reload). if (user !== null) return; + let disposed = false; const probe = async () => { if (inFlightRef.current) return; @@ -71,8 +76,9 @@ export function GatewayOfflineBanner({ console.warn("[gateway-offline-banner] probe failed:", err); errored = true; } finally { - inFlightRef.current = false; + if (!disposed) inFlightRef.current = false; } + if (disposed) return; const action = decideProbeAction( authFailuresRef.current, @@ -98,9 +104,11 @@ export function GatewayOfflineBanner({ void probe(); }, OFFLINE_BANNER_RETRY_INTERVAL_MS); return () => { + disposed = true; + inFlightRef.current = false; window.clearInterval(handle); }; - }, [gatewayUnavailable, user, applyUser, refreshUser]); + }, [gatewayUnavailable, user, authScope, applyUser, refreshUser]); if (!shouldShowOfflineBanner(user, gatewayUnavailable)) { return null; diff --git a/frontend/src/core/auth/AuthProvider.tsx b/frontend/src/core/auth/AuthProvider.tsx index daaacda83f..7d35e5be84 100644 --- a/frontend/src/core/auth/AuthProvider.tsx +++ b/frontend/src/core/auth/AuthProvider.tsx @@ -14,6 +14,7 @@ import { useArtifactDraftOwner } from "@/core/artifacts/draft-context"; import { isStaticWebsiteOnly } from "../static-mode"; +import { AuthQueryScopeContext } from "./query-scope"; import { ACCOUNT_DISABLED_LOGIN_URL, type User, @@ -70,8 +71,11 @@ export function AuthProvider({ }: AuthProviderProps) { const [user, setUser] = useState(initialUser); const [authKnown, setAuthKnown] = useState(!initialAuthUnknown); - useArtifactDraftOwner(authKnown ? (user?.id ?? null) : undefined); + const authQueryScope = authKnown ? (user?.id ?? null) : undefined; + useArtifactDraftOwner(authQueryScope); const [isLoading, setIsLoading] = useState(false); + // Logout and externally confirmed users supersede pending auth refreshes. + const authRequestRef = React.useRef(0); const router = useRouter(); const pathname = usePathname(); const staticMode = isStaticWebsiteOnly(); @@ -84,8 +88,10 @@ export function AuthProvider({ * so consumers don't reach into React internals. */ const applyUser = useCallback((next: User | null) => { + authRequestRef.current++; setUser(next); setAuthKnown(true); + setIsLoading(false); }, []); /** @@ -94,15 +100,18 @@ export function AuthProvider({ */ const refreshUser = useCallback(async () => { if (staticMode) return; + const requestId = ++authRequestRef.current; try { setIsLoading(true); const res = await fetch("/api/v1/auth/me", { credentials: "include", }); + if (requestId !== authRequestRef.current) return; if (res.ok) { const data = await res.json(); + if (requestId !== authRequestRef.current) return; setUser(data); setAuthKnown(true); } else if (res.status === 401) { @@ -112,6 +121,7 @@ export function AuthProvider({ .json() .then((data) => parseAuthError(data).code) .catch(() => null); + if (requestId !== authRequestRef.current) return; if (code === "account_disabled") { // Operator-disabled account (#4063 gap 3): the login page states // the reason. Unlike an expired session there is nothing to come @@ -124,11 +134,12 @@ export function AuthProvider({ } } } catch (err) { + if (requestId !== authRequestRef.current) return; console.error("Failed to refresh user:", err); setUser(null); setAuthKnown(false); } finally { - setIsLoading(false); + if (requestId === authRequestRef.current) setIsLoading(false); } }, [staticMode, pathname, router]); @@ -145,8 +156,10 @@ export function AuthProvider({ */ const logout = useCallback(async () => { // Immediately clear local state to prevent UI flicker + authRequestRef.current++; setUser(null); setAuthKnown(true); + setIsLoading(false); if (staticMode) { router.push("/"); @@ -208,7 +221,13 @@ export function AuthProvider({ applyUser, }; - return {children}; + return ( + + + {children} + + + ); } /** diff --git a/frontend/src/core/auth/query-scope.ts b/frontend/src/core/auth/query-scope.ts new file mode 100644 index 0000000000..14ff31ba2d --- /dev/null +++ b/frontend/src/core/auth/query-scope.ts @@ -0,0 +1,8 @@ +"use client"; + +import { createContext } from "react"; + +// undefined: public route or unknown auth; null: confirmed signed out. +export const AuthQueryScopeContext = createContext( + undefined, +); diff --git a/frontend/tests/unit/components/query-client-provider.dom.test.tsx b/frontend/tests/unit/components/query-client-provider.dom.test.tsx new file mode 100644 index 0000000000..d52211f674 --- /dev/null +++ b/frontend/tests/unit/components/query-client-provider.dom.test.tsx @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, expect, rs, test } from "@rstest/core"; +import { + useMutation, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { StrictMode, type ReactNode } from "react"; + +import { QueryClientProvider } from "@/components/query-client-provider"; +import { useAgents } from "@/core/agents/hooks"; +import { AuthProvider, useAuth } from "@/core/auth/AuthProvider"; +import type { User } from "@/core/auth/types"; +import { useModels } from "@/core/models/hooks"; +import { useProjects } from "@/core/projects/hooks"; +import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary"; +import { isStaticWebsiteOnly } from "@/core/static-mode"; + +rs.mock("next/navigation", () => ({ + useRouter: () => ({ push: rs.fn() }), + usePathname: () => "/workspace", +})); +rs.mock("@/core/settings/user-preferences", () => ({ + startUserPreferences: () => () => undefined, +})); +rs.mock("@/core/static-mode", () => ({ + isStaticWebsiteOnly: rs.fn(() => false), +})); +rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" })); + +const userA: User = { + id: "user-A", + email: "a@example.test", + system_role: "user", + needs_setup: false, +}; +const userB: User = { ...userA, id: "user-B", email: "b@example.test" }; +let auth: ReturnType; +let client: QueryClient; +let owner: "A" | "B"; +let failReads: boolean; +let holdAReads: boolean; +let pendingReads: Array<() => void>; +let snapshots: Array<{ user: string | undefined; data: string }>; +const clients = new Set(); +const requests = rs.fn(); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +} + +function response(url: string, account: string) { + if (url.endsWith("/api/projects?status=active")) { + return Response.json({ + projects: [ + { id: `${account}-project`, name: `${account} private project` }, + ], + }); + } + if (url.endsWith("/api/agents")) { + return Response.json({ agents: [{ name: `${account}-private-agent` }] }); + } + if (url.endsWith("/api/models")) { + return Response.json({ models: [{ name: `${account}-private-model` }] }); + } + throw new Error(`Unexpected request: ${url}`); +} + +function AuthProbe() { + auth = useAuth(); + return {auth.user?.id ?? "signed out"}; +} + +function QueryProbe() { + const { user } = useAuth(); + client = useQueryClient(); + clients.add(client); + const projects = useProjects("active"); + const agents = useAgents(); + const models = useModels(); + const data = JSON.stringify({ + projects: projects.data, + agents: agents.agents, + models: models.models, + }); + snapshots.push({ user: user?.id, data }); + return {data}; +} + +function App({ children = }: { children?: ReactNode }) { + return ( + + + + {children} + + + ); +} + +async function expectAccount(account: "A" | "B") { + await waitFor(() => { + const text = screen.getByTestId("data").textContent; + expect(text).toContain(`${account} private project`); + expect(text).toContain(`${account}-private-agent`); + expect(text).toContain(`${account}-private-model`); + }); +} + +beforeEach(() => { + rs.mocked(isStaticWebsiteOnly).mockReturnValue(false); + owner = "A"; + failReads = false; + holdAReads = false; + pendingReads = []; + snapshots = []; + requests.mockReset(); + rs.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + requests(url, owner); + if (url.endsWith("/api/v1/auth/me")) { + return Response.json(owner === "A" ? userA : userB); + } + if (failReads) return new Response("unavailable", { status: 503 }); + if (holdAReads && owner === "A") { + return new Promise((resolve) => { + pendingReads.push(() => resolve(response(url, "A"))); + }); + } + return response(url, owner); + }); +}); + +afterEach(() => { + cleanup(); + for (const queryClient of clients) queryClient.clear(); + clients.clear(); + rs.restoreAllMocks(); +}); + +test("a confirmed account switch never renders the previous account, even when new reads fail", async () => { + render(); + await expectAccount("A"); + const oldClient = client; + owner = "B"; + failReads = true; + await act(async () => auth.refreshUser()); + + expect(screen.getByTestId("user").textContent).toBe(userB.id); + expect(screen.getByTestId("data").textContent).not.toContain("A"); + expect( + snapshots.filter((snapshot) => snapshot.user === userB.id).length, + ).toBeGreaterThan(0); + for (const snapshot of snapshots.filter((item) => item.user === userB.id)) { + expect(snapshot.data).not.toContain("A"); + } + expect(client).not.toBe(oldClient); + expect(oldClient.getQueryCache().getAll()).toEqual([]); + expect(requests).toHaveBeenCalledWith("/api/models", "B"); +}); + +test("late queries from the previous account cannot replace the new account's data", async () => { + holdAReads = true; + render(); + await waitFor(() => expect(pendingReads).toHaveLength(3)); + owner = "B"; + act(() => auth.applyUser(userB)); + await expectAccount("B"); + + await act(async () => { + for (const resolve of pendingReads) resolve(); + }); + await expectAccount("B"); + for (const snapshot of snapshots.filter((item) => item.user === userB.id)) { + expect(snapshot.data).not.toContain("A"); + } +}); + +test("late mutation callbacks retain only the previous account's client", async () => { + const pending = deferred(); + const callback = rs.fn(); + let mutate!: () => Promise; + function MutationProbe() { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: () => pending.promise, + onSuccess: () => { + callback(); + queryClient.setQueryData( + ["projects", { status: "active" }], + [{ id: "A-late-project", name: "A late mutation" }], + ); + }, + }); + mutate = () => mutation.mutateAsync(); + return ; + } + render( + + + , + ); + await expectAccount("A"); + let completion!: Promise; + act(() => { + completion = mutate(); + }); + owner = "B"; + act(() => auth.applyUser(userB)); + await expectAccount("B"); + await act(async () => { + pending.resolve(); + await completion; + }); + + expect(callback).toHaveBeenCalledOnce(); + await expectAccount("B"); + expect(screen.getByTestId("data").textContent).not.toContain("A"); +}); + +test("logout clears query consumers immediately, before the server confirms logout", async () => { + render(); + await expectAccount("A"); + const oldClient = client; + const pending = deferred(); + rs.mocked(globalThis.fetch).mockReturnValue(pending.promise); + let completion!: Promise; + act(() => { + completion = auth.logout(); + }); + + expect(screen.getByTestId("user").textContent).toBe("signed out"); + expect(screen.queryByTestId("data")).toBeNull(); + expect(oldClient.getQueryCache().getAll()).toEqual([]); + expect(globalThis.fetch).toHaveBeenLastCalledWith("/api/v1/auth/logout", { + method: "POST", + credentials: "include", + }); + await act(async () => { + pending.resolve(new Response(null, { status: 204 })); + await completion; + }); +}); + +test.each(["token_expired", "account_disabled"])( + "a confirmed %s session clears the old query cache", + async (code) => { + render(); + await expectAccount("A"); + const oldClient = client; + rs.mocked(globalThis.fetch).mockResolvedValue( + Response.json({ detail: { code, message: code } }, { status: 401 }), + ); + await act(async () => auth.refreshUser()); + + expect(screen.getByTestId("user").textContent).toBe("signed out"); + expect(screen.queryByTestId("data")).toBeNull(); + expect(oldClient.getQueryCache().getAll()).toEqual([]); + }, +); + +test("static demo auth still mounts query consumers and logout clears them", async () => { + rs.mocked(isStaticWebsiteOnly).mockReturnValue(true); + render(); + await waitFor(() => expect(screen.getByTestId("data")).toBeTruthy()); + expect( + requests.mock.calls.some(([url]) => url === "/api/projects?status=active"), + ).toBe(false); + await act(async () => auth.logout()); + + expect(screen.queryByTestId("data")).toBeNull(); + expect( + requests.mock.calls.some(([url]) => url === "/api/v1/auth/logout"), + ).toBe(false); +}); + +test("refreshing the same account preserves the client and session-fresh model data", async () => { + render(); + await expectAccount("A"); + const oldClient = client; + await act(async () => auth.refreshUser()); + + expect(client).toBe(oldClient); + expect( + requests.mock.calls.filter(([url]) => url === "/api/models"), + ).toHaveLength(1); + await expectAccount("A"); +}); + +test("StrictMode effect replay preserves active observers and cache invalidation", async () => { + render( + + + , + ); + await expectAccount("A"); + expect(client.getQueryCache().getAll()).toHaveLength(3); + owner = "B"; + await act(async () => { + await client.invalidateQueries(); + }); + await expectAccount("B"); + expect(client.getQueryCache().getAll()).toHaveLength(3); +}); + +test("providers without auth and with unknown auth have independent mount-local caches", () => { + function ClientProbe() { + client = useQueryClient(); + clients.add(client); + return public content; + } + const first = render( + + + , + ); + const firstClient = client; + firstClient.setQueryData(["models"], "private data"); + first.unmount(); + render( + + + + + , + ); + + expect(screen.getByText("public content")).toBeTruthy(); + expect(client).not.toBe(firstClient); + expect(client.getQueryData(["models"])).toBeUndefined(); +}); diff --git a/frontend/tests/unit/components/workspace/gateway-offline-banner.dom.test.tsx b/frontend/tests/unit/components/workspace/gateway-offline-banner.dom.test.tsx index b4c573c5fd..ec7476924f 100644 --- a/frontend/tests/unit/components/workspace/gateway-offline-banner.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/gateway-offline-banner.dom.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, rs } from "@rstest/core"; import { + act, cleanup, fireEvent, render, @@ -8,7 +9,8 @@ import { } from "@testing-library/react"; import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner"; -import { AuthProvider } from "@/core/auth/AuthProvider"; +import { AuthProvider, useAuth } from "@/core/auth/AuthProvider"; +import type { User } from "@/core/auth/types"; // AuthProvider pulls useRouter/usePathname from next/navigation. Hand it a // no-op router so logout()'s `router.push("/")` stays inert under happy-dom @@ -129,3 +131,74 @@ describe("GatewayOfflineBanner logout recovery (#3001)", () => { expect(logoutCall.method).not.toBe("GET"); }); }); + +it.each(["switch account", "logout", "unmount"] as const)( + "ignores a probe body that finishes after %s", + async (action) => { + const userA: User = { + id: "account-a", + email: "a@example.com", + system_role: "user", + needs_setup: false, + }; + const userB = { ...userA, id: "account-b" }; + let auth!: ReturnType; + let resolve!: (user: User) => void; + const body = new Promise((done) => (resolve = done)); + const response = Response.json(userA); + const json = rs.fn(() => body); + response.json = json; + rs.spyOn(globalThis, "fetch").mockImplementation(async (input) => + input === "/api/v1/auth/logout" + ? new Response(null, { status: 204 }) + : response, + ); + function AuthProbe() { + auth = useAuth(); + return {auth.user?.id ?? "no user"}; + } + function Recovery({ showBanner = true }: { showBanner?: boolean }) { + return ( + + + {showBanner && } + + ); + } + const view = render(); + await waitFor(() => expect(json).toHaveBeenCalledOnce()); + await act(async () => { + if (action === "switch account") auth.applyUser(userB); + else if (action === "logout") await auth.logout(); + else view.rerender(); + }); + await act(async () => resolve(userA)); + + expect(auth.user).toEqual(action === "switch account" ? userB : null); + expect(screen.queryByText(userA.id)).toBeNull(); + }, +); + +it("applies the active recovery probe without a second auth request", async () => { + const user: User = { + id: "recovered-account", + email: "recovered@example.com", + system_role: "user", + needs_setup: false, + }; + const fetch = rs + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json(user)); + function AuthProbe() { + return {useAuth().user?.id}; + } + render( + + + + , + ); + + expect(await screen.findByText(user.id)).toBeTruthy(); + expect(fetch).toHaveBeenCalledOnce(); +}); diff --git a/frontend/tests/unit/core/auth/AuthProvider.dom.test.tsx b/frontend/tests/unit/core/auth/AuthProvider.dom.test.tsx index d5a0657076..8e98beb8b4 100644 --- a/frontend/tests/unit/core/auth/AuthProvider.dom.test.tsx +++ b/frontend/tests/unit/core/auth/AuthProvider.dom.test.tsx @@ -124,3 +124,75 @@ test("useRequireAuth returns to the page it guards, query string included", () = "/login?next=%2Fworkspace%2Fscheduled-tasks%3Ftask_id%3Dt-1", ); }); + +test.each(["applyUser", "logout"] as const)( + "%s supersedes an older successful auth refresh", + async (action) => { + mount(); + let resolve!: (response: Response) => void; + const pending = new Promise((done) => (resolve = done)); + globalThis.fetch = rs.fn((input) => + input === "/api/v1/auth/me" + ? pending + : Promise.resolve(new Response(null, { status: 204 })), + ) as typeof globalThis.fetch; + let completion!: Promise; + act(() => { + completion = ctx!.refreshUser(); + }); + const nextUser = { ...initialUser, id: "u-2" }; + await act(async () => { + if (action === "logout") await ctx!.logout(); + else ctx!.applyUser(nextUser); + }); + await act(async () => { + resolve(jsonResponse(initialUser)); + await completion; + }); + + expect(ctx!.user).toEqual(action === "logout" ? null : nextUser); + expect(ctx!.isLoading).toBe(false); + }, +); + +test("a stale disabled-session response cannot clear a newer account or redirect it", async () => { + mount(); + let resolve!: (response: Response) => void; + globalThis.fetch = rs.fn( + () => new Promise((done) => (resolve = done)), + ); + let completion!: Promise; + act(() => { + completion = ctx!.refreshUser(); + }); + const nextUser = { ...initialUser, id: "u-2" }; + act(() => ctx!.applyUser(nextUser)); + await act(async () => { + resolve(jsonResponse({ detail: { code: "account_disabled" } }, 401)); + await completion; + }); + + expect(ctx!.user).toEqual(nextUser); + expect(push).not.toHaveBeenCalled(); +}); + +test("a response whose body completes after an account switch cannot restore the old account", async () => { + mount(); + let resolve!: (user: User) => void; + const body = new Promise((done) => (resolve = done)); + const response = jsonResponse(initialUser); + response.json = () => body; + globalThis.fetch = rs.fn(async () => response); + let completion!: Promise; + await act(async () => { + completion = ctx!.refreshUser(); + }); + const nextUser = { ...initialUser, id: "u-2" }; + act(() => ctx!.applyUser(nextUser)); + await act(async () => { + resolve(initialUser); + await completion; + }); + + expect(ctx!.user).toEqual(nextUser); +});