Merge v1.25.1: clear the in-memory query cache at every session boundary

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 21:48:16 +02:00
commit 9c1c0d86a9
4 files changed
+135 -27

No files matched your search

+22 -7
View File
@@ -80,6 +80,18 @@ export default function LoginPage() {
// can ride in parallel with the route transition + page-chunk
// download instead of queueing behind the dashboard mount.
function navigateAfterLogin() {
// Start the new session from an empty in-memory cache. The root
// QueryClient is created once and outlives every client-side navigation,
// so a previous user who closed the tab WITHOUT logging out (no logout
// fired) leaves their non-user-scoped health-data queries
// (`["measurements"]`, `["dashboard","snapshot"]`, …) sitting in memory.
// Clearing here — before any prefetch warms THIS user's data and before we
// navigate — guarantees a fresh login never inherits another account's
// cached entries. Logout / expiry clear the same cache from their own
// boundaries; this covers the no-logout path. `clear()` also drops the
// stale `["auth","me"]`, so the shell refetches it fresh on mount and the
// former explicit `invalidateQueries({ queryKey: auth() })` is redundant.
queryClient.clear();
const target = getRedirectTarget();
if (target === "/" && isDashboardSnapshotEnabled()) {
prefetchDashboardSnapshot(queryClient);
@@ -105,7 +117,8 @@ export default function LoginPage() {
credential,
});
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
// `navigateAfterLogin` clears the in-memory cache (cross-user guard) and
// navigates; the shell refetches `["auth","me"]` fresh on mount.
navigateAfterLogin();
} catch (err) {
// Route rejections carry the envelope error verbatim; everything
@@ -150,7 +163,8 @@ export default function LoginPage() {
return;
}
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
// `navigateAfterLogin` clears the in-memory cache (cross-user guard) and
// navigates; the shell refetches `["auth","me"]` fresh on mount.
navigateAfterLogin();
} catch (err) {
setError(
@@ -193,7 +207,8 @@ export default function LoginPage() {
challengeId,
credential,
});
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
// `navigateAfterLogin` clears the in-memory cache (cross-user guard)
// and navigates; the shell refetches `["auth","me"]` fresh on mount.
navigateAfterLogin();
} catch {
// Autofill aborts (no passkey, user dismisses, password path taken)
@@ -228,10 +243,10 @@ export default function LoginPage() {
<MfaLoginStep
mfaTicket={mfaChallenge.ticket}
methods={mfaChallenge.methods}
onSuccess={async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.auth(),
});
onSuccess={() => {
// `navigateAfterLogin` clears the in-memory cache (cross-user
// guard) and navigates; the shell refetches `["auth","me"]`
// fresh on mount.
navigateAfterLogin();
}}
onCancel={() => {
+11 -9
View File
@@ -1,5 +1,6 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
@@ -8,8 +9,7 @@ import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { BottomNav } from "./bottom-nav";
@@ -52,6 +52,7 @@ export function AuthShell({
const { t } = useTranslations();
const pathname = usePathname();
const router = useRouter();
const queryClient = useQueryClient();
const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
// v1.4.26 — `/privacy` is a long-form legal page that brings its own
@@ -129,15 +130,16 @@ export function AuthShell({
useEffect(() => {
if (!isLoading && !isAuthenticated && !isPublicPage) {
// Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe
// every client-side cache that can hold the previous account's health
// data before bouncing to login, so it never leaks to the next account
// on a shared device. Logout does the same from `useLogout`; this covers
// the expiry path that logout never runs through. Best-effort; the
// redirect proceeds regardless.
void clearOfflineCachesForSessionEnd();
// the in-memory query cache (the cross-user guard — the QueryClient
// outlives the navigation on this SPA and the health-data families are
// not user-scoped) plus the persisted IndexedDB + SW caches, so nothing
// leaks to the next account on a shared browser. Logout does the same
// from `useLogout`; this covers the expiry path logout never runs
// through. Best-effort; the redirect proceeds regardless.
clearCachesForSessionEnd(queryClient);
router.replace("/auth/login");
}
}, [isLoading, isAuthenticated, isPublicPage, router]);
}, [isLoading, isAuthenticated, isPublicPage, router, queryClient]);
// Redirect non-admins away from /admin
useEffect(() => {
@@ -0,0 +1,71 @@
/**
* Cross-user in-memory cache leak (v1.25.1).
*
* The root QueryClient is created once and survives every client-side
* navigation, so logout→login on the same browser used to leave the previous
* account's NON-user-scoped health-data queries (`["measurements"]`,
* `["dashboard","snapshot"]`, …) sitting in memory — the next account read
* them before any refetch landed. `clearCachesForSessionEnd` drops the entire
* in-memory cache at the session boundary; this test pins that guarantee on a
* real QueryClient.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
// The offline-cache wipe touches IndexedDB + the SW caches, neither of which
// exists in the node test env — mock it to a spy so we can assert it still
// runs alongside the in-memory clear without booting a browser.
const clearOfflineCachesForSessionEnd = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/pwa/query-persister", () => ({
clearOfflineCachesForSessionEnd: () => clearOfflineCachesForSessionEnd(),
}));
// `use-auth` pulls in the typed fetch wrapper, the router, and the i18n
// context at module load. None of them matter for the pure session-end
// helper, so stub them so the import is side-effect free in the test env.
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn(),
apiFetchRaw: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
import { clearCachesForSessionEnd } from "../use-auth";
afterEach(() => {
clearOfflineCachesForSessionEnd.mockClear();
});
describe("clearCachesForSessionEnd — cross-user in-memory wipe", () => {
it("drops every in-memory query, including non-auth health data, at the session boundary", () => {
const queryClient = new QueryClient();
// Seed the cache as a logged-in account would leave it: an auth/me entry
// plus a non-user-scoped health-data family.
queryClient.setQueryData(queryKeys.authMe(), { id: "account-a" });
queryClient.setQueryData(["measurements"], [{ id: "m1", value: 72 }]);
queryClient.setQueryData(["dashboard", "snapshot"], { weightKg: 80 });
expect(queryClient.getQueryCache().getAll().length).toBeGreaterThan(0);
clearCachesForSessionEnd(queryClient);
// Nothing survives into the next session: the in-memory cache is empty,
// so the health-data entries can never render for the next account.
expect(queryClient.getQueryCache().getAll()).toHaveLength(0);
expect(queryClient.getQueryData(["measurements"])).toBeUndefined();
expect(queryClient.getQueryData(["dashboard", "snapshot"])).toBeUndefined();
expect(queryClient.getQueryData(queryKeys.authMe())).toBeUndefined();
// The persisted IndexedDB + SW caches are still wiped alongside the
// in-memory clear — the existing offline-leak guard stays intact.
expect(clearOfflineCachesForSessionEnd).toHaveBeenCalledTimes(1);
});
});
+31 -11
View File
@@ -1,6 +1,11 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
useQuery,
useMutation,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { queryKeys } from "@/lib/query-keys";
@@ -231,6 +236,30 @@ export function useAuth() {
};
}
/**
* Wipe every client-side cache at a session END so no session ever inherits
* another account's data on a shared browser.
*
* Primary cross-user guard: `queryClient.clear()` drops the entire IN-MEMORY
* query cache. On a long-lived SPA the root QueryClient instance outlives every
* client-side navigation, and the health-data families
* (`["measurements"]`, `["dashboard","snapshot"]`, `["labs", …]`, `["mood", …]`,
* `["insights", …]`) are NOT user-scoped — so without this wipe the next account
* that logs in on the same browser reads the previous account's cached entries
* before any refetch lands. `clear()` also drops `["auth","me"]`, superseding the
* former explicit `setQueryData(null)` + `invalidateQueries(["auth"])`.
*
* Then `clearOfflineCachesForSessionEnd()` wipes the persisted layers — the
* IndexedDB query snapshot, the SW offline read-data cache (`healthlog-data-*`),
* and the SW page cache (`healthlog-pages-*`, cached navigation HTML). The static
* cache (hashed chunks, icons) carries no PII and stays intact to avoid a
* needless re-download. Best-effort; never blocks the redirect.
*/
export function clearCachesForSessionEnd(queryClient: QueryClient): void {
queryClient.clear();
void clearOfflineCachesForSessionEnd();
}
export function useLogout() {
const queryClient = useQueryClient();
const router = useRouter();
@@ -241,16 +270,7 @@ export function useLogout() {
await apiFetchRaw("/api/auth/logout", { method: "POST" });
},
onSuccess: () => {
queryClient.setQueryData(queryKeys.authMe(), null);
queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
// Defense-in-depth at the session boundary: wipe every client-side cache
// that can hold this account's health data — the IndexedDB query
// snapshot, the SW offline read-data cache (`healthlog-data-*`), and the
// SW page cache (`healthlog-pages-*`, cached navigation HTML) — so
// nothing survives a logout on a shared device. The static cache (hashed
// chunks, icons) carries no PII and is left intact to avoid a needless
// re-download. Best-effort; never blocks the redirect.
void clearOfflineCachesForSessionEnd();
clearCachesForSessionEnd(queryClient);
router.push("/auth/login");
},
// v1.16.4 — a network-failed logout used to do nothing at all (the