diff --git a/package.json b/package.json index 029c7af884..1cf8024aa1 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "history": "^5.3.0", "hls.js": "^1.6.2", "idb-keyval": "^6.2.2", + "idb": "^8.0.3", "js-sha256": "^0.9.0", "jwt-decode": "^4.0.0", "lande": "^1.0.10", diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index f6353a899b..ad431e6c9b 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -6,7 +6,6 @@ import { AtpAgent, getAgeAssuranceRegionConfig, } from '@atproto/api' -import AsyncStorage from '@react-native-async-storage/async-storage' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' import {persistQueryClient} from '@tanstack/react-query-persist-client' @@ -14,6 +13,7 @@ import debounce from 'lodash.debounce' import {networkRetry} from '#/lib/async/retry' import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {getAge} from '#/lib/strings/time' import { hasSnoozedBirthdateUpdateForDid, @@ -45,7 +45,7 @@ const qc = new QueryClient({ }, }) const persister = createAsyncStoragePersister({ - storage: AsyncStorage, + storage: createPersistedQueryStorage('age_assurance'), key: 'age-assurance-query-client', }) const [, cacheHydrationPromise] = persistQueryClient({ diff --git a/src/lib/__tests__/persisted-query-storage.test.ts b/src/lib/__tests__/persisted-query-storage.test.ts new file mode 100644 index 0000000000..fe6a61884b --- /dev/null +++ b/src/lib/__tests__/persisted-query-storage.test.ts @@ -0,0 +1,87 @@ +import {beforeEach, describe, expect, it, jest} from '@jest/globals' + +jest.mock('@bsky.app/react-native-mmkv', () => ({ + MMKV: class MMKVMock { + _store = new Map() + + getString(key: string) { + return this._store.get(key) + } + + set(key: string, value: string) { + this._store.set(key, value) + } + + delete(key: string) { + this._store.delete(key) + } + + clearAll() { + this._store.clear() + } + }, +})) + +import {createPersistedQueryStorage} from '../persisted-query-storage' + +describe('createPersistedQueryStorage', () => { + it('should create isolated storage instances', async () => { + const storage1 = createPersistedQueryStorage('store1') + const storage2 = createPersistedQueryStorage('store2') + + await storage1.setItem('key', 'value1') + await storage2.setItem('key', 'value2') + + expect(await storage1.getItem('key')).toBe('value1') + expect(await storage2.getItem('key')).toBe('value2') + }) + + describe('storage operations', () => { + let storage: ReturnType + + beforeEach(() => { + storage = createPersistedQueryStorage('test_store') + }) + + it('should return null for non-existent keys', async () => { + const result = await storage.getItem('non-existent-key') + expect(result).toBeNull() + }) + + it('should store and retrieve a value', async () => { + const testValue = JSON.stringify({data: 'test'}) + await storage.setItem('test-key', testValue) + const result = await storage.getItem('test-key') + expect(result).toBe(testValue) + }) + + it('should remove a value', async () => { + const testValue = JSON.stringify({data: 'test'}) + await storage.setItem('test-key', testValue) + await storage.removeItem('test-key') + const result = await storage.getItem('test-key') + expect(result).toBeNull() + }) + + it('should handle complex JSON data', async () => { + const complexData = JSON.stringify({ + queries: [ + {key: 'query1', data: {nested: {value: 123}}}, + {key: 'query2', data: {array: [1, 2, 3]}}, + ], + timestamp: Date.now(), + }) + await storage.setItem('complex-key', complexData) + const result = await storage.getItem('complex-key') + expect(result).toBe(complexData) + expect(JSON.parse(result!)).toEqual(JSON.parse(complexData)) + }) + + it('should overwrite existing values', async () => { + await storage.setItem('test-key', 'value1') + await storage.setItem('test-key', 'value2') + const result = await storage.getItem('test-key') + expect(result).toBe('value2') + }) + }) +}) diff --git a/src/lib/persisted-query-storage.ts b/src/lib/persisted-query-storage.ts new file mode 100644 index 0000000000..916f6d17df --- /dev/null +++ b/src/lib/persisted-query-storage.ts @@ -0,0 +1,39 @@ +/** + * Persisted Query Storage - Native Implementation (MMKV) + * + * This module provides a storage abstraction for persisting react-query cache. + * On native platforms, it uses MMKV for high-performance synchronous storage. + */ +import {MMKV} from '@bsky.app/react-native-mmkv' + +/** + * Interface for async storage compatible with @tanstack/query-async-storage-persister + */ +export interface PersistedQueryStorage { + getItem: (key: string) => Promise + setItem: (key: string, value: string) => Promise + removeItem: (key: string) => Promise +} + +/** + * Creates an MMKV-based storage adapter for persisting react-query cache on native platforms. + * Each storage instance uses a separate MMKV store identified by the provided id. + * MMKV provides synchronous access but we wrap it in Promises for API compatibility. + * + * @param id - Unique identifier for this storage instance (used as MMKV store id) + */ +export function createPersistedQueryStorage(id: string): PersistedQueryStorage { + const mmkv = new MMKV({id}) + + return { + getItem: async (key: string): Promise => { + return mmkv.getString(key) ?? null + }, + setItem: async (key: string, value: string): Promise => { + mmkv.set(key, value) + }, + removeItem: async (key: string): Promise => { + mmkv.delete(key) + }, + } +} diff --git a/src/lib/persisted-query-storage.web.ts b/src/lib/persisted-query-storage.web.ts new file mode 100644 index 0000000000..520d867167 --- /dev/null +++ b/src/lib/persisted-query-storage.web.ts @@ -0,0 +1,82 @@ +/** + * Persisted Query Storage - Web Implementation (IndexedDB) + * + * This module provides a storage abstraction for persisting react-query cache. + * On web platforms, it uses IndexedDB for efficient async storage. + */ +import {type DBSchema, type IDBPDatabase, openDB} from 'idb' + +/** + * Interface for async storage compatible with @tanstack/query-async-storage-persister + */ +export interface PersistedQueryStorage { + getItem: (key: string) => Promise + setItem: (key: string, value: string) => Promise + removeItem: (key: string) => Promise +} + +interface PersistedQueryDB extends DBSchema { + queries: { + key: string + value: string + } +} + +const DB_VERSION = 1 +const STORE_NAME = 'queries' + +const dbCache = new Map>>() + +function getDB(dbName: string): Promise> { + let dbPromise = dbCache.get(dbName) + if (!dbPromise) { + dbPromise = openDB(dbName, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME) + } + }, + }) + dbCache.set(dbName, dbPromise) + } + return dbPromise +} + +/** + * Creates an IndexedDB-based storage adapter for persisting react-query cache on web platforms. + * Each storage instance uses a separate IndexedDB database identified by the provided id. + * + * @param id - Unique identifier for this storage instance (used as database name) + */ +export function createPersistedQueryStorage(id: string): PersistedQueryStorage { + const dbName = `bsky_${id}` + + return { + getItem: async (key: string): Promise => { + try { + const db = await getDB(dbName) + const value = await db.get(STORE_NAME, key) + return value ?? null + } catch (e) { + console.error('Failed to get item from IndexedDB:', e) + return null + } + }, + setItem: async (key: string, value: string): Promise => { + try { + const db = await getDB(dbName) + await db.put(STORE_NAME, value, key) + } catch (e) { + console.error('Failed to set item in IndexedDB:', e) + } + }, + removeItem: async (key: string): Promise => { + try { + const db = await getDB(dbName) + await db.delete(STORE_NAME, key) + } catch (e) { + console.error('Failed to remove item from IndexedDB:', e) + } + }, + } +} diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx index a53ea7bdc9..1f04d81ba0 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -1,6 +1,5 @@ import {useEffect, useRef, useState} from 'react' import {AppState, type AppStateStatus} from 'react-native' -import AsyncStorage from '@react-native-async-storage/async-storage' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query' import { @@ -9,6 +8,7 @@ import { } from '@tanstack/react-query-persist-client' import {SuperJSON} from 'superjson' +import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' import {IS_NATIVE, IS_WEB} from '#/env' @@ -18,7 +18,7 @@ declare global { } } -// any query keys in this array will be persisted to AsyncStorage. +// any query keys in this array will be persisted to storage. // keys are here for require cycle reasons ¯\_(ツ)_/¯ export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' export const preferencesQueryKeyRoot = 'get-preferences' @@ -187,8 +187,9 @@ function QueryProviderInner({ // Do not move the query client creation outside of this component. const [queryClient, _setQueryClient] = useState(() => createQueryClient()) const [persistOptions, _setPersistOptions] = useState(() => { + const storage = createPersistedQueryStorage('persisted_queries') const asyncPersister = createAsyncStoragePersister({ - storage: AsyncStorage, + storage, key: 'queryClient-' + (currentDid ?? 'logged-out') + `-v${PERSIST_VERSION}`, serialize: SuperJSON.stringify, diff --git a/yarn.lock b/yarn.lock index 9351de69fb..a13185636e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12940,6 +12940,11 @@ idb-keyval@^6.2.2: resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.2.2.tgz#b0171b5f73944854a3291a5cdba8e12768c4854a" integrity sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg== +idb@^8.0.3: + version "8.0.3" + resolved "https://registry.yarnpkg.com/idb/-/idb-8.0.3.tgz#c91e558f15a8d53f1d7f53a094d226fc3ad71fd9" + integrity sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg== + ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"