From 46d2ab7e4b8c8677441d3dba89b690a2c82e27ef Mon Sep 17 00:00:00 2001 From: Abbey Campbell Date: Wed, 4 Feb 2026 18:56:26 -0800 Subject: [PATCH 1/3] show offline/retry state for notifications when request takes too long --- .../Notifications/NotificationsContainer.tsx | 2 + .../Notifications/NotificationsList.tsx | 14 ++++-- .../useInfiniteNotificationsScroll.ts | 45 ++++++++++++++++++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/components/Notifications/NotificationsContainer.tsx b/src/components/Notifications/NotificationsContainer.tsx index 54fd4ff38..d438776c5 100644 --- a/src/components/Notifications/NotificationsContainer.tsx +++ b/src/components/Notifications/NotificationsContainer.tsx @@ -32,6 +32,7 @@ const NotificationsContainer = ( { isError, isFetching, isInitialLoading, + loadingTimedOut, notifications, refetch, } = useInfiniteNotificationsScroll( notificationParams ); @@ -69,6 +70,7 @@ const NotificationsContainer = ( { isFetching={isFetching} isInitialLoading={isInitialLoading} isConnected={isConnected} + loadingTimedOut={loadingTimedOut} onEndReached={fetchNextPage} reload={refetch} refreshing={refreshing} diff --git a/src/components/Notifications/NotificationsList.tsx b/src/components/Notifications/NotificationsList.tsx index 636ac1123..ebcc16dcc 100644 --- a/src/components/Notifications/NotificationsList.tsx +++ b/src/components/Notifications/NotificationsList.tsx @@ -20,6 +20,7 @@ interface Props { isFetching?: boolean; isInitialLoading?: boolean; isConnected: boolean | null; + loadingTimedOut: boolean; onEndReached: ( ) => void; onRefresh: ( ) => void; refreshing: boolean; @@ -39,6 +40,7 @@ const NotificationsList = ( { isFetching, isInitialLoading, isConnected, + loadingTimedOut, onEndReached, onRefresh, reload, @@ -59,6 +61,12 @@ const NotificationsList = ( { ), [isFetching, isConnected, data.length] ); const renderEmptyComponent = useCallback( ( ) => { + // show an offline/retry state if the user isn't connected or this request just takes too long + if ( isConnected === false || loadingTimedOut ) { + return ; + } + + // Loading if ( isInitialLoading ) { return ( @@ -67,10 +75,7 @@ const NotificationsList = ( { ); } - if ( isConnected === false ) { - return ; - } - + // Empty/error state let msg = t( "No-Notifications-Found" ); let msg2 = null; if ( !currentUser ) { @@ -92,6 +97,7 @@ const NotificationsList = ( { isError, isInitialLoading, isConnected, + loadingTimedOut, reload, t, ] ); diff --git a/src/sharedHooks/useInfiniteNotificationsScroll.ts b/src/sharedHooks/useInfiniteNotificationsScroll.ts index 8f05bb3ca..eb4121a16 100644 --- a/src/sharedHooks/useInfiniteNotificationsScroll.ts +++ b/src/sharedHooks/useInfiniteNotificationsScroll.ts @@ -1,3 +1,4 @@ +import { useQueryClient } from "@tanstack/react-query"; import { fetchObservationUpdates, fetchRemoteObservations } from "api/observations"; import type { ApiNotification, @@ -7,12 +8,15 @@ import type { } from "api/types"; import flatten from "lodash/flatten"; import { RealmContext } from "providers/contexts"; +import { useEffect, useMemo, useState } from "react"; import type Realm from "realm"; import Observation from "realmModels/Observation"; import { useAuthenticatedInfiniteQuery, useCurrentUser } from "sharedHooks"; const { useRealm } = RealmContext; +const LOADING_TIMEOUT = 5000; + // Extends API response with data we need in this app export interface Notification extends ApiNotification { resource?: ApiObservation; @@ -24,6 +28,7 @@ interface InfiniteNotificationsScrollResponse { isError?: boolean; isFetching?: boolean; isInitialLoading?: boolean; + loadingTimedOut: boolean; notifications: Notification[]; refetch: ( ) => void; } @@ -89,8 +94,13 @@ const useInfiniteNotificationsScroll = ( ): InfiniteNotificationsScrollResponse => { const currentUser = useCurrentUser( ); const realm = useRealm( ); + const queryClient = useQueryClient(); + const [loadingTimedOut, setLoadingTimedOut] = useState( false ); - const queryKey = ["useInfiniteNotificationsScroll", JSON.stringify( notificationParams )]; + const queryKey = useMemo( + () => ["useInfiniteNotificationsScroll", JSON.stringify( notificationParams )], + [notificationParams], + ); const infQueryResult = useAuthenticatedInfiniteQuery( queryKey, @@ -142,11 +152,44 @@ const useInfiniteNotificationsScroll = ( }, ); + // We want to timeout and show an offline/retry state if this request takes too long + useEffect( () => { + // Reset if we get data + if ( infQueryResult.data !== undefined && !infQueryResult.isFetching ) { + setLoadingTimedOut( false ); + return undefined; + } + + // Don't set timer if not loading + if ( !infQueryResult.isFetching ) { + return undefined; + } + + // Set a timeout and cancel the query if we hit the limit + const timer = setTimeout( () => { + if ( infQueryResult.data === undefined && infQueryResult.isFetching ) { + queryClient.cancelQueries( { queryKey } ); + setLoadingTimedOut( true ); + } + }, LOADING_TIMEOUT ); + + // eslint-disable-next-line consistent-return + return () => clearTimeout( timer ); + }, [infQueryResult.data, infQueryResult.isFetching, queryKey, queryClient] ); + + // Reset when user manually retries + useEffect( () => { + if ( infQueryResult.isFetching ) { + setLoadingTimedOut( false ); + } + }, [infQueryResult.isFetching] ); + return { refetch: infQueryResult.refetch, isError: infQueryResult.isError, isFetching: infQueryResult.isFetching, isInitialLoading: infQueryResult.isInitialLoading, + loadingTimedOut, // Disable fetchNextPage if signed out fetchNextPage: currentUser ? infQueryResult.fetchNextPage From d4b3353cc08d7e51f84ec04ac482256c34cb3b1f Mon Sep 17 00:00:00 2001 From: Abbey Campbell Date: Thu, 5 Feb 2026 12:13:51 -0800 Subject: [PATCH 2/3] destructure from query object --- .../useInfiniteNotificationsScroll.ts | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/sharedHooks/useInfiniteNotificationsScroll.ts b/src/sharedHooks/useInfiniteNotificationsScroll.ts index eb4121a16..dd5f1cd04 100644 --- a/src/sharedHooks/useInfiniteNotificationsScroll.ts +++ b/src/sharedHooks/useInfiniteNotificationsScroll.ts @@ -102,7 +102,14 @@ const useInfiniteNotificationsScroll = ( [notificationParams], ); - const infQueryResult = useAuthenticatedInfiniteQuery( + const { + data, + isFetching, + isInitialLoading, + isError, + refetch, + fetchNextPage, + } = useAuthenticatedInfiniteQuery( queryKey, async ( { pageParam }: { pageParam: number }, optsWithAuth: ApiOpts ) => { const params = { ...BASE_PARAMS, ...notificationParams }; @@ -155,19 +162,19 @@ const useInfiniteNotificationsScroll = ( // We want to timeout and show an offline/retry state if this request takes too long useEffect( () => { // Reset if we get data - if ( infQueryResult.data !== undefined && !infQueryResult.isFetching ) { + if ( data !== undefined && !isFetching ) { setLoadingTimedOut( false ); return undefined; } // Don't set timer if not loading - if ( !infQueryResult.isFetching ) { + if ( !isFetching ) { return undefined; } // Set a timeout and cancel the query if we hit the limit const timer = setTimeout( () => { - if ( infQueryResult.data === undefined && infQueryResult.isFetching ) { + if ( data === undefined && isFetching ) { queryClient.cancelQueries( { queryKey } ); setLoadingTimedOut( true ); } @@ -175,26 +182,26 @@ const useInfiniteNotificationsScroll = ( // eslint-disable-next-line consistent-return return () => clearTimeout( timer ); - }, [infQueryResult.data, infQueryResult.isFetching, queryKey, queryClient] ); + }, [data, isFetching, queryKey, queryClient] ); // Reset when user manually retries useEffect( () => { - if ( infQueryResult.isFetching ) { + if ( isFetching ) { setLoadingTimedOut( false ); } - }, [infQueryResult.isFetching] ); + }, [isFetching] ); return { - refetch: infQueryResult.refetch, - isError: infQueryResult.isError, - isFetching: infQueryResult.isFetching, - isInitialLoading: infQueryResult.isInitialLoading, + refetch, + isError, + isFetching, + isInitialLoading, loadingTimedOut, // Disable fetchNextPage if signed out fetchNextPage: currentUser - ? infQueryResult.fetchNextPage + ? fetchNextPage : ( ) => undefined, - notifications: flatten( infQueryResult?.data?.pages ), + notifications: flatten( data?.pages ), }; }; From a0b7a23991a023530b1c54762ef3199ef6c65ffc Mon Sep 17 00:00:00 2001 From: Abbey Campbell Date: Mon, 9 Feb 2026 16:26:55 -0800 Subject: [PATCH 3/3] define notificationParams only once --- src/components/Notifications/Notifications.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Notifications/Notifications.tsx b/src/components/Notifications/Notifications.tsx index 6e742bafa..b6c2cd830 100644 --- a/src/components/Notifications/Notifications.tsx +++ b/src/components/Notifications/Notifications.tsx @@ -14,6 +14,9 @@ import NotificationsTab, { OWNER_TAB, } from "./NotificationsTab"; +const OWNER_TAB_PARAMS = { observations_by: "owner" } as const; +const FOLLOWING_TAB_PARAMS = { observations_by: "following" } as const; + const Notifications = ( ) => { const [activeTab, setActiveTab] = useState( OWNER_TAB ); const { t } = useTranslation(); @@ -44,14 +47,14 @@ const Notifications = ( ) => { {activeTab === OWNER_TAB && ( EventRegister.emit( NOTIFICATIONS_REFRESHED, OWNER_TAB )} /> )} {activeTab === OTHER_TAB && ( EventRegister.emit( NOTIFICATIONS_REFRESHED, OTHER_TAB )} /> )}