From 9f453c8fb6091f6b54369cad1435b1412cc15de5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 12:13:44 -0800 Subject: [PATCH 1/4] fix(web): clear cached data when someone signs out --- apps/web/src/App.tsx | 8 ++++++- apps/web/src/queries/client.test.ts | 34 +++++++++++++++++++++++++++++ apps/web/src/queries/client.ts | 14 ++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/queries/client.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ef1824de..b38e3b13 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,7 +12,7 @@ import { wording } from "./config.ts"; import styles from "./App.module.css"; import { humanFault, responseCode } from "./pages/workspace/human-fault.ts"; import { ProductApiError } from "@corbits-builder/client"; -import { queryClient } from "./queries/client.ts"; +import { forgetQueryCache, queryClient } from "./queries/client.ts"; import { keys } from "./queries/keys.ts"; import { hostLatency, productClient } from "./api/transport.ts"; import { useQuery } from "@tanstack/react-query"; @@ -565,6 +565,7 @@ export function App() { queryKey: keys.projects, queryFn: readProjects, refetchInterval: FOLD_POLL_MS, + enabled: auth === "signed-in", }); const projects = projectsRead.data?.projects ?? NO_PROJECTS; const providers = providersRead.data?.providers ?? NO_PROVIDERS; @@ -595,6 +596,11 @@ export function App() { useEffect(() => { if (signedOutByHost) setAuth("signed-out"); }, [signedOutByHost]); + // Query keys are not scoped to a person, so whatever is cached belongs to + // whoever was signed in, however the session ended. + useEffect(() => { + if (auth === "signed-out") void forgetQueryCache(); + }, [auth]); const [setupLater, setSetupLater] = useState(modelSetupDeferred); const [user, setUser] = useState(null); useEffect(() => { diff --git a/apps/web/src/queries/client.test.ts b/apps/web/src/queries/client.test.ts new file mode 100644 index 00000000..59943783 --- /dev/null +++ b/apps/web/src/queries/client.test.ts @@ -0,0 +1,34 @@ +import { afterAll, expect, mock, test } from "bun:test"; + +const realIdb = await import("idb-keyval"); +const stored = new Map(); +mock.module("idb-keyval", () => ({ + get: (key: string) => Promise.resolve(stored.get(key)), + set: (key: string, value: unknown) => { + stored.set(key, value); + return Promise.resolve(); + }, + del: (key: string) => { + stored.delete(key); + return Promise.resolve(); + }, +})); +afterAll(() => { + mock.module("idb-keyval", () => realIdb); +}); + +const { forgetQueryCache, queryCacheReady, queryClient } = + await import("./client.ts"); + +test("forgetting the cache leaves nothing of the last person, in memory or saved", async () => { + await queryCacheReady; + queryClient.setQueryData(["projects"], { projects: [{ id: "theirs" }] }); + await Bun.sleep(1_100); + expect(JSON.stringify([...stored.values()])).toContain("theirs"); + + await forgetQueryCache(); + await Bun.sleep(1_100); + + expect(queryClient.getQueryCache().getAll()).toHaveLength(0); + expect(JSON.stringify([...stored.values()])).not.toContain("theirs"); +}); diff --git a/apps/web/src/queries/client.ts b/apps/web/src/queries/client.ts index 1e34cb82..dd5411e0 100644 --- a/apps/web/src/queries/client.ts +++ b/apps/web/src/queries/client.ts @@ -60,3 +60,17 @@ async function restoreQueryCache(): Promise { /** Resolves after the saved cache is on the client, before the first paint. */ export const queryCacheReady: Promise = restoreQueryCache(); + +/** + * Drops everything read for the person who just left, in memory and on disk, + * so whoever signs in next never sees it. Clearing first means the save that + * clearing triggers is of an empty cache, never a stale one. + */ +export async function forgetQueryCache(): Promise { + queryClient.clear(); + try { + await persistOptions.persister.removeClient(); + } catch (cause) { + console.warn("The saved query cache could not be removed.", cause); + } +} From fc804670c94e26301f93b077af30ffb040e65865 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 13:38:26 -0800 Subject: [PATCH 2/4] test(web): restore the real idb-keyval after the cache test --- apps/web/src/queries/client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/queries/client.test.ts b/apps/web/src/queries/client.test.ts index 59943783..59045dcb 100644 --- a/apps/web/src/queries/client.test.ts +++ b/apps/web/src/queries/client.test.ts @@ -1,6 +1,6 @@ import { afterAll, expect, mock, test } from "bun:test"; -const realIdb = await import("idb-keyval"); +const realIdb = { ...(await import("idb-keyval")) }; const stored = new Map(); mock.module("idb-keyval", () => ({ get: (key: string) => Promise.resolve(stored.get(key)), From 644156cd9d3f72dccd0bdf9cd57bead0c59d2354 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 13:52:38 -0800 Subject: [PATCH 3/4] refactor(web): trim the sign-out cache clear --- apps/web/src/App.tsx | 8 +++---- apps/web/src/queries/client.test.ts | 34 ----------------------------- apps/web/src/queries/client.ts | 14 ------------ 3 files changed, 4 insertions(+), 52 deletions(-) delete mode 100644 apps/web/src/queries/client.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b38e3b13..99044b35 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,7 +12,7 @@ import { wording } from "./config.ts"; import styles from "./App.module.css"; import { humanFault, responseCode } from "./pages/workspace/human-fault.ts"; import { ProductApiError } from "@corbits-builder/client"; -import { forgetQueryCache, queryClient } from "./queries/client.ts"; +import { queryClient } from "./queries/client.ts"; import { keys } from "./queries/keys.ts"; import { hostLatency, productClient } from "./api/transport.ts"; import { useQuery } from "@tanstack/react-query"; @@ -596,10 +596,10 @@ export function App() { useEffect(() => { if (signedOutByHost) setAuth("signed-out"); }, [signedOutByHost]); - // Query keys are not scoped to a person, so whatever is cached belongs to - // whoever was signed in, however the session ended. + // Query keys are not scoped to a person, so the cache belongs to whoever was + // signed in. Clearing it also saves the empty cache over the stored one. useEffect(() => { - if (auth === "signed-out") void forgetQueryCache(); + if (auth === "signed-out") queryClient.clear(); }, [auth]); const [setupLater, setSetupLater] = useState(modelSetupDeferred); const [user, setUser] = useState(null); diff --git a/apps/web/src/queries/client.test.ts b/apps/web/src/queries/client.test.ts deleted file mode 100644 index 59045dcb..00000000 --- a/apps/web/src/queries/client.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { afterAll, expect, mock, test } from "bun:test"; - -const realIdb = { ...(await import("idb-keyval")) }; -const stored = new Map(); -mock.module("idb-keyval", () => ({ - get: (key: string) => Promise.resolve(stored.get(key)), - set: (key: string, value: unknown) => { - stored.set(key, value); - return Promise.resolve(); - }, - del: (key: string) => { - stored.delete(key); - return Promise.resolve(); - }, -})); -afterAll(() => { - mock.module("idb-keyval", () => realIdb); -}); - -const { forgetQueryCache, queryCacheReady, queryClient } = - await import("./client.ts"); - -test("forgetting the cache leaves nothing of the last person, in memory or saved", async () => { - await queryCacheReady; - queryClient.setQueryData(["projects"], { projects: [{ id: "theirs" }] }); - await Bun.sleep(1_100); - expect(JSON.stringify([...stored.values()])).toContain("theirs"); - - await forgetQueryCache(); - await Bun.sleep(1_100); - - expect(queryClient.getQueryCache().getAll()).toHaveLength(0); - expect(JSON.stringify([...stored.values()])).not.toContain("theirs"); -}); diff --git a/apps/web/src/queries/client.ts b/apps/web/src/queries/client.ts index dd5411e0..1e34cb82 100644 --- a/apps/web/src/queries/client.ts +++ b/apps/web/src/queries/client.ts @@ -60,17 +60,3 @@ async function restoreQueryCache(): Promise { /** Resolves after the saved cache is on the client, before the first paint. */ export const queryCacheReady: Promise = restoreQueryCache(); - -/** - * Drops everything read for the person who just left, in memory and on disk, - * so whoever signs in next never sees it. Clearing first means the save that - * clearing triggers is of an empty cache, never a stale one. - */ -export async function forgetQueryCache(): Promise { - queryClient.clear(); - try { - await persistOptions.persister.removeClient(); - } catch (cause) { - console.warn("The saved query cache could not be removed.", cause); - } -} From 6d6e2bdf58aa1da8358f954fe059f51d3cdec4fc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 14:11:23 -0800 Subject: [PATCH 4/4] fix(web): forget the open project on sign-out --- apps/web/src/App.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 99044b35..1d298ef2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -599,7 +599,9 @@ export function App() { // Query keys are not scoped to a person, so the cache belongs to whoever was // signed in. Clearing it also saves the empty cache over the stored one. useEffect(() => { - if (auth === "signed-out") queryClient.clear(); + if (auth !== "signed-out") return; + queryClient.clear(); + setSelected(null); }, [auth]); const [setupLater, setSetupLater] = useState(modelSetupDeferred); const [user, setUser] = useState(null);