Use IDB for query storage (#9687)

* 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.

* Refactor storage abstraction to use factory pattern

Change createPersistedQueryStorage to a factory function that accepts a
storage ID, allowing multiple isolated storage instances:
- Native: Each instance gets its own MMKV store
- Web: Each instance gets its own IndexedDB database

Adopt the factory pattern in:
- react-query.tsx: Uses 'persisted_queries' storage
- ageAssurance/data.tsx: Uses 'age_assurance' storage

This provides better separation between different query client caches
and allows each to be managed independently.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-01-26 21:33:51 +02:00
parent c8cdf18bd4
commit 38177bc9a6
7 changed files with 220 additions and 5 deletions
+1
View File
@@ -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",
+2 -2
View File
@@ -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({
@@ -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<string, string>()
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<typeof createPersistedQueryStorage>
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')
})
})
})
+39
View File
@@ -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<string | null>
setItem: (key: string, value: string) => Promise<void>
removeItem: (key: string) => Promise<void>
}
/**
* 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<string | null> => {
return mmkv.getString(key) ?? null
},
setItem: async (key: string, value: string): Promise<void> => {
mmkv.set(key, value)
},
removeItem: async (key: string): Promise<void> => {
mmkv.delete(key)
},
}
}
+82
View File
@@ -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<string | null>
setItem: (key: string, value: string) => Promise<void>
removeItem: (key: string) => Promise<void>
}
interface PersistedQueryDB extends DBSchema {
queries: {
key: string
value: string
}
}
const DB_VERSION = 1
const STORE_NAME = 'queries'
const dbCache = new Map<string, Promise<IDBPDatabase<PersistedQueryDB>>>()
function getDB(dbName: string): Promise<IDBPDatabase<PersistedQueryDB>> {
let dbPromise = dbCache.get(dbName)
if (!dbPromise) {
dbPromise = openDB<PersistedQueryDB>(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<string | null> => {
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<void> => {
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<void> => {
try {
const db = await getDB(dbName)
await db.delete(STORE_NAME, key)
} catch (e) {
console.error('Failed to remove item from IndexedDB:', e)
}
},
}
}
+4 -3
View File
@@ -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,
+5
View File
@@ -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"