From 8bb99bebc523942eec76087f8de7460203e2f257 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 18:30:26 +0000 Subject: [PATCH] Add storage abstraction for persisted query data Introduce a platform-specific storage abstraction layer for react-query persistence: - Native: Uses MMKV for high-performance synchronous storage - Web: Uses IndexedDB via the `idb` library for efficient async storage This replaces the previous AsyncStorage implementation with more performant platform-native solutions. The abstraction maintains API compatibility with @tanstack/query-async-storage-persister. --- package.json | 1 + .../__tests__/persisted-query-storage.test.ts | 73 ++++++++++++++++++ src/lib/persisted-query-storage.ts | 34 +++++++++ src/lib/persisted-query-storage.web.ts | 74 +++++++++++++++++++ src/lib/react-query.tsx | 6 +- yarn.lock | 5 ++ 6 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/persisted-query-storage.test.ts create mode 100644 src/lib/persisted-query-storage.ts create mode 100644 src/lib/persisted-query-storage.web.ts diff --git a/package.json b/package.json index 19f6c0baa0..7f65b6190c 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,7 @@ "fast-text-encoding": "^1.0.6", "history": "^5.3.0", "hls.js": "^1.6.2", + "idb": "^8.0.3", "js-sha256": "^0.9.0", "jwt-decode": "^4.0.0", "lande": "^1.0.10", 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..1943af30e3 --- /dev/null +++ b/src/lib/__tests__/persisted-query-storage.test.ts @@ -0,0 +1,73 @@ +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 {persistedQueryStorage} from '../persisted-query-storage' + +describe('persistedQueryStorage', () => { + beforeEach(async () => { + // Clear storage between tests + await persistedQueryStorage.removeItem('test-key') + }) + + it('should return null for non-existent keys', async () => { + const result = await persistedQueryStorage.getItem('non-existent-key') + expect(result).toBeNull() + }) + + it('should store and retrieve a value', async () => { + const testValue = JSON.stringify({data: 'test'}) + await persistedQueryStorage.setItem('test-key', testValue) + const result = await persistedQueryStorage.getItem('test-key') + expect(result).toBe(testValue) + }) + + it('should remove a value', async () => { + const testValue = JSON.stringify({data: 'test'}) + await persistedQueryStorage.setItem('test-key', testValue) + await persistedQueryStorage.removeItem('test-key') + const result = await persistedQueryStorage.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 persistedQueryStorage.setItem('complex-key', complexData) + const result = await persistedQueryStorage.getItem('complex-key') + expect(result).toBe(complexData) + expect(JSON.parse(result!)).toEqual(JSON.parse(complexData)) + }) + + it('should overwrite existing values', async () => { + await persistedQueryStorage.setItem('test-key', 'value1') + await persistedQueryStorage.setItem('test-key', 'value2') + const result = await persistedQueryStorage.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..eec81a60c3 --- /dev/null +++ b/src/lib/persisted-query-storage.ts @@ -0,0 +1,34 @@ +/** + * 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 +} + +const mmkv = new MMKV({id: 'bsky_persisted_queries'}) + +/** + * MMKV-based storage adapter for persisting react-query cache on native platforms. + * MMKV provides synchronous access but we wrap it in Promises for API compatibility. + */ +export const persistedQueryStorage: PersistedQueryStorage = { + 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..90b093bd0d --- /dev/null +++ b/src/lib/persisted-query-storage.web.ts @@ -0,0 +1,74 @@ +/** + * 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_NAME = 'bsky_persisted_queries' +const DB_VERSION = 1 +const STORE_NAME = 'queries' + +let dbPromise: Promise> | null = null + +function getDB(): Promise> { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME) + } + }, + }) + } + return dbPromise +} + +/** + * IndexedDB-based storage adapter for persisting react-query cache on web platforms. + */ +export const persistedQueryStorage: PersistedQueryStorage = { + getItem: async (key: string): Promise => { + try { + const db = await getDB() + 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() + 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() + 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 e1cf1eecdb..43a61ef8c5 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -1,6 +1,5 @@ import {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,10 +8,11 @@ import { } from '@tanstack/react-query-persist-client' import {SuperJSON} from 'superjson' +import {persistedQueryStorage} from '#/lib/persisted-query-storage' import {isNative} from '#/platform/detection' import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' -// 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' @@ -182,7 +182,7 @@ function QueryProviderInner({ const [queryClient, _setQueryClient] = useState(() => createQueryClient()) const [persistOptions, _setPersistOptions] = useState(() => { const asyncPersister = createAsyncStoragePersister({ - storage: AsyncStorage, + storage: persistedQueryStorage, key: 'queryClient-' + (currentDid ?? 'logged-out') + `-v${PERSIST_VERSION}`, serialize: SuperJSON.stringify, diff --git a/yarn.lock b/yarn.lock index 1ccf9fe75f..f6962970b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12605,6 +12605,11 @@ icss-utils@^5.0.0, icss-utils@^5.1.0: resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== +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"