Traffic reduction and tuned caching strats (#2215)

* Update the feed to only check latest on focus after 30s, but to do a full reset on focus after 1 hour to avoid very stale data

* Remove the isFeedPublic query

* Fix: avoid double next-page fetches

* Reduce some poll intervals to reduce server load

* Guard against double-fires of fetchNextPage

* Reduce polling on blurred screens
zio/stable
Paul Frazee 2023-12-15 15:49:07 -08:00 committed by GitHub
parent dd074371cf
commit 2a712630b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 83 additions and 151 deletions

View File

@ -161,51 +161,6 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) {
}) })
} }
export const isFeedPublicQueryKey = ({uri}: {uri: string}) => [
'isFeedPublic',
uri,
]
export function useIsFeedPublicQuery({uri}: {uri: string}) {
return useQuery({
queryKey: isFeedPublicQueryKey({uri}),
queryFn: async ({queryKey}) => {
const [, uri] = queryKey
try {
const res = await getAgent().app.bsky.feed.getFeed({
feed: uri,
limit: 1,
})
return {
isPublic: Boolean(res.data.feed),
error: undefined,
}
} catch (e: any) {
/**
* This should be an `XRPCError`, but I can't safely import from
* `@atproto/xrpc` due to a depdency on node's `crypto` module.
*
* @see https://github.com/bluesky-social/atproto/blob/c17971a2d8e424cc7f10c071d97c07c08aa319cf/packages/xrpc/src/client.ts#L126
*/
if (e?.status === 401) {
return {
isPublic: false,
error: e,
}
}
/*
* Non-401 response means something else went wrong on the server
*/
return {
isPublic: true,
error: e,
}
}
},
})
}
export const useGetPopularFeedsQueryKey = ['getPopularFeeds'] export const useGetPopularFeedsQueryKey = ['getPopularFeeds']
export function useGetPopularFeedsQuery() { export function useGetPopularFeedsQuery() {

View File

@ -16,7 +16,7 @@
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
*/ */
import {useEffect} from 'react' import {useEffect, useRef} from 'react'
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import { import {
useInfiniteQuery, useInfiniteQuery,
@ -49,6 +49,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const threadMutes = useMutedThreads() const threadMutes = useMutedThreads()
const unreads = useUnreadNotificationsApi() const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false const enabled = opts?.enabled !== false
const lastPageCountRef = useRef(0)
const query = useInfiniteQuery< const query = useInfiniteQuery<
FeedPage, FeedPage,
@ -104,24 +105,26 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
useEffect(() => { useEffect(() => {
const {isFetching, hasNextPage, data} = query const {isFetching, hasNextPage, data} = query
if (isFetching || !hasNextPage) {
let count = 0 return
let numEmpties = 0
for (const page of data?.pages || []) {
if (!page.items.length) {
numEmpties++
} }
// avoid double-fires of fetchNextPage()
if (
lastPageCountRef.current !== 0 &&
lastPageCountRef.current === data?.pages?.length
) {
return
}
// fetch next page if we haven't gotten a full page of content
let count = 0
for (const page of data?.pages || []) {
count += page.items.length count += page.items.length
} }
if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) {
if (
!isFetching &&
hasNextPage &&
count < PAGE_SIZE &&
numEmpties < 3 &&
(data?.pages.length || 0) < 6
) {
query.fetchNextPage() query.fetchNextPage()
lastPageCountRef.current = data?.pages?.length || 0
} }
}, [query]) }, [query])

View File

@ -1,4 +1,4 @@
import React, {useCallback, useEffect} from 'react' import React, {useCallback, useEffect, useRef} from 'react'
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
@ -78,6 +78,7 @@ export interface FeedPageUnselected {
api: FeedAPI api: FeedAPI
cursor: string | undefined cursor: string | undefined
feed: AppBskyFeedDefs.FeedViewPost[] feed: AppBskyFeedDefs.FeedViewPost[]
fetchedAt: number
} }
export interface FeedPage { export interface FeedPage {
@ -85,6 +86,7 @@ export interface FeedPage {
tuner: FeedTuner | NoopFeedTuner tuner: FeedTuner | NoopFeedTuner
cursor: string | undefined cursor: string | undefined
slices: FeedPostSlice[] slices: FeedPostSlice[]
fetchedAt: number
} }
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@ -98,11 +100,12 @@ export function usePostFeedQuery(
const feedTuners = useFeedTuners(feedDesc) const feedTuners = useFeedTuners(feedDesc)
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const enabled = opts?.enabled !== false && Boolean(moderationOpts) const enabled = opts?.enabled !== false && Boolean(moderationOpts)
const lastRun = React.useRef<{ const lastRun = useRef<{
data: InfiniteData<FeedPageUnselected> data: InfiniteData<FeedPageUnselected>
args: typeof selectArgs args: typeof selectArgs
result: InfiniteData<FeedPage> result: InfiniteData<FeedPage>
} | null>(null) } | null>(null)
const lastPageCountRef = useRef(0)
// Make sure this doesn't invalidate unless really needed. // Make sure this doesn't invalidate unless really needed.
const selectArgs = React.useMemo( const selectArgs = React.useMemo(
@ -152,6 +155,7 @@ export function usePostFeedQuery(
api, api,
cursor: res.cursor, cursor: res.cursor,
feed: res.feed, feed: res.feed,
fetchedAt: Date.now(),
} }
}, },
initialPageParam: undefined, initialPageParam: undefined,
@ -214,6 +218,7 @@ export function usePostFeedQuery(
api: page.api, api: page.api,
tuner, tuner,
cursor: page.cursor, cursor: page.cursor,
fetchedAt: page.fetchedAt,
slices: tuner slices: tuner
.tune(page.feed) .tune(page.feed)
.map(slice => { .map(slice => {
@ -279,26 +284,28 @@ export function usePostFeedQuery(
useEffect(() => { useEffect(() => {
const {isFetching, hasNextPage, data} = query const {isFetching, hasNextPage, data} = query
if (isFetching || !hasNextPage) {
let count = 0 return
let numEmpties = 0
for (const page of data?.pages || []) {
if (page.slices.length === 0) {
numEmpties++
} }
// avoid double-fires of fetchNextPage()
if (
lastPageCountRef.current !== 0 &&
lastPageCountRef.current === data?.pages?.length
) {
return
}
// fetch next page if we haven't gotten a full page of content
let count = 0
for (const page of data?.pages || []) {
for (const slice of page.slices) { for (const slice of page.slices) {
count += slice.items.length count += slice.items.length
} }
} }
if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) {
if (
!isFetching &&
hasNextPage &&
count < PAGE_SIZE &&
numEmpties < 3 &&
(data?.pages.length || 0) < 6
) {
query.fetchNextPage() query.fetchNextPage()
lastPageCountRef.current = data?.pages?.length || 0
} }
}, [query]) }, [query])

View File

@ -35,9 +35,7 @@ export function useProfileQuery({did}: {did: string | undefined}) {
// if you remove it, the UI infinite-loops // if you remove it, the UI infinite-loops
// -prf // -prf
staleTime: isCurrentAccount ? STALE.SECONDS.THIRTY : STALE.MINUTES.FIVE, staleTime: isCurrentAccount ? STALE.SECONDS.THIRTY : STALE.MINUTES.FIVE,
refetchInterval: isCurrentAccount refetchInterval: STALE.MINUTES.FIVE,
? STALE.SECONDS.THIRTY
: STALE.MINUTES.FIVE,
queryKey: RQKEY(did || ''), queryKey: RQKEY(did || ''),
queryFn: async () => { queryFn: async () => {
const res = await getAgent().getProfile({actor: did || ''}) const res = await getAgent().getProfile({actor: did || ''})

View File

@ -29,7 +29,7 @@ import {truncateAndInvalidate} from '#/state/queries/util'
import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers' import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
const POLL_FREQ = 30e3 // 30sec const POLL_FREQ = 60e3 // 60sec
export function FeedPage({ export function FeedPage({
testID, testID,

View File

@ -29,12 +29,16 @@ import {
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {listenPostCreated} from '#/state/events' import {listenPostCreated} from '#/state/events'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {STALE} from '#/state/queries'
const LOADING_ITEM = {_reactKey: '__loading__'} const LOADING_ITEM = {_reactKey: '__loading__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
const REFRESH_AFTER = STALE.HOURS.ONE
const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
let Feed = ({ let Feed = ({
feed, feed,
feedParams, feedParams,
@ -77,6 +81,7 @@ let Feed = ({
const {currentAccount} = useSession() const {currentAccount} = useSession()
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = React.useState(false)
const checkForNewRef = React.useRef<(() => void) | null>(null) const checkForNewRef = React.useRef<(() => void) | null>(null)
const lastFetchRef = React.useRef<number>(Date.now())
const opts = React.useMemo( const opts = React.useMemo(
() => ({enabled, ignoreFilterFor}), () => ({enabled, ignoreFilterFor}),
@ -94,6 +99,9 @@ let Feed = ({
fetchNextPage, fetchNextPage,
} = usePostFeedQuery(feed, feedParams, opts) } = usePostFeedQuery(feed, feedParams, opts)
const isEmpty = !isFetching && !data?.pages[0]?.slices.length const isEmpty = !isFetching && !data?.pages[0]?.slices.length
if (data?.pages[0]) {
lastFetchRef.current = data?.pages[0].fetchedAt
}
const checkForNew = React.useCallback(async () => { const checkForNew = React.useCallback(async () => {
if (!data?.pages[0] || isFetching || !onHasNew || !enabled) { if (!data?.pages[0] || isFetching || !onHasNew || !enabled) {
@ -133,11 +141,21 @@ let Feed = ({
checkForNewRef.current = checkForNew checkForNewRef.current = checkForNew
}, [checkForNew]) }, [checkForNew])
React.useEffect(() => { React.useEffect(() => {
if (enabled && checkForNewRef.current) { if (enabled) {
const timeSinceFirstLoad = Date.now() - lastFetchRef.current
if (timeSinceFirstLoad > REFRESH_AFTER) {
// do a full refresh
scrollElRef?.current?.scrollToOffset({offset: 0, animated: false})
queryClient.resetQueries({queryKey: RQKEY(feed)})
} else if (
timeSinceFirstLoad > CHECK_LATEST_AFTER &&
checkForNewRef.current
) {
// check for new on enable (aka on focus) // check for new on enable (aka on focus)
checkForNewRef.current() checkForNewRef.current()
} }
}, [enabled]) }
}, [enabled, feed, queryClient, scrollElRef])
React.useEffect(() => { React.useEffect(() => {
let cleanup1: () => void | undefined, cleanup2: () => void | undefined let cleanup1: () => void | undefined, cleanup2: () => void | undefined
const subscription = AppState.addEventListener('change', nextAppState => { const subscription = AppState.addEventListener('change', nextAppState => {

View File

@ -1,7 +1,7 @@
import React, {useMemo, useCallback} from 'react' import React, {useMemo, useCallback} from 'react'
import {Dimensions, StyleSheet, View, ActivityIndicator} from 'react-native' import {Dimensions, StyleSheet, View, ActivityIndicator} from 'react-native'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useNavigation} from '@react-navigation/native' import {useIsFocused, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {HeartIcon, HeartIconSolid} from 'lib/icons' import {HeartIcon, HeartIconSolid} from 'lib/icons'
@ -42,11 +42,7 @@ import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import { import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed'
useFeedSourceInfoQuery,
FeedSourceFeedInfo,
useIsFeedPublicQuery,
} from '#/state/queries/feed'
import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import { import {
UsePreferencesQueryResponse, UsePreferencesQueryResponse,
@ -132,10 +128,8 @@ export function ProfileFeedScreen(props: Props) {
function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
const {isLoading: isPublicStatusLoading, data: isPublicResponse} =
useIsFeedPublicQuery({uri: feedUri})
if (!preferences || !info || isPublicStatusLoading) { if (!preferences || !info) {
return ( return (
<CenteredView> <CenteredView>
<View style={s.p20}> <View style={s.p20}>
@ -149,7 +143,6 @@ function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
<ProfileFeedScreenInner <ProfileFeedScreenInner
preferences={preferences} preferences={preferences}
feedInfo={info as FeedSourceFeedInfo} feedInfo={info as FeedSourceFeedInfo}
isPublicResponse={isPublicResponse}
/> />
) )
} }
@ -157,11 +150,9 @@ function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
export function ProfileFeedScreenInner({ export function ProfileFeedScreenInner({
preferences, preferences,
feedInfo, feedInfo,
isPublicResponse,
}: { }: {
preferences: UsePreferencesQueryResponse preferences: UsePreferencesQueryResponse
feedInfo: FeedSourceFeedInfo feedInfo: FeedSourceFeedInfo
isPublicResponse: ReturnType<typeof useIsFeedPublicQuery>['data']
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
@ -170,6 +161,7 @@ export function ProfileFeedScreenInner({
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
const {track} = useAnalytics() const {track} = useAnalytics()
const feedSectionRef = React.useRef<SectionRef>(null) const feedSectionRef = React.useRef<SectionRef>(null)
const isScreenFocused = useIsFocused()
const { const {
mutateAsync: saveFeed, mutateAsync: saveFeed,
@ -205,6 +197,9 @@ export function ProfileFeedScreenInner({
useSetTitle(feedInfo?.displayName) useSetTitle(feedInfo?.displayName)
// event handlers
//
const onToggleSaved = React.useCallback(async () => { const onToggleSaved = React.useCallback(async () => {
try { try {
Haptics.default() Haptics.default()
@ -398,21 +393,15 @@ export function ProfileFeedScreenInner({
isHeaderReady={true} isHeaderReady={true}
renderHeader={renderHeader} renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}> onCurrentPageSelected={onCurrentPageSelected}>
{({headerHeight, scrollElRef, isFocused}) => {({headerHeight, scrollElRef, isFocused}) => (
isPublicResponse?.isPublic ? (
<FeedSection <FeedSection
ref={feedSectionRef} ref={feedSectionRef}
feed={`feedgen|${feedInfo.uri}`} feed={`feedgen|${feedInfo.uri}`}
headerHeight={headerHeight} headerHeight={headerHeight}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
isFocused={isFocused} isFocused={isScreenFocused && isFocused}
/> />
) : ( )}
<CenteredView sideBorders style={[{paddingTop: headerHeight}]}>
<NonPublicFeedMessage rawError={isPublicResponse?.error} />
</CenteredView>
)
}
{({headerHeight, scrollElRef}) => ( {({headerHeight, scrollElRef}) => (
<AboutSection <AboutSection
feedOwnerDid={feedInfo.creatorDid} feedOwnerDid={feedInfo.creatorDid}
@ -446,45 +435,6 @@ export function ProfileFeedScreenInner({
) )
} }
function NonPublicFeedMessage({rawError}: {rawError?: Error}) {
const pal = usePalette('default')
return (
<View
style={[
pal.border,
{
padding: 18,
borderTopWidth: 1,
minHeight: Dimensions.get('window').height * 1.5,
},
]}>
<View
style={[
pal.viewLight,
{
padding: 12,
borderRadius: 8,
gap: 12,
},
]}>
<Text style={[pal.text]}>
<Trans>
Looks like this feed is only available to users with a Bluesky
account. Please sign up or sign in to view this feed!
</Trans>
</Text>
{rawError?.message && (
<Text style={pal.textLight}>
<Trans>Message from server</Trans>: {rawError.message}
</Text>
)}
</View>
</View>
)
}
interface FeedSectionProps { interface FeedSectionProps {
feed: FeedDescriptor feed: FeedDescriptor
headerHeight: number headerHeight: number
@ -519,7 +469,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
<Feed <Feed
enabled={isFocused} enabled={isFocused}
feed={feed} feed={feed}
pollInterval={30e3} pollInterval={60e3}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onHasNew={setHasNew} onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown} onScrolledDownChange={setIsScrolledDown}

View File

@ -1,6 +1,6 @@
import React, {useCallback, useMemo} from 'react' import React, {useCallback, useMemo} from 'react'
import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@ -115,6 +115,7 @@ function ProfileListScreenLoaded({
const aboutSectionRef = React.useRef<SectionRef>(null) const aboutSectionRef = React.useRef<SectionRef>(null)
const {openModal} = useModalControls() const {openModal} = useModalControls()
const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
const isScreenFocused = useIsFocused()
useSetTitle(list.name) useSetTitle(list.name)
@ -165,7 +166,7 @@ function ProfileListScreenLoaded({
feed={`list|${uri}`} feed={`list|${uri}`}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
headerHeight={headerHeight} headerHeight={headerHeight}
isFocused={isFocused} isFocused={isScreenFocused && isFocused}
/> />
)} )}
{({headerHeight, scrollElRef}) => ( {({headerHeight, scrollElRef}) => (
@ -623,7 +624,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
testID="listFeed" testID="listFeed"
enabled={isFocused} enabled={isFocused}
feed={feed} feed={feed}
pollInterval={30e3} pollInterval={60e3}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onHasNew={setHasNew} onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown} onScrolledDownChange={setIsScrolledDown}