diff --git a/CLAUDE.md b/CLAUDE.md index d7be980124..a15b5bad88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -542,6 +542,19 @@ export function useDraftsQuery() { To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []` +**Persisted Queries** + +To persist query data across app restarts, `createQueryKey` supports a third +parameter called `options`, which has a `persistedVersion` property. When this +property is set to a number, the query will be persisted. + +When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid. + +```tsx +export const createProfileQueryKey = (args: {did: string}) => + createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1}) +``` + ### Preferences (React Context) ```tsx diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx index ec657b6c41..7ee1e0a11d 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -10,7 +10,7 @@ import { import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' -import {PERSISTED_QUERY_ROOT} from '#/state/queries' +import {isQueryPersisted} from '#/state/queries/util' import * as env from '#/env' import {IS_NATIVE, IS_WEB} from '#/env' @@ -137,8 +137,7 @@ const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehyd { shouldDehydrateMutation: (_: any) => false, shouldDehydrateQuery: query => { - const root = String(query.queryKey[0]) - return root === PERSISTED_QUERY_ROOT + return isQueryPersisted(query.queryKey) }, } diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index c2b173b5ce..b85d917ad3 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -22,13 +22,10 @@ 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 { - PERSISTED_QUERY_GCTIME, - PERSISTED_QUERY_ROOT, - STALE, -} from '#/state/queries' +import {GCTIME, STALE} from '#/state/queries' import {RQKEY as listQueryKey} from '#/state/queries/list' import {usePreferencesQuery} from '#/state/queries/preferences' +import {createQueryKey} from '#/state/queries/util' import {useAgent, useSession} from '#/state/session' import {router} from '#/routes' import {useModerationOpts} from '../preferences/moderation-opts' @@ -416,10 +413,20 @@ const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = { contentMode: undefined, } -const createPinnedFeedInfosQueryKeyRoot = ( +const createPinnedFeedInfosQueryKey = ( kind: 'pinned' | 'saved', feedUris: string[], -) => [PERSISTED_QUERY_ROOT, 'feed-info', kind, feedUris] +) => + createQueryKey( + 'feed-info', + { + kind, + feedUris, + }, + { + persistedVersion: 1, + }, + ) export function usePinnedFeedsInfos() { const {hasSession} = useSession() @@ -428,11 +435,11 @@ export function usePinnedFeedsInfos() { const pinnedItems = preferences?.savedFeeds.filter(feed => feed.pinned) ?? [] return useQuery({ - queryKey: createPinnedFeedInfosQueryKeyRoot( + queryKey: createPinnedFeedInfosQueryKey( 'pinned', pinnedItems.map(f => f.value), ), - gcTime: PERSISTED_QUERY_GCTIME, + gcTime: GCTIME.INFINITY, staleTime: STALE.INFINITY, enabled: !isLoadingPrefs, queryFn: async () => { @@ -536,11 +543,11 @@ export function useSavedFeeds() { const queryClient = useQueryClient() return useQuery({ - queryKey: createPinnedFeedInfosQueryKeyRoot( + queryKey: createPinnedFeedInfosQueryKey( 'saved', savedItems.map(f => f.value), ), - gcTime: PERSISTED_QUERY_GCTIME, + gcTime: GCTIME.INFINITY, staleTime: STALE.INFINITY, enabled: !isLoadingPrefs, placeholderData: previousData => { diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts index bac6931fd5..183d8c883a 100644 --- a/src/state/queries/index.ts +++ b/src/state/queries/index.ts @@ -19,22 +19,6 @@ 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 +export const GCTIME = { + INFINITY: Infinity, +} diff --git a/src/state/queries/labeler.ts b/src/state/queries/labeler.ts index 9bf4372739..e7251866fe 100644 --- a/src/state/queries/labeler.ts +++ b/src/state/queries/labeler.ts @@ -3,15 +3,12 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {z} from 'zod' import {MAX_LABELERS} from '#/lib/constants' -import { - PERSISTED_QUERY_GCTIME, - PERSISTED_QUERY_ROOT, - STALE, -} from '#/state/queries' +import {GCTIME, STALE} from '#/state/queries' import { preferencesQueryKey, usePreferencesQuery, } from '#/state/queries/preferences' +import {createQueryKey} from '#/state/queries/util' import {useAgent} from '#/state/session' const labelerInfoQueryKeyRoot = 'labeler-info' @@ -26,11 +23,8 @@ export const labelersInfoQueryKey = (dids: string[]) => [ dids.slice().sort(), ] -const persistedLabelersDetailedInfoQueryKey = (dids: string[]) => [ - PERSISTED_QUERY_ROOT, - 'labelers-detailed-info', - dids, -] +const createLabelersDetailedInfoQueryKey = (dids: string[]) => + createQueryKey('labelers-detailed-info', {dids}, {persistedVersion: 1}) export function useLabelerInfoQuery({ did, @@ -69,8 +63,8 @@ export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) { const agent = useAgent() return useQuery({ enabled: !!dids.length, - queryKey: persistedLabelersDetailedInfoQueryKey(dids), - gcTime: PERSISTED_QUERY_GCTIME, + queryKey: createLabelersDetailedInfoQueryKey(dids), + gcTime: GCTIME.INFINITY, staleTime: STALE.MINUTES.ONE, queryFn: async () => { const res = await agent.app.bsky.labeler.getServices({ diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index e78a5d73f1..b57e9ffe85 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -9,11 +9,7 @@ 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 { - PERSISTED_QUERY_GCTIME, - PERSISTED_QUERY_ROOT, - STALE, -} from '#/state/queries' +import {GCTIME, STALE} from '#/state/queries' import { DEFAULT_HOME_FEED_PREFS, DEFAULT_LOGGED_OUT_PREFERENCES, @@ -23,6 +19,7 @@ import { type ThreadViewPreferences, type UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' +import {createQueryKey} from '#/state/queries/util' import {useAgent} from '#/state/session' import {saveLabelers} from '#/state/session/agent-config' import {useAgeAssurance} from '#/ageAssurance' @@ -33,7 +30,11 @@ export * from '#/state/queries/preferences/const' export * from '#/state/queries/preferences/moderation' export * from '#/state/queries/preferences/types' -export const preferencesQueryKey = [PERSISTED_QUERY_ROOT, 'getPreferences'] +export const preferencesQueryKey = createQueryKey( + 'getPreferences', + {}, + {persistedVersion: 1}, +) export function usePreferencesQuery() { const agent = useAgent() @@ -44,7 +45,7 @@ export function usePreferencesQuery() { structuralSharing: replaceEqualDeep, refetchOnWindowFocus: true, queryKey: preferencesQueryKey, - gcTime: PERSISTED_QUERY_GCTIME, + gcTime: GCTIME.INFINITY, queryFn: async () => { if (!agent.did) { return DEFAULT_LOGGED_OUT_PREFERENCES diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts index 839ccfb7b9..7ea54745c0 100644 --- a/src/state/queries/util.ts +++ b/src/state/queries/util.ts @@ -14,14 +14,17 @@ import { import * as bsky from '#/types/bsky' +export type StructuredQueryKey> = readonly [ + string, + T, + { + persistedVersion?: number + }, +] + /** * Helper method to ensure consistent query keys and key ordering */ -export function createQueryKey(root: string): readonly [string] -export function createQueryKey>( - root: string, - args: T, -): readonly [string, T] export function createQueryKey>( /** * The query key root. All queries must have a root. @@ -30,12 +33,40 @@ export function createQueryKey>( /** * Any arguments the query depends on, and if changed, should result in the query being refetched. */ - args?: T, -) { - if (args === undefined) { - return [root] as const - } - return [root, args] as const + args: T, + options: { + /** + * If provided, this indicates that the query is persisted and the version + * of the persisted query format. + * + * This is used to ensure that when we make breaking changes to the + * persisted query format, we can increment the version and avoid trying to + * read old persisted queries with the new format. + * + * If you're persisting your queries, you probably want to set `gcTime: + * GCTIME.INFINITY` for this query, otherwise it'll get busted immediately + * after being persisted. + */ + persistedVersion?: number + } = {}, +): StructuredQueryKey { + return [root, args, options] as const +} + +export function isQueryPersisted( + queryKey: QueryKey, +): queryKey is StructuredQueryKey> { + return ( + Array.isArray(queryKey) && + queryKey.length === 3 && + typeof queryKey[0] === 'string' && + typeof queryKey[1] === 'object' && + queryKey[1] !== null && + typeof queryKey[2] === 'object' && + queryKey[2] !== null && + 'persistedVersion' in queryKey[2] && + typeof queryKey[2].persistedVersion === 'number' + ) } export async function truncateAndInvalidate(