Speed up startup by persisting some queries (#9594)

* persist startup queries

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

* Refactor to use archival storage

(cherry picked from commit a773b40e41c96f821cd32260919ce1437c0fc3ab)

* Improve archive db types

(cherry picked from commit 80e4959ba2aa00c984c26aed2f7dfae1095720b0)

* rm idb

* clear on logout, bust on app version

* create abstraction for persisting queries, make gcTime infinite

* Rm abstraction

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2026-02-02 19:55:01 +02:00
committed by GitHub
parent bfd5ed480f
commit 4f1d4821c5
16 changed files with 304 additions and 89 deletions
+24 -19
View File
@@ -8,11 +8,11 @@ import {
moderateFeedGenerator,
RichText,
} from '@atproto/api'
import {t} from '@lingui/macro'
import {
type InfiniteData,
keepPreviousData,
type QueryClient,
type QueryKey,
useInfiniteQuery,
useMutation,
useQuery,
@@ -22,7 +22,11 @@ import {
import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {STALE} from '#/state/queries'
import {
PERSISTED_QUERY_GCTIME,
PERSISTED_QUERY_ROOT,
STALE,
} from '#/state/queries'
import {RQKEY as listQueryKey} from '#/state/queries/list'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session'
@@ -114,7 +118,7 @@ export function hydrateFeedGenerator(
avatar: view.avatar,
displayName: view.displayName
? sanitizeDisplayName(view.displayName)
: `Feed by ${sanitizeHandle(view.creator.handle, '@')}`,
: t`Feed by ${sanitizeHandle(view.creator.handle, '@')}`,
description: new RichText({
text: view.description || '',
facets: (view.descriptionFacets || [])?.slice(),
@@ -155,7 +159,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
creatorHandle: view.creator.handle,
displayName: view.name
? sanitizeDisplayName(view.name)
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`,
: t`User List by ${sanitizeHandle(view.creator.handle, '@')}`,
contentMode: undefined,
}
}
@@ -238,13 +242,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
)
const lastPageCountRef = useRef(0)
const query = useInfiniteQuery<
AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema,
Error,
InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
QueryKey,
string | undefined
>({
const query = useInfiniteQuery({
enabled: Boolean(moderationOpts) && options?.enabled !== false,
queryKey: createGetPopularFeedsQueryKey(options),
queryFn: async ({pageParam}) => {
@@ -261,7 +259,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
return res.data
},
initialPageParam: undefined,
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.cursor,
select: useCallback(
(
@@ -418,7 +416,10 @@ const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = {
contentMode: undefined,
}
const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
const createPinnedFeedInfosQueryKeyRoot = (
kind: 'pinned' | 'saved',
feedUris: string[],
) => [PERSISTED_QUERY_ROOT, 'feed-info', kind, feedUris]
export function usePinnedFeedsInfos() {
const {hasSession} = useSession()
@@ -427,13 +428,13 @@ export function usePinnedFeedsInfos() {
const pinnedItems = preferences?.savedFeeds.filter(feed => feed.pinned) ?? []
return useQuery({
queryKey: createPinnedFeedInfosQueryKeyRoot(
'pinned',
pinnedItems.map(f => f.value),
),
gcTime: PERSISTED_QUERY_GCTIME,
staleTime: STALE.INFINITY,
enabled: !isLoadingPrefs,
queryKey: [
pinnedFeedInfosQueryKeyRoot,
(hasSession ? 'authed:' : 'unauthed:') +
pinnedItems.map(f => f.value).join(','),
],
queryFn: async () => {
if (!hasSession) {
return [PWI_DISCOVER_FEED_STUB]
@@ -535,9 +536,13 @@ export function useSavedFeeds() {
const queryClient = useQueryClient()
return useQuery({
queryKey: createPinnedFeedInfosQueryKeyRoot(
'saved',
savedItems.map(f => f.value),
),
gcTime: PERSISTED_QUERY_GCTIME,
staleTime: STALE.INFINITY,
enabled: !isLoadingPrefs,
queryKey: [pinnedFeedInfosQueryKeyRoot, ...savedItems],
placeholderData: previousData => {
return (
previousData || {
+20
View File
@@ -18,3 +18,23 @@ export const STALE = {
},
INFINITY: Infinity,
}
/**
* Root key for persisted queries.
*
* If the `querykey` of your query uses this at index 0, it will be
* persisted automatically by the `PersistQueryClientProvider` in
* `#/lib/react-query.tsx`.
*
* Be careful when using this, since it will change the query key and may
* break any cases where we call `invalidateQueries` or `refetchQueries`
* with the old key.
*
* Also, only use this for queries that are safe to persist between
* app launches (like user preferences).
*
* Note that for queries that are persisted, it is recommended to extend
* the `gcTime` to a longer duration, otherwise it'll get busted
*/
export const PERSISTED_QUERY_ROOT = 'PERSISTED'
export const PERSISTED_QUERY_GCTIME = Infinity
+10 -6
View File
@@ -3,8 +3,11 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {z} from 'zod'
import {MAX_LABELERS} from '#/lib/constants'
import {labelersDetailedInfoQueryKeyRoot} from '#/lib/react-query'
import {STALE} from '#/state/queries'
import {
PERSISTED_QUERY_GCTIME,
PERSISTED_QUERY_ROOT,
STALE,
} from '#/state/queries'
import {
preferencesQueryKey,
usePreferencesQuery,
@@ -23,8 +26,9 @@ export const labelersInfoQueryKey = (dids: string[]) => [
dids.slice().sort(),
]
export const labelersDetailedInfoQueryKey = (dids: string[]) => [
labelersDetailedInfoQueryKeyRoot,
const persistedLabelersDetailedInfoQueryKey = (dids: string[]) => [
PERSISTED_QUERY_ROOT,
'labelers-detailed-info',
dids,
]
@@ -65,8 +69,8 @@ export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
const agent = useAgent()
return useQuery({
enabled: !!dids.length,
queryKey: labelersDetailedInfoQueryKey(dids),
gcTime: 1000 * 60 * 60 * 6, // 6 hours
queryKey: persistedLabelersDetailedInfoQueryKey(dids),
gcTime: PERSISTED_QUERY_GCTIME,
staleTime: STALE.MINUTES.ONE,
queryFn: async () => {
const res = await agent.app.bsky.labeler.getServices({
+17 -4
View File
@@ -9,7 +9,11 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {replaceEqualDeep} from '#/lib/functions'
import {getAge} from '#/lib/strings/time'
import {STALE} from '#/state/queries'
import {
PERSISTED_QUERY_GCTIME,
PERSISTED_QUERY_ROOT,
STALE,
} from '#/state/queries'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_LOGGED_OUT_PREFERENCES,
@@ -29,18 +33,18 @@ export * from '#/state/queries/preferences/const'
export * from '#/state/queries/preferences/moderation'
export * from '#/state/queries/preferences/types'
const preferencesQueryKeyRoot = 'getPreferences'
export const preferencesQueryKey = [preferencesQueryKeyRoot]
export const preferencesQueryKey = [PERSISTED_QUERY_ROOT, 'getPreferences']
export function usePreferencesQuery() {
const agent = useAgent()
const aa = useAgeAssurance()
return useQuery({
const query = useQuery({
staleTime: STALE.SECONDS.FIFTEEN,
structuralSharing: replaceEqualDeep,
refetchOnWindowFocus: true,
queryKey: preferencesQueryKey,
gcTime: PERSISTED_QUERY_GCTIME,
queryFn: async () => {
if (!agent.did) {
return DEFAULT_LOGGED_OUT_PREFERENCES
@@ -92,6 +96,15 @@ export function usePreferencesQuery() {
[aa],
),
})
if (query.data?.birthDate) {
/**
* The persisted query cache stores dates as strings, but our code expects a `Date`.
*/
query.data.birthDate = new Date(query.data.birthDate)
}
return query
}
export function useClearPreferencesMutation() {
+43 -28
View File
@@ -1,4 +1,13 @@
import React from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import {type AtpSessionEvent, type BskyAgent} from '@atproto/api'
import * as persisted from '#/state/persisted'
@@ -19,6 +28,8 @@ import {type Action, getInitialState, reducer, type State} from './reducer'
export {isSignupQueued} from './util'
import {addSessionDebugLog} from './logging'
export type {SessionAccount} from '#/state/session/types'
import {clearPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {
type SessionApiContext,
type SessionStateContext,
@@ -29,21 +40,21 @@ import {
clearAgeAssuranceDataForDid,
} from '#/ageAssurance/data'
const StateContext = React.createContext<SessionStateContext>({
const StateContext = createContext<SessionStateContext>({
accounts: [],
currentAccount: undefined,
hasSession: false,
})
StateContext.displayName = 'SessionStateContext'
const AgentContext = React.createContext<BskyAgent | null>(null)
const AgentContext = createContext<BskyAgent | null>(null)
AgentContext.displayName = 'SessionAgentContext'
const ApiContext = React.createContext<SessionApiContext>({
const ApiContext = createContext<SessionApiContext>({
createAccount: async () => {},
login: async () => {},
logoutCurrentAccount: async () => {},
logoutEveryAccount: async () => {},
logoutCurrentAccount: () => {},
logoutEveryAccount: () => {},
resumeSession: async () => {},
removeAccount: () => {},
partialRefreshSession: async () => {},
@@ -94,11 +105,11 @@ class SessionStore {
export function Provider({children}: React.PropsWithChildren<{}>) {
const ax = useAnalyticsBase()
const cancelPendingTask = useOneTaskAtATime()
const [store] = React.useState(() => new SessionStore())
const state = React.useSyncExternalStore(store.subscribe, store.getState)
const [store] = useState(() => new SessionStore())
const state = useSyncExternalStore(store.subscribe, store.getState)
const onboardingDispatch = useOnboardingDispatch()
const onAgentSessionChange = React.useCallback(
const onAgentSessionChange = useCallback(
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away.
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
@@ -115,7 +126,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[store],
)
const createAccount = React.useCallback<SessionApiContext['createAccount']>(
const createAccount = useCallback<SessionApiContext['createAccount']>(
async (params, metrics) => {
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
const signal = cancelPendingTask()
@@ -141,7 +152,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[ax, store, onAgentSessionChange, cancelPendingTask],
)
const login = React.useCallback<SessionApiContext['login']>(
const login = useCallback<SessionApiContext['login']>(
async (params, logContext) => {
addSessionDebugLog({type: 'method:start', method: 'login'})
const signal = cancelPendingTask()
@@ -168,7 +179,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[ax, store, onAgentSessionChange, cancelPendingTask],
)
const logoutCurrentAccount = React.useCallback<
const logoutCurrentAccount = useCallback<
SessionApiContext['logoutCurrentAccount']
>(
logContext => {
@@ -192,6 +203,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
addSessionDebugLog({type: 'method:end', method: 'logout'})
if (prevState.currentAgentState.did) {
clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did})
void clearPersistedQueryStorage(prevState.currentAgentState.did)
}
// reset onboarding flow on logout
onboardingDispatch({type: 'skip'})
@@ -199,7 +211,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[ax, store, cancelPendingTask, onboardingDispatch],
)
const logoutEveryAccount = React.useCallback<
const logoutEveryAccount = useCallback<
SessionApiContext['logoutEveryAccount']
>(
logContext => {
@@ -222,13 +234,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
addSessionDebugLog({type: 'method:end', method: 'logout'})
clearAgeAssuranceData()
for (const account of prevState.accounts) {
void clearPersistedQueryStorage(account.did)
}
// reset onboarding flow on logout
onboardingDispatch({type: 'skip'})
},
[store, cancelPendingTask, onboardingDispatch],
[store, cancelPendingTask, onboardingDispatch, ax],
)
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
const resumeSession = useCallback<SessionApiContext['resumeSession']>(
async (storedAccount, isSwitchingAccounts = false) => {
addSessionDebugLog({
type: 'method:start',
@@ -258,7 +273,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
)
const partialRefreshSession = React.useCallback<
const partialRefreshSession = useCallback<
SessionApiContext['partialRefreshSession']
>(async () => {
const agent = state.currentAgentState.agent as BskyAppAgent
@@ -275,7 +290,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})
}, [store, state, cancelPendingTask])
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
const removeAccount = useCallback<SessionApiContext['removeAccount']>(
account => {
addSessionDebugLog({
type: 'method:start',
@@ -292,7 +307,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
[store, cancelPendingTask],
)
React.useEffect(() => {
useEffect(() => {
return persisted.onUpdate('session', nextSession => {
const synced = nextSession
addSessionDebugLog({type: 'persisted:receive', data: synced})
@@ -322,7 +337,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})
}, [store, state, resumeSession])
const stateContext = React.useMemo(
const stateContext = useMemo(
() => ({
accounts: state.accounts,
currentAccount: state.accounts.find(
@@ -333,7 +348,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[state],
)
const api = React.useMemo(
const api = useMemo(
() => ({
createAccount,
login,
@@ -358,8 +373,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (__DEV__ && IS_WEB) window.agent = state.currentAgentState.agent
const agent = state.currentAgentState.agent as BskyAppAgent
const currentAgentRef = React.useRef(agent)
React.useEffect(() => {
const currentAgentRef = useRef(agent)
useEffect(() => {
if (currentAgentRef.current !== agent) {
// Read the previous value and immediately advance the pointer.
const prevAgent = currentAgentRef.current
@@ -390,8 +405,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
function useOneTaskAtATime() {
const abortController = React.useRef<AbortController | null>(null)
const cancelPendingTask = React.useCallback(() => {
const abortController = useRef<AbortController | null>(null)
const cancelPendingTask = useCallback(() => {
if (abortController.current) {
abortController.current.abort()
}
@@ -402,11 +417,11 @@ function useOneTaskAtATime() {
}
export function useSession() {
return React.useContext(StateContext)
return useContext(StateContext)
}
export function useSessionApi() {
return React.useContext(ApiContext)
return useContext(ApiContext)
}
export function useRequireAuth() {
@@ -414,7 +429,7 @@ export function useRequireAuth() {
const closeAll = useCloseAllActiveElements()
const {signinDialogControl} = useGlobalDialogsControlContext()
return React.useCallback(
return useCallback(
(fn: () => void) => {
if (hasSession) {
fn()
@@ -428,7 +443,7 @@ export function useRequireAuth() {
}
export function useAgent(): BskyAgent {
const agent = React.useContext(AgentContext)
const agent = useContext(AgentContext)
if (!agent) {
throw Error('useAgent() must be below <SessionProvider>.')
}