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 ;
+}
+
+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 ;
+}
+
+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 ;
+ }
+ 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 ;
+ }
+ 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 ;
+ }
+ 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);
+});