mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
4 files changed
+142
-27
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,6 @@ export {
|
||||
getScopeKey,
|
||||
hasScopedStorage,
|
||||
onStorageScopeChange,
|
||||
queryPersister,
|
||||
setStorageScope,
|
||||
} from "@/lib/mmkv";
|
||||
|
||||
|
||||
Reference in new issue
Block a user