diff --git a/src/App.native.tsx b/src/App.native.tsx index d6e726a592..a6800eece6 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -12,7 +12,7 @@ import { import * as SplashScreen from 'expo-splash-screen' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client' +import {useQueryClient} from '@tanstack/react-query' import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {init as initPersistedState} from '#/state/persisted' @@ -20,11 +20,7 @@ import * as persisted from '#/state/persisted' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {useIntentHandler} from 'lib/hooks/useIntentHandler' import * as notifications from 'lib/notifications/notifications' -import { - asyncStoragePersister, - dehydrateOptions, - queryClient, -} from 'lib/react-query' +import {GlobalQueryClientProvider} from 'lib/react-query' import {s} from 'lib/styles' import {ThemeProvider} from 'lib/ThemeContext' import {Provider as DialogStateProvider} from 'state/dialogs' @@ -55,22 +51,30 @@ import {listenSessionDropped} from './state/events' SplashScreen.preventAutoHideAsync() function InnerApp() { + const queryClient = useQueryClient() const {isInitialLoad, currentAccount} = useSession() const {resumeSession} = useSessionApi() const theme = useColorModeTheme() const {_} = useLingui() useIntentHandler() - // init useEffect(() => { notifications.init(queryClient) - listenSessionDropped(() => { + }, [queryClient]) + + useEffect(() => { + return listenSessionDropped(() => { Toast.show(_(msg`Sorry! Your session expired. Please log in again.`)) }) + }, [_]) + // init + useEffect(() => { const account = persisted.get('session').currentAccount resumeSession(account) - }, [resumeSession, _]) + // TODO test + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) return ( @@ -121,9 +125,7 @@ function App() { * that is set up in the InnerApp component above. */ return ( - + @@ -145,7 +147,7 @@ function App() { - + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index f47f763da1..ac3e4ecc98 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -1,44 +1,38 @@ import 'lib/sentry' // must be near top - -import React, {useState, useEffect} from 'react' -import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client' -import {SafeAreaProvider} from 'react-native-safe-area-context' -import {RootSiblingParent} from 'react-native-root-siblings' - import 'view/icons' -import {ThemeProvider as Alf} from '#/alf' -import {useColorModeTheme} from '#/alf/util/useColorModeTheme' +import React, {useEffect, useState} from 'react' +import {RootSiblingParent} from 'react-native-root-siblings' +import {SafeAreaProvider} from 'react-native-safe-area-context' + +import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {init as initPersistedState} from '#/state/persisted' -import {Shell} from 'view/shell/index' -import {ToastContainer} from 'view/com/util/Toast.web' -import {ThemeProvider} from 'lib/ThemeContext' -import { - queryClient, - asyncStoragePersister, - dehydrateOptions, -} from 'lib/react-query' -import {Provider as ShellStateProvider} from 'state/shell' -import {Provider as ModalStateProvider} from 'state/modals' -import {Provider as DialogStateProvider} from 'state/dialogs' -import {Provider as LightboxStateProvider} from 'state/lightbox' -import {Provider as MutedThreadsProvider} from 'state/muted-threads' -import {Provider as InvitesStateProvider} from 'state/invites' -import {Provider as PrefsStateProvider} from 'state/preferences' -import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' -import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' +import * as persisted from '#/state/persisted' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' -import I18nProvider from './locale/i18nProvider' +import {useIntentHandler} from 'lib/hooks/useIntentHandler' +import {GlobalQueryClientProvider} from 'lib/react-query' +import {ThemeProvider} from 'lib/ThemeContext' +import {Provider as DialogStateProvider} from 'state/dialogs' +import {Provider as InvitesStateProvider} from 'state/invites' +import {Provider as LightboxStateProvider} from 'state/lightbox' +import {Provider as ModalStateProvider} from 'state/modals' +import {Provider as MutedThreadsProvider} from 'state/muted-threads' +import {Provider as PrefsStateProvider} from 'state/preferences' +import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import { Provider as SessionProvider, useSession, useSessionApi, } from 'state/session' -import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' -import * as persisted from '#/state/persisted' +import {Provider as ShellStateProvider} from 'state/shell' +import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' +import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' +import {ToastContainer} from 'view/com/util/Toast.web' +import {Shell} from 'view/shell/index' +import {ThemeProvider as Alf} from '#/alf' +import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {Provider as PortalProvider} from '#/components/Portal' -import {Provider as StatsigProvider} from '#/lib/statsig/statsig' -import {useIntentHandler} from 'lib/hooks/useIntentHandler' +import I18nProvider from './locale/i18nProvider' function InnerApp() { const {isInitialLoad, currentAccount} = useSession() @@ -50,7 +44,9 @@ function InnerApp() { useEffect(() => { const account = persisted.get('session').currentAccount resumeSession(account) - }, [resumeSession]) + // only run this effect once on first load TODO test + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // wait for session to resume if (isInitialLoad) return null @@ -100,9 +96,7 @@ function App() { * that is set up in the InnerApp component above. */ return ( - + @@ -124,7 +118,7 @@ function App() { - + ) } diff --git a/src/lib/react-query.ts b/src/lib/react-query.tsx similarity index 53% rename from src/lib/react-query.ts rename to src/lib/react-query.tsx index d6cd3c54b2..b77b9defdd 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.tsx @@ -1,9 +1,14 @@ +import React from 'react' import {AppState, AppStateStatus} from 'react-native' -import {QueryClient, focusManager} from '@tanstack/react-query' -import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import AsyncStorage from '@react-native-async-storage/async-storage' -import {PersistQueryClientProviderProps} from '@tanstack/react-query-persist-client' +import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' +import {focusManager, QueryClient} from '@tanstack/react-query' +import { + PersistQueryClientProvider, + PersistQueryClientProviderProps, +} from '@tanstack/react-query-persist-client' +import {logger} from '#/logger' import {isNative} from '#/platform/detection' // any query keys in this array will be persisted to AsyncStorage @@ -35,6 +40,19 @@ focusManager.setEventListener(onFocus => { } }) +export const asyncStoragePersister = createAsyncStoragePersister({ + storage: AsyncStorage, + key: 'queryCache', +}) + +export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] = + { + shouldDehydrateMutation: (_: any) => false, + shouldDehydrateQuery: query => { + return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0])) + }, + } + export const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -56,15 +74,73 @@ export const queryClient = new QueryClient({ }, }) -export const asyncStoragePersister = createAsyncStoragePersister({ - storage: AsyncStorage, - key: 'queryCache', +function createGlobalQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + // NOTE + // refetchOnWindowFocus breaks some UIs (like feeds) + // so we only selectively want to enable this + // -prf + refetchOnWindowFocus: false, + // Structural sharing between responses makes it impossible to rely on + // "first seen" timestamps on objects to determine if they're fresh. + // Disable this optimization so that we can rely on "first seen" timestamps. + structuralSharing: false, + // We don't want to retry queries by default, because in most cases we + // want to fail early and show a response to the user. There are + // exceptions, and those can be made on a per-query basis. For others, we + // should give users controls to retry. + retry: false, + }, + }, + }) +} + +let __globalQueryClient = createGlobalQueryClient() + +export function getGlobalQueryClient() { + return __globalQueryClient +} + +type Context = { + resetGlobalQueryClient(): void +} + +const Context = React.createContext({ + resetGlobalQueryClient() {}, }) -export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] = - { - shouldDehydrateMutation: (_: any) => false, - shouldDehydrateQuery: query => { - return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0])) - }, - } +export function useResetGlobalQueryClient() { + return React.useContext(Context).resetGlobalQueryClient +} + +export function GlobalQueryClientProvider({ + children, +}: { + children: React.ReactNode +}) { + const [queryClient, setQueryClient] = React.useState(() => + createGlobalQueryClient(), + ) + const context = React.useMemo(() => { + return { + resetGlobalQueryClient() { + logger.debug(`react query: setting new global query client`) + const client = createGlobalQueryClient() + __globalQueryClient = client + setQueryClient(client) + }, + } + }, []) + + return ( + + + {children} + + + ) +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 644cadcdde..6ca9bd414b 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -10,6 +10,7 @@ import {jwtDecode} from 'jwt-decode' import {track} from '#/lib/analytics/analytics' import {networkRetry} from '#/lib/async/retry' import {IS_TEST_USER} from '#/lib/constants' +import {useResetGlobalQueryClient} from '#/lib/react-query' import {logEvent, LogEvents} from '#/lib/statsig/statsig' import {hasProp} from '#/lib/type-guards' import {logger} from '#/logger' @@ -179,6 +180,7 @@ function createPersistSessionHandler( export function Provider({children}: React.PropsWithChildren<{}>) { const queryClient = useQueryClient() + const resetGlobalQueryClient = useResetGlobalQueryClient() const isDirty = React.useRef(false) const [state, setState] = React.useState({ isInitialLoad: true, @@ -373,6 +375,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { persistSession: createPersistSessionHandler( account, ({expired, refreshedAccount}) => { + console.log('RESUME: PERSIST') upsertAccount(refreshedAccount, expired) }, {networkErrorCallback: clearCurrentAccount}, @@ -414,8 +417,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { * state so that the app can rerender with new data. */ agent.session = prevSession + resetGlobalQueryClient() __globalAgent = agent - queryClient.clear() upsertAccount(account) if (prevSession.deactivated) { @@ -444,6 +447,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { try { // this calls `upsertAccount` via `agent.resumeSession` and persistor await resumeSessionWithFreshAccount() + console.log('RESUME: DONE') + resetGlobalQueryClient() // only update global agen if resumeSessionWithFreshAccount succeeded __globalAgent = agent } catch (e) { @@ -456,7 +461,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) __globalAgent = PUBLIC_BSKY_AGENT - } finally { queryClient.clear() } } @@ -487,7 +491,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } } }, - [upsertAccount, queryClient, clearCurrentAccount], + [upsertAccount, queryClient, clearCurrentAccount, resetGlobalQueryClient], ) const resumeSession = React.useCallback(