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 <willem.jiang@gmail.com>
This commit is contained in:
Lucien 2026-10-20 10:26:48 +08:00 committed by GitHub
parent ee02e51df9
commit 1ae50826b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 559 additions and 17 deletions

View File

@ -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

View File

@ -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)

View File

@ -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 (
<ScopedQueryClientProvider key={owner}>
{children}
</ScopedQueryClientProvider>
);
}
function ScopedQueryClientProvider({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(() => new QueryClient());
useEffect(() => () => queryClient.clear(), [queryClient]);
return (
<TanStackQueryClientProvider client={queryClient}>
{children}

View File

@ -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;

View File

@ -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<User | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
return (
<AuthContext.Provider value={value}>
<AuthQueryScopeContext.Provider value={authQueryScope}>
{children}
</AuthQueryScopeContext.Provider>
</AuthContext.Provider>
);
}
/**

View File

@ -0,0 +1,8 @@
"use client";
import { createContext } from "react";
// undefined: public route or unknown auth; null: confirmed signed out.
export const AuthQueryScopeContext = createContext<string | null | undefined>(
undefined,
);

View File

@ -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<typeof useAuth>;
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<QueryClient>();
const requests = rs.fn();
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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 <output data-testid="user">{auth.user?.id ?? "signed out"}</output>;
}
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 <output data-testid="data">{data}</output>;
}
function App({ children = <QueryProbe /> }: { children?: ReactNode }) {
return (
<AuthProvider initialUser={userA}>
<AuthProbe />
<QueryClientProvider>
<UserPreferencesBoundary>{children}</UserPreferencesBoundary>
</QueryClientProvider>
</AuthProvider>
);
}
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<Response>((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(<App />);
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(<App />);
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<void>();
const callback = rs.fn();
let mutate!: () => Promise<void>;
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 <QueryProbe />;
}
render(
<App>
<MutationProbe />
</App>,
);
await expectAccount("A");
let completion!: Promise<void>;
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(<App />);
await expectAccount("A");
const oldClient = client;
const pending = deferred<Response>();
rs.mocked(globalThis.fetch).mockReturnValue(pending.promise);
let completion!: Promise<void>;
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(<App />);
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(<App />);
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(<App />);
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(
<StrictMode>
<App />
</StrictMode>,
);
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 <output>public content</output>;
}
const first = render(
<QueryClientProvider>
<ClientProbe />
</QueryClientProvider>,
);
const firstClient = client;
firstClient.setQueryData(["models"], "private data");
first.unmount();
render(
<AuthProvider initialUser={null} initialAuthUnknown>
<QueryClientProvider>
<ClientProbe />
</QueryClientProvider>
</AuthProvider>,
);
expect(screen.getByText("public content")).toBeTruthy();
expect(client).not.toBe(firstClient);
expect(client.getQueryData(["models"])).toBeUndefined();
});

View File

@ -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<typeof useAuth>;
let resolve!: (user: User) => void;
const body = new Promise<User>((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 <output>{auth.user?.id ?? "no user"}</output>;
}
function Recovery({ showBanner = true }: { showBanner?: boolean }) {
return (
<AuthProvider initialUser={null} initialAuthUnknown>
<AuthProbe />
{showBanner && <GatewayOfflineBanner gatewayUnavailable />}
</AuthProvider>
);
}
const view = render(<Recovery />);
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(<Recovery showBanner={false} />);
});
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 <output>{useAuth().user?.id}</output>;
}
render(
<AuthProvider initialUser={null} initialAuthUnknown>
<AuthProbe />
<GatewayOfflineBanner gatewayUnavailable />
</AuthProvider>,
);
expect(await screen.findByText(user.id)).toBeTruthy();
expect(fetch).toHaveBeenCalledOnce();
});

View File

@ -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<Response>((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<void>;
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<Response>((done) => (resolve = done)),
);
let completion!: Promise<void>;
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<User>((done) => (resolve = done));
const response = jsonResponse(initialUser);
response.json = () => body;
globalThis.fetch = rs.fn(async () => response);
let completion!: Promise<void>;
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);
});