diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 00000000..9fde5a38 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,173 @@ +/** + * Access Layer service worker — background sync for creator profile and + * portfolio endpoints (Issue #754). + * + * Intercepts failed GET requests during offline periods, queues them in + * IndexedDB, then replays them when connectivity is restored via the + * Background Sync API. + */ + +const SYNC_TAG = 'api-retry'; +const DB_NAME = 'accesslayer-query-cache'; +const DB_VERSION = 1; +const QUERIES_STORE = 'queries'; +const SYNC_STORE = 'sync-queue'; +const MAX_QUEUE_AGE_MS = 60 * 60 * 1000; // 1 hour + +const INTERCEPTED_PATHS = ['/api/creators/', '/api/wallet/']; + +// --------------------------------------------------------------------------- +// IndexedDB helpers (duplicated from the main-thread adapter so the SW is +// self-contained and does not import ES modules). +// --------------------------------------------------------------------------- + +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = event => { + const db = event.target.result; + if (!db.objectStoreNames.contains(QUERIES_STORE)) { + db.createObjectStore(QUERIES_STORE, { keyPath: 'queryHash' }); + } + if (!db.objectStoreNames.contains(SYNC_STORE)) { + db.createObjectStore(SYNC_STORE, { keyPath: 'id', autoIncrement: true }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function enqueueRequest(url, method, body) { + try { + const db = await openDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(SYNC_STORE, 'readwrite'); + tx.objectStore(SYNC_STORE).add({ url, method, body: body ?? null, queuedAt: Date.now() }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + // silently ignore — offline queuing is best-effort + } +} + +async function drainQueue() { + try { + const db = await openDB(); + + const items = await new Promise((resolve, reject) => { + const req = db.transaction(SYNC_STORE, 'readonly').objectStore(SYNC_STORE).getAll(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + const now = Date.now(); + + for (const item of items) { + // Discard items older than 1 hour without replaying. + if (now - item.queuedAt > MAX_QUEUE_AGE_MS) { + await deleteQueueItem(db, item.id); + continue; + } + + try { + const init = { method: item.method }; + if (item.body) init.body = item.body; + + const response = await fetch(item.url, init); + if (response.ok) { + const data = await response.json(); + await updateQueryCache(db, item.url, data); + await deleteQueueItem(db, item.id); + } + } catch { + // Network still unavailable for this item — leave it queued. + } + } + } catch { + // silently ignore + } +} + +async function deleteQueueItem(db, id) { + return new Promise(resolve => { + const tx = db.transaction(SYNC_STORE, 'readwrite'); + tx.objectStore(SYNC_STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); +} + +async function updateQueryCache(db, url, data) { + try { + const queryHash = url; + const entry = { + queryKey: [url], + data, + dataUpdatedAt: Date.now(), + queryHash, + }; + await new Promise((resolve, reject) => { + const tx = db.transaction(QUERIES_STORE, 'readwrite'); + tx.objectStore(QUERIES_STORE).put(entry); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + // silently ignore + } +} + +// --------------------------------------------------------------------------- +// Service Worker lifecycle +// --------------------------------------------------------------------------- + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', event => { + event.waitUntil(self.clients.claim()); +}); + +// --------------------------------------------------------------------------- +// Fetch interception — queue failed requests to the tracked endpoints. +// --------------------------------------------------------------------------- + +self.addEventListener('fetch', event => { + const { request } = event; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + const isTracked = INTERCEPTED_PATHS.some(p => url.pathname.startsWith(p)); + if (!isTracked) return; + + event.respondWith( + fetch(request).catch(async err => { + await enqueueRequest(request.url, request.method, null); + + // Register background sync so the queue is drained once online. + if ('sync' in self.registration) { + try { + await self.registration.sync.register(SYNC_TAG); + } catch { + // Background Sync API not available — queue will drain on + // the next successful fetch. + } + } + + throw err; + }) + ); +}); + +// --------------------------------------------------------------------------- +// Background Sync — drain the queue when connectivity is restored. +// --------------------------------------------------------------------------- + +self.addEventListener('sync', event => { + if (event.tag === SYNC_TAG) { + event.waitUntil(drainQueue()); + } +}); diff --git a/src/App.tsx b/src/App.tsx index a294013e..903ce2c9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { Toaster } from 'react-hot-toast'; import { createBrowserRouter, RouterProvider } from 'react-router'; import AppErrorBoundary from './components/common/AppErrorBoundary'; +import OfflineBanner from './components/common/OfflineBanner'; import { routes } from './routes'; import { useRouteChangeLogging } from './hooks/useRouteChangeLogging'; @@ -26,6 +27,7 @@ function App() { return ( + + You are offline — showing cached data + + ); +} diff --git a/src/components/common/StaleBadge.tsx b/src/components/common/StaleBadge.tsx new file mode 100644 index 00000000..619268e7 --- /dev/null +++ b/src/components/common/StaleBadge.tsx @@ -0,0 +1,21 @@ +export const STALE_THRESHOLD_MS = 5 * 60 * 1000; + +interface StaleBadgeProps { + dataUpdatedAt: number; +} + +export default function StaleBadge({ dataUpdatedAt }: StaleBadgeProps) { + const isStale = Date.now() - dataUpdatedAt > STALE_THRESHOLD_MS; + + if (!isStale) return null; + + return ( + + Data may be outdated + + ); +} diff --git a/src/hooks/useOfflineStatus.ts b/src/hooks/useOfflineStatus.ts new file mode 100644 index 00000000..06d67969 --- /dev/null +++ b/src/hooks/useOfflineStatus.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +export function useOfflineStatus(): boolean { + const [isOffline, setIsOffline] = useState(!navigator.onLine); + + useEffect(() => { + const onOnline = () => setIsOffline(false); + const onOffline = () => setIsOffline(true); + + window.addEventListener('online', onOnline); + window.addEventListener('offline', onOffline); + + return () => { + window.removeEventListener('online', onOnline); + window.removeEventListener('offline', onOffline); + }; + }, []); + + return isOffline; +} diff --git a/src/lib/__tests__/conflictResolution.test.ts b/src/lib/__tests__/conflictResolution.test.ts new file mode 100644 index 00000000..eecf8df9 --- /dev/null +++ b/src/lib/__tests__/conflictResolution.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { resolveConflict } from '@/lib/conflictResolution'; +import type { ConflictEntry } from '@/lib/conflictResolution'; + +function entry(data: Record, updatedAt: number): ConflictEntry { + return { data, dataUpdatedAt: updatedAt }; +} + +describe('resolveConflict', () => { + describe('when server data is newer', () => { + it('returns server data when there are no optimistic fields', () => { + const local = entry({ name: 'Alice', score: 10 }, 1000); + const server = entry({ name: 'Alice', score: 20 }, 2000); + + const result = resolveConflict(local, server); + + expect(result).toEqual(server); + }); + + it('preserves optimistic fields from local entry', () => { + const local = entry({ name: 'Alice', positions: [{ id: 1 }] }, 1000); + const server = entry({ name: 'Alice Updated', positions: [] }, 2000); + + const result = resolveConflict(local, server, { + optimisticFields: ['positions'], + }); + + expect(result.data.name).toBe('Alice Updated'); + expect(result.data.positions).toEqual([{ id: 1 }]); + expect(result.dataUpdatedAt).toBe(2000); + }); + + it('preserves multiple optimistic fields', () => { + const local = entry( + { title: 'Old', positions: [1, 2], balance: 99 }, + 500 + ); + const server = entry( + { title: 'New', positions: [], balance: 50 }, + 1500 + ); + + const result = resolveConflict(local, server, { + optimisticFields: ['positions', 'balance'], + }); + + expect(result.data.title).toBe('New'); + expect(result.data.positions).toEqual([1, 2]); + expect(result.data.balance).toBe(99); + }); + + it('ignores an optimistic field that does not exist in local data', () => { + const local = entry({ name: 'Alice' }, 1000); + const server = entry({ name: 'Bob', score: 5 }, 2000); + + const result = resolveConflict(local, server, { + optimisticFields: ['score'], + }); + + expect(result.data.score).toBe(5); + expect(result.data.name).toBe('Bob'); + }); + + it('stamps the result with the server dataUpdatedAt', () => { + const local = entry({ a: 1 }, 1000); + const server = entry({ a: 2 }, 3000); + + const result = resolveConflict(local, server, { + optimisticFields: ['a'], + }); + + expect(result.dataUpdatedAt).toBe(3000); + }); + }); + + describe('when local data is newer or equal', () => { + it('returns the local entry when local is newer', () => { + const local = entry({ name: 'Alice', score: 50 }, 3000); + const server = entry({ name: 'Alice', score: 10 }, 1000); + + const result = resolveConflict(local, server); + + expect(result).toEqual(local); + }); + + it('returns the local entry when timestamps are equal', () => { + const local = entry({ value: 'local' }, 1000); + const server = entry({ value: 'server' }, 1000); + + const result = resolveConflict(local, server); + + expect(result).toEqual(local); + }); + + it('ignores optimistic fields when local is newer', () => { + const local = entry({ positions: [1, 2, 3] }, 5000); + const server = entry({ positions: [] }, 2000); + + const result = resolveConflict(local, server, { + optimisticFields: ['positions'], + }); + + expect(result.data.positions).toEqual([1, 2, 3]); + expect(result.dataUpdatedAt).toBe(5000); + }); + }); + + describe('edge cases', () => { + it('handles empty optimistic fields array the same as no options', () => { + const local = entry({ x: 1 }, 1000); + const server = entry({ x: 2 }, 2000); + + const withEmpty = resolveConflict(local, server, { optimisticFields: [] }); + const withDefault = resolveConflict(local, server); + + expect(withEmpty).toEqual(withDefault); + }); + + it('does not mutate the input entries', () => { + const local = entry({ a: 1, b: 2 }, 1000); + const server = entry({ a: 10, b: 20 }, 2000); + + const localCopy = JSON.parse(JSON.stringify(local)) as ConflictEntry; + const serverCopy = JSON.parse(JSON.stringify(server)) as ConflictEntry; + + resolveConflict(local, server, { optimisticFields: ['a'] }); + + expect(local).toEqual(localCopy); + expect(server).toEqual(serverCopy); + }); + }); +}); diff --git a/src/lib/__tests__/indexedDBCache.test.ts b/src/lib/__tests__/indexedDBCache.test.ts new file mode 100644 index 00000000..20feacd6 --- /dev/null +++ b/src/lib/__tests__/indexedDBCache.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CacheEntry } from '@/lib/indexedDBCache'; +import { IndexedDBCache } from '@/lib/indexedDBCache'; + +// --------------------------------------------------------------------------- +// Minimal in-memory IDBDatabase stub +// --------------------------------------------------------------------------- + +type Row = CacheEntry; + +function makeIDBStub() { + const stores: Record> = { + queries: new Map(), + 'sync-queue': new Map(), + }; + + function makeRequest(resultFn: () => T): IDBRequest { + const req = { result: undefined as T, error: null } as unknown as IDBRequest; + Promise.resolve().then(() => { + (req as unknown as { result: T }).result = resultFn(); + (req as unknown as { onsuccess: ((e: Event) => void) | null }).onsuccess?.({} as Event); + }); + return req; + } + + function makeStore(storeName: string) { + const store = stores[storeName]; + return { + get: (key: string) => makeRequest(() => store.get(key) as unknown as Row), + put: (val: Row) => { + store.set(String(val.queryHash), val); + return makeRequest(() => undefined as unknown as Row); + }, + delete: (key: string) => { + store.delete(key); + return makeRequest(() => undefined as unknown as Row); + }, + clear: () => { + store.clear(); + return makeRequest(() => undefined as unknown as Row); + }, + getAll: () => makeRequest(() => [...store.values()] as unknown as Row), + }; + } + + function makeTx(storeNames: string | string[]) { + const names = Array.isArray(storeNames) ? storeNames : [storeNames]; + const txStores: Record> = {}; + for (const n of names) txStores[n] = makeStore(n); + + const tx = { + objectStore: (n: string) => txStores[n], + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + error: null, + }; + // Fire oncomplete on next microtask + Promise.resolve().then(() => tx.oncomplete?.()); + return tx; + } + + const db = { + transaction: (stores: string | string[]) => makeTx(stores), + objectStoreNames: { contains: (n: string) => n in stores }, + }; + + return { db, stores }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('IndexedDBCache', () => { + let cache: IndexedDBCache; + let stores: ReturnType['stores']; + + function entry(hash: string, updatedAt = Date.now()): CacheEntry { + return { + queryHash: hash, + queryKey: ['test', hash], + data: { value: hash }, + dataUpdatedAt: updatedAt, + }; + } + + beforeEach(() => { + const stub = makeIDBStub(); + stores = stub.stores; + + const mockOpen = vi.fn().mockImplementation(() => { + const req = { + result: stub.db, + error: null, + onupgradeneeded: null as ((e: Event) => void) | null, + onsuccess: null as ((e: Event) => void) | null, + onerror: null as ((e: Event) => void) | null, + }; + Promise.resolve().then(() => + req.onsuccess?.({ target: req } as unknown as Event) + ); + return req; + }); + + vi.stubGlobal('indexedDB', { open: mockOpen }); + + cache = new IndexedDBCache(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('get', () => { + it('returns undefined for a key that does not exist', async () => { + expect(await cache.get('missing')).toBeUndefined(); + }); + + it('returns the entry for a key that was set', async () => { + const e = entry('abc'); + stores.queries.set('abc', e); + const result = await cache.get('abc'); + expect(result?.queryHash).toBe('abc'); + }); + + it('returns undefined and does not throw when IndexedDB errors', async () => { + vi.stubGlobal('indexedDB', { + open: vi.fn().mockImplementation(() => { + const req = { + error: new Error('IDB error'), + onupgradeneeded: null, + onsuccess: null, + onerror: null as ((e: Event) => void) | null, + }; + Promise.resolve().then(() => + req.onerror?.({} as Event) + ); + return req; + }), + }); + const failCache = new IndexedDBCache(); + await expect(failCache.get('key')).resolves.toBeUndefined(); + }); + }); + + describe('set', () => { + it('stores an entry retrievable by get', async () => { + const e = entry('xyz'); + await cache.set('xyz', e); + const stored = stores.queries.get('xyz'); + expect(stored?.queryHash).toBe('xyz'); + }); + + it('does not throw when IndexedDB errors', async () => { + vi.stubGlobal('indexedDB', { + open: vi.fn().mockImplementation(() => { + const req = { + error: new Error('fail'), + onupgradeneeded: null, + onsuccess: null, + onerror: null as ((e: Event) => void) | null, + }; + Promise.resolve().then(() => req.onerror?.({} as Event)); + return req; + }), + }); + const failCache = new IndexedDBCache(); + await expect(failCache.set('k', entry('k'))).resolves.toBeUndefined(); + }); + }); + + describe('delete', () => { + it('removes an existing entry', async () => { + stores.queries.set('del', entry('del')); + await cache.delete('del'); + expect(stores.queries.has('del')).toBe(false); + }); + + it('is a no-op for a key that does not exist', async () => { + await expect(cache.delete('nonexistent')).resolves.toBeUndefined(); + }); + }); + + describe('clear', () => { + it('removes all entries from the store', async () => { + stores.queries.set('a', entry('a')); + stores.queries.set('b', entry('b')); + await cache.clear(); + expect(stores.queries.size).toBe(0); + }); + }); + + describe('getAll', () => { + it('returns an empty array when the store is empty', async () => { + expect(await cache.getAll()).toEqual([]); + }); + + it('returns all stored entries', async () => { + stores.queries.set('p', entry('p')); + stores.queries.set('q', entry('q')); + const all = await cache.getAll(); + expect(all.map(e => e.queryHash).sort()).toEqual(['p', 'q']); + }); + }); + + describe('evictStale', () => { + it('removes entries older than maxAgeMs', async () => { + const old = entry('old', Date.now() - 200); + const fresh = entry('fresh', Date.now()); + stores.queries.set('old', old); + stores.queries.set('fresh', fresh); + + await cache.evictStale(100); + + expect(stores.queries.has('old')).toBe(false); + expect(stores.queries.has('fresh')).toBe(true); + }); + + it('keeps all entries when none are stale', async () => { + stores.queries.set('a', entry('a', Date.now())); + stores.queries.set('b', entry('b', Date.now())); + await cache.evictStale(60_000); + expect(stores.queries.size).toBe(2); + }); + + it('removes all entries when all are stale', async () => { + stores.queries.set('x', entry('x', 0)); + stores.queries.set('y', entry('y', 1)); + await cache.evictStale(100); + expect(stores.queries.size).toBe(0); + }); + }); +}); diff --git a/src/lib/conflictResolution.ts b/src/lib/conflictResolution.ts new file mode 100644 index 00000000..02258dfd --- /dev/null +++ b/src/lib/conflictResolution.ts @@ -0,0 +1,43 @@ +export interface ConflictEntry { + data: Record; + dataUpdatedAt: number; +} + +export interface ConflictResolutionOptions { + optimisticFields?: string[]; +} + +/** + * Last-write-wins merge for IndexedDB vs server data. + * + * If the server response is newer, its data wins for all fields except those + * listed in `optimisticFields`, which are preserved from the local entry (they + * carry user-initiated optimistic updates made while offline). + * + * If the local entry is newer, it is returned unchanged — the server has + * nothing new to offer. + */ +export function resolveConflict( + localEntry: ConflictEntry, + serverEntry: ConflictEntry, + options: ConflictResolutionOptions = {} +): ConflictEntry { + const { optimisticFields = [] } = options; + + if (serverEntry.dataUpdatedAt <= localEntry.dataUpdatedAt) { + return localEntry; + } + + if (optimisticFields.length === 0) { + return serverEntry; + } + + const merged: Record = { ...serverEntry.data }; + for (const field of optimisticFields) { + if (Object.prototype.hasOwnProperty.call(localEntry.data, field)) { + merged[field] = localEntry.data[field]; + } + } + + return { data: merged, dataUpdatedAt: serverEntry.dataUpdatedAt }; +} diff --git a/src/lib/indexedDBCache.ts b/src/lib/indexedDBCache.ts new file mode 100644 index 00000000..821ec801 --- /dev/null +++ b/src/lib/indexedDBCache.ts @@ -0,0 +1,141 @@ +export interface CacheEntry { + queryKey: readonly unknown[]; + data: unknown; + dataUpdatedAt: number; + queryHash: string; +} + +export interface SyncQueueItem { + id?: number; + url: string; + method: string; + body: string | null; + queuedAt: number; +} + +const DB_NAME = 'accesslayer-query-cache'; +const DB_VERSION = 1; +export const QUERIES_STORE = 'queries'; +export const SYNC_STORE = 'sync-queue'; +export const MAX_AGE_MS = 24 * 60 * 60 * 1000; + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + + req.onupgradeneeded = event => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(QUERIES_STORE)) { + db.createObjectStore(QUERIES_STORE, { keyPath: 'queryHash' }); + } + if (!db.objectStoreNames.contains(SYNC_STORE)) { + db.createObjectStore(SYNC_STORE, { + keyPath: 'id', + autoIncrement: true, + }); + } + }; + + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export class IndexedDBCache { + private dbPromise: Promise | null = null; + + private getDB(): Promise { + if (!this.dbPromise) this.dbPromise = openDB(); + return this.dbPromise; + } + + async get(queryHash: string): Promise { + try { + const db = await this.getDB(); + return new Promise(resolve => { + const req = db + .transaction(QUERIES_STORE, 'readonly') + .objectStore(QUERIES_STORE) + .get(queryHash); + req.onsuccess = () => resolve(req.result as CacheEntry | undefined); + req.onerror = () => resolve(undefined); + }); + } catch { + return undefined; + } + } + + async set(queryHash: string, entry: CacheEntry): Promise { + try { + const db = await this.getDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(QUERIES_STORE, 'readwrite'); + tx.objectStore(QUERIES_STORE).put({ ...entry, queryHash }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + // fall back silently to in-memory cache + } + } + + async delete(queryHash: string): Promise { + try { + const db = await this.getDB(); + await new Promise(resolve => { + const tx = db.transaction(QUERIES_STORE, 'readwrite'); + tx.objectStore(QUERIES_STORE).delete(queryHash); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } catch { + // silently ignore + } + } + + async clear(): Promise { + try { + const db = await this.getDB(); + await new Promise(resolve => { + const tx = db.transaction(QUERIES_STORE, 'readwrite'); + tx.objectStore(QUERIES_STORE).clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } catch { + // silently ignore + } + } + + async getAll(): Promise { + try { + const db = await this.getDB(); + return new Promise(resolve => { + const req = db + .transaction(QUERIES_STORE, 'readonly') + .objectStore(QUERIES_STORE) + .getAll(); + req.onsuccess = () => resolve(req.result as CacheEntry[]); + req.onerror = () => resolve([]); + }); + } catch { + return []; + } + } + + async evictStale(maxAgeMs = MAX_AGE_MS): Promise { + try { + const entries = await this.getAll(); + const cutoff = Date.now() - maxAgeMs; + await Promise.all( + entries + .filter(e => e.dataUpdatedAt < cutoff) + .map(e => this.delete(e.queryHash)) + ); + } catch { + // silently ignore + } + } +} + +export const indexedDBCache = new IndexedDBCache(); diff --git a/src/lib/queryPersistence.ts b/src/lib/queryPersistence.ts new file mode 100644 index 00000000..b1e3aea6 --- /dev/null +++ b/src/lib/queryPersistence.ts @@ -0,0 +1,45 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { indexedDBCache, MAX_AGE_MS } from './indexedDBCache'; + +/** + * Restores persisted queries younger than 24 hours from IndexedDB into + * the React Query cache so the UI renders immediately with stale data + * before the first network fetch completes. + * + * Stale entries (older than MAX_AGE_MS) are evicted first so they are + * never surfaced to the UI. + */ +export async function restorePersistedQueries( + queryClient: QueryClient +): Promise { + await indexedDBCache.evictStale(MAX_AGE_MS); + const entries = await indexedDBCache.getAll(); + for (const entry of entries) { + queryClient.setQueryData( + entry.queryKey as readonly unknown[], + entry.data, + { updatedAt: entry.dataUpdatedAt } + ); + } +} + +/** + * Subscribes to React Query cache events and persists every query whose + * data has been set or updated to IndexedDB. + * + * Returns an unsubscribe function — call it on app teardown. + */ +export function subscribeToQueryCache(queryClient: QueryClient): () => void { + return queryClient.getQueryCache().subscribe(event => { + if (!event) return; + const { query } = event; + if (query.state.data !== undefined) { + void indexedDBCache.set(query.queryHash, { + queryKey: query.queryKey, + data: query.state.data, + dataUpdatedAt: query.state.dataUpdatedAt, + queryHash: query.queryHash, + }); + } + }); +} diff --git a/src/main.tsx b/src/main.tsx index 69a34532..42193255 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,9 +3,30 @@ import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; import { registerUnhandledRejectionLogger } from './utils/unhandledRejectionLogger'; +import { queryClient } from './providers/web3Utils'; +import { + restorePersistedQueries, + subscribeToQueryCache, +} from './lib/queryPersistence'; registerUnhandledRejectionLogger(); +// Restore IndexedDB-persisted queries before the first render so the UI +// has stale data available immediately (#754). +void restorePersistedQueries(queryClient); + +// Persist every subsequent cache update to IndexedDB. +subscribeToQueryCache(queryClient); + +// Register the background-sync service worker. +if ('serviceWorker' in navigator) { + navigator.serviceWorker + .register('/service-worker.js') + .catch(() => { + // SW registration is best-effort; a failure must not block the app. + }); +} + createRoot(document.getElementById('root')!).render(