fix(native): persist the query cache per server+user and clear it with the session

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-05 12:51:00 -04:00
1 parent 870d130261
commit 8f4cd03715
4 files changed
+142 -27

No files matched your search

+28 -15
View File
@@ -6,12 +6,14 @@ import {
persistQueryClientRestore,
persistQueryClientSubscribe,
} from "@tanstack/react-query-persist-client";
import * as Application from "expo-application";
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
import { ThemeProvider } from "expo-router/react-navigation";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import * as Updates from "expo-updates";
import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
@@ -23,11 +25,12 @@ import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-bann
import { useServerConnection } from "@/hooks/use-server-connection";
import { useWidgetRefresh } from "@/hooks/use-widget-refresh";
import { initLocale } from "@/lib/i18n";
import { createScopedQueryPersister, hasScopedStorage, scopedStorage } from "@/lib/mmkv";
import { initAnalytics, posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { QUERY_PERSIST_MAX_AGE } from "@/lib/query-config";
import { initSentry, Sentry } from "@/lib/sentry";
import { getScopeKey, initSession, onStorageScopeChange, queryPersister } from "@/lib/server";
import { getScopeKey, initSession, onStorageScopeChange } from "@/lib/server";
import { sofaTheme } from "@/lib/theme";
import { i18n } from "@sofa/i18n";
@@ -137,6 +140,13 @@ function AppContent() {
);
}
/** Persisted caches from another app build or OTA update are discarded, never restored. */
const QUERY_PERSIST_BUSTER = [
Application.nativeApplicationVersion ?? "dev",
Application.nativeBuildVersion ?? "0",
Updates.updateId ?? "embedded",
].join(":");
/**
* Always renders a single QueryClientProvider so the React tree is never torn
* down. Cache persistence is managed imperatively: when scoped storage becomes
@@ -145,13 +155,7 @@ function AppContent() {
* partition, and re-subscribe.
*/
function QueryProvider({ children }: { children: React.ReactNode }) {
const [, setScopeVersion] = useState(0);
useEffect(() => {
return onStorageScopeChange(() => setScopeVersion((n) => n + 1));
}, []);
const scopeKey = getScopeKey();
const scopeKey = useSyncExternalStore(onStorageScopeChange, getScopeKey);
const prevScopeKeyRef = useRef<string | null>(null);
@@ -169,17 +173,26 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
queryClient.clear();
}
if (!scopeKey) return;
if (!scopeKey || !hasScopedStorage()) return;
const options = { queryClient, persister: queryPersister, maxAge: QUERY_PERSIST_MAX_AGE };
const persister = createScopedQueryPersister(scopedStorage());
const options = {
queryClient,
persister,
maxAge: QUERY_PERSIST_MAX_AGE,
buster: QUERY_PERSIST_BUSTER,
dehydrateOptions: { shouldDehydrateMutation: () => false },
};
let unsubscribe: (() => void) | undefined;
let aborted = false;
persistQueryClientRestore(options).then(() => {
if (aborted) return;
unsubscribe = persistQueryClientSubscribe(options);
});
persistQueryClientRestore(options)
.catch((error) => console.warn("[QueryCache] Failed to restore persisted cache:", error))
.then(() => {
if (aborted) return;
unsubscribe = persistQueryClientSubscribe(options);
});
return () => {
aborted = true;
+102
View File
@@ -0,0 +1,102 @@
import type { PersistedClient } from "@tanstack/query-persist-client-core";
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("react-native-mmkv", () => {
const data = new Map<string, Map<string, string>>();
class MockMMKV {
private readonly entries: Map<string, string>;
constructor(config?: { id?: string }) {
const id = config?.id ?? "default";
let entries = data.get(id);
if (!entries) {
entries = new Map();
data.set(id, entries);
}
this.entries = entries;
}
getString(key: string) {
return this.entries.get(key);
}
set(key: string, value: string) {
this.entries.set(key, value);
}
remove(key: string) {
return this.entries.delete(key);
}
contains(key: string) {
return this.entries.has(key);
}
}
return { createMMKV: (config?: { id?: string }) => new MockMMKV(config) };
});
const {
QUERY_CACHE_KEY,
clearStorageScope,
createScopedQueryPersister,
getScopeKey,
onStorageScopeChange,
scopedStorage,
setStorageScope,
} = await import("./mmkv");
function makeClient(timestamp: number): PersistedClient {
return { timestamp, buster: "", clientState: { queries: [], mutations: [] } };
}
afterEach(() => {
vi.useRealTimers();
clearStorageScope();
});
describe("storage scope", () => {
test("tracks the scope key and notifies listeners", () => {
const listener = vi.fn<() => void>();
const unsubscribe = onStorageScopeChange(listener);
expect(getScopeKey()).toBeNull();
setStorageScope("i", "u");
expect(getScopeKey()).toBe("i_u");
expect(listener).toHaveBeenCalledTimes(1);
clearStorageScope();
expect(getScopeKey()).toBeNull();
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
});
});
describe("createScopedQueryPersister", () => {
test("a throttled write lands in the store the persister was created with, not the current scope", async () => {
vi.useFakeTimers();
setStorageScope("i", "a");
const storeA = scopedStorage();
const persister = createScopedQueryPersister(storeA);
const clientA1 = makeClient(1);
const clientA2 = makeClient(2);
// persistClient resolves only once its (possibly throttled) write has flushed.
const writes = [persister.persistClient(clientA1), persister.persistClient(clientA2)];
setStorageScope("i", "b");
const storeB = scopedStorage();
await vi.advanceTimersByTimeAsync(1100);
await Promise.all(writes);
const stored = storeA.getString(QUERY_CACHE_KEY);
expect(stored).toBeDefined();
expect(JSON.parse(stored as string)).toEqual(clientA2);
expect(storeB.getString(QUERY_CACHE_KEY)).toBeUndefined();
});
});
+12 -11
View File
@@ -43,16 +43,17 @@ export function onStorageScopeChange(callback: () => void): () => void {
};
}
// Query persister — reads/writes through scopedStore via closure
const scopedMmkvStorage = {
getItem: (key: string) => scopedStore?.getString(key) ?? null,
setItem: (key: string, value: string) => scopedStore?.set(key, value),
removeItem: (key: string) => void scopedStore?.remove(key),
};
export const QUERY_CACHE_KEY = "REACT_QUERY_OFFLINE_CACHE";
export const queryPersister = createAsyncStoragePersister({
storage: scopedMmkvStorage,
key: QUERY_CACHE_KEY,
});
/** A query persister bound to one scope's store, so delayed (throttled) writes can't land in
* another server/user's partition after a switch. */
export function createScopedQueryPersister(store: ReturnType<typeof createMMKV>) {
return createAsyncStoragePersister({
storage: {
getItem: (key) => store.getString(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => void store.remove(key),
},
key: QUERY_CACHE_KEY,
});
}
-1
View File
@@ -15,7 +15,6 @@ export {
getScopeKey,
hasScopedStorage,
onStorageScopeChange,
queryPersister,
setStorageScope,
} from "@/lib/mmkv";