/** * React Query Cache Persistence * * Persists the React Query cache to IndexedDB so that after a tab discard * or page reload, the user sees cached data instantly while fresh data * loads in the background. * * Uses @tanstack/react-query-persist-client with idb-keyval for IndexedDB storage. * Cached data is treated as stale on restore and silently refetched. */ import { get, set, del } from 'idb-keyval'; import type { PersistedClient, Persister } from '@tanstack/react-query-persist-client'; import { createLogger } from '@automaker/utils/logger'; const logger = createLogger('QueryPersist'); const IDB_KEY = 'automaker-react-query-cache'; /** * Maximum age of persisted cache before it's discarded (24 hours). * After this time, the cache is considered too old and will be removed. */ export const PERSIST_MAX_AGE_MS = 24 * 60 * 60 * 1000; /** * Throttle time for persisting cache to IndexedDB. * Prevents excessive writes during rapid query updates. */ export const PERSIST_THROTTLE_MS = 2000; /** * Query key prefixes that should NOT be persisted. * Auth-related and volatile data should always be fetched fresh. */ const EXCLUDED_QUERY_KEY_PREFIXES = ['auth', 'health', 'wsToken', 'sandbox']; /** * Check if a query key should be excluded from persistence */ function shouldExcludeQuery(queryKey: readonly unknown[]): boolean { if (queryKey.length === 0) return false; const firstKey = String(queryKey[0]); return EXCLUDED_QUERY_KEY_PREFIXES.some((prefix) => firstKey.startsWith(prefix)); } /** * Check whether there is a recent enough React Query cache in IndexedDB * to consider the app "warm" (i.e., safe to skip blocking on the server * health check and show the UI immediately). * * Returns true only if: * 1. The cache exists and is recent (within maxAgeMs) * 2. The cache buster matches the current build hash * * If the buster doesn't match, PersistQueryClientProvider will wipe the * cache on restore — so we must NOT skip the server wait in that case, * otherwise the board renders with empty queries and no data. * * This is a read-only probe — it does not restore the cache (that is * handled by PersistQueryClientProvider automatically). */ export async function hasWarmIDBCache( currentBuster: string, maxAgeMs = PERSIST_MAX_AGE_MS ): Promise { try { const client = await get(IDB_KEY); if (!client) return false; // PersistedClient stores a `timestamp` (ms) when it was last persisted const age = Date.now() - (client.timestamp ?? 0); if (age >= maxAgeMs) return false; // If the buster doesn't match, PersistQueryClientProvider will wipe the cache. // Treat this as a cold start — we need fresh data from the server. if (currentBuster && client.buster !== currentBuster) return false; return true; } catch { return false; } } /** * Create an IndexedDB-based persister for React Query. * * This persister: * - Stores the full query cache in IndexedDB under a single key * - Filters out auth/health queries that shouldn't be persisted * - Handles errors gracefully (cache persistence is best-effort) */ export function createIDBPersister(): Persister { return { persistClient: async (client: PersistedClient) => { try { // Filter out excluded queries before persisting const filteredClient: PersistedClient = { ...client, clientState: { ...client.clientState, queries: client.clientState.queries.filter( (query) => !shouldExcludeQuery(query.queryKey) ), // Don't persist mutations (they should be re-triggered, not replayed) mutations: [], }, }; await set(IDB_KEY, filteredClient); } catch (error) { logger.warn('Failed to persist query cache to IndexedDB:', error); } }, restoreClient: async () => { try { const client = await get(IDB_KEY); if (client) { logger.info('Restored React Query cache from IndexedDB'); } return client ?? undefined; } catch (error) { logger.warn('Failed to restore query cache from IndexedDB:', error); return undefined; } }, removeClient: async () => { try { await del(IDB_KEY); } catch (error) { logger.warn('Failed to remove query cache from IndexedDB:', error); } }, }; }