From 55a6ea4fca299f776d8e7b5c5b7f4f18e66a4f31 Mon Sep 17 00:00:00 2001 From: Damien Erambert Date: Mon, 2 Feb 2026 20:25:19 -0800 Subject: [PATCH] Prevent double fetching when force refreshing paginated views (#1637) * Prevent double fetching when force refreshing paginated views * remove await from infinite list loader query invalidation * add mutation and loading state to list refresh * add non-suspense query to list genre filters to add loading state * remove list count data set on random queries --------- Co-authored-by: jeffvli --- src/renderer/api/utils-list-count.ts | 6 +++- .../helpers/item-list-infinite-loader.ts | 21 +++++++++----- .../helpers/item-list-paginated-loader.ts | 20 ++++++------- .../components/jellyfin-album-filters.tsx | 5 +++- .../components/navidrome-album-filters.tsx | 25 +++++++++++++++-- .../components/subsonic-album-filters.tsx | 28 ++++++++++++++++--- .../shared/components/list-refresh-button.tsx | 10 ++++++- .../shared/components/refresh-button.tsx | 7 +++-- .../components/jellyfin-song-filters.tsx | 26 ++++++++++++++--- .../components/navidrome-song-filters.tsx | 27 +++++++++++++++--- .../components/subsonic-song-filters.tsx | 2 ++ 11 files changed, 140 insertions(+), 37 deletions(-) diff --git a/src/renderer/api/utils-list-count.ts b/src/renderer/api/utils-list-count.ts index 4f9d92c4b..0e3f78bed 100644 --- a/src/renderer/api/utils-list-count.ts +++ b/src/renderer/api/utils-list-count.ts @@ -62,7 +62,11 @@ export const getOptimizedListCount = async < query: pageQuery, }); - client.setQueryData(pageQueryKey, pageResult); + const keyContainsRandom = JSON.stringify(pageQueryKey).toLowerCase().includes('random'); + + if (!keyContainsRandom) { + client.setQueryData(pageQueryKey, pageResult); + } return pageResult.totalRecordCount ?? 0; }; diff --git a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts index df570a114..d55c2219a 100644 --- a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts +++ b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts @@ -1,4 +1,5 @@ import { + useMutation, useQuery, useQueryClient, useSuspenseQuery, @@ -11,6 +12,7 @@ import { queryKeys } from '/@/renderer/api/query-keys'; import { useListContext } from '/@/renderer/context/list-context'; import { eventEmitter } from '/@/renderer/events/event-emitter'; import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; +import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button'; import { LibraryItem } from '/@/shared/types/domain-types'; export const getListQueryKeyName = (itemType: LibraryItem): string => { @@ -293,10 +295,10 @@ export const useItemListInfiniteLoader = ({ [onRangeChangedBase], ); - const refresh = useCallback( - async (force?: boolean) => { + const refreshMutation = useMutation({ + mutationFn: async (force?: boolean) => { // Invalidate all queries to ensure fresh data - await queryClient.invalidateQueries(); + queryClient.invalidateQueries(); // Reset the infinite list data const currentData = queryClient.getQueryData<{ @@ -320,7 +322,7 @@ export const useItemListInfiniteLoader = ({ } // Add a delay to make the refresh visually clear - await new Promise((resolve) => setTimeout(resolve, 150)); + // await new Promise((resolve) => setTimeout(resolve, 150)); // Determine which page to refetch based on current visible range let pageToFetch = 0; @@ -344,7 +346,12 @@ export const useItemListInfiniteLoader = ({ stopIndex, }); }, - [queryClient, itemsPerPage, onRangeChangedBase, dataQueryKey, totalItemCount, fetchPage], + mutationKey: getListRefreshMutationKey(eventKey), + }); + + const refresh = useCallback( + async (force?: boolean) => refreshMutation.mutateAsync(force), + [refreshMutation], ); const updateItems = useCallback( @@ -376,7 +383,7 @@ export const useItemListInfiniteLoader = ({ return; } - return refresh(true); + refreshMutation.mutate(true); }; eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh); @@ -384,7 +391,7 @@ export const useItemListInfiniteLoader = ({ return () => { eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh); }; - }, [eventKey, refresh]); + }, [eventKey, refreshMutation]); useEffect(() => { const handleFavorite = (payload: UserFavoriteEventPayload) => { diff --git a/src/renderer/components/item-list/helpers/item-list-paginated-loader.ts b/src/renderer/components/item-list/helpers/item-list-paginated-loader.ts index 72c734769..73e1d2aa2 100644 --- a/src/renderer/components/item-list/helpers/item-list-paginated-loader.ts +++ b/src/renderer/components/item-list/helpers/item-list-paginated-loader.ts @@ -1,4 +1,5 @@ import { + useMutation, useQuery, useQueryClient, useSuspenseQuery, @@ -10,6 +11,7 @@ import { queryKeys } from '/@/renderer/api/query-keys'; import { useListContext } from '/@/renderer/context/list-context'; import { eventEmitter } from '/@/renderer/events/event-emitter'; import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; +import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button'; import { LibraryItem } from '/@/shared/types/domain-types'; const getQueryKeyName = (itemType: LibraryItem): string => { @@ -83,7 +85,7 @@ export const useItemListPaginatedLoader = ({ [itemsPerPage, startIndex, query], ); - const { data, refetch: queryRefetch } = useQuery({ + const { data } = useQuery({ gcTime: 1000 * 15, placeholderData: { items: getInitialData(itemsPerPage) }, queryFn: async ({ signal }) => { @@ -98,22 +100,20 @@ export const useItemListPaginatedLoader = ({ staleTime: 1000 * 15, }); - const refresh = useCallback( - async (force?: boolean) => { + const refreshMutation = useMutation({ + mutationFn: async (force?: boolean) => { const queryKey = queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams); - await queryClient.invalidateQueries(); - if (force) { queryClient.setQueryData(queryKey, { items: getInitialData(itemsPerPage), }); } - return queryRefetch(); + await queryClient.invalidateQueries(); }, - [queryClient, queryRefetch, queryParams, serverId, itemType, itemsPerPage], - ); + mutationKey: getListRefreshMutationKey(eventKey ?? 'paginated'), + }); const updateItems = useCallback( (indexes: number[], value: object) => { @@ -153,7 +153,7 @@ export const useItemListPaginatedLoader = ({ return; } - return refresh(true); + refreshMutation.mutate(true); }; const handleFavorite = (payload: UserFavoriteEventPayload) => { @@ -220,7 +220,7 @@ export const useItemListPaginatedLoader = ({ eventEmitter.off('USER_FAVORITE', handleFavorite); eventEmitter.off('USER_RATING', handleRating); }; - }, [data, eventKey, itemType, serverId, refresh, updateItems]); + }, [data, eventKey, itemType, refreshMutation, serverId, updateItems]); return { data: data?.items || [], pageCount, totalItemCount }; }; diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index d77e22d18..b6eefa165 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -52,9 +52,12 @@ export const JellyfinAlbumFilters = ({ setMinYear, } = useAlbumListFilters(); - // TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library const genreListQuery = useQuery( genresQueries.list({ + options: { + gcTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, query: { sortBy: GenreListSort.NAME, sortOrder: SortOrder.ASC, diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index 9c4df98a2..136f9490d 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; -import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; +import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; import { ArtistMultiSelectRow, GenreMultiSelectRow, @@ -22,7 +22,12 @@ import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { Text } from '/@/shared/components/text/text'; import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; -import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types'; +import { + AlbumArtistListSort, + GenreListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; interface NavidromeAlbumFiltersProps { disableArtistFilter?: boolean; @@ -54,7 +59,20 @@ export const NavidromeAlbumFilters = ({ setRecentlyPlayed, } = useAlbumListFilters(); - const genreListQuery = useGenreList(); + const genreListQuery = useQuery( + genresQueries.list({ + options: { + gcTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, + query: { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }), + ); const genreList = useMemo(() => { if (!genreListQuery?.data) return []; @@ -333,6 +351,7 @@ export const NavidromeAlbumFilters = ({ { if (!genreListQuery?.data) return []; @@ -252,6 +270,7 @@ export const SubsonicAlbumFilters = ({ disabled={isArtistDisabled} displayCountType="album" height={300} + isLoading={albumArtistListQuery.isFetching} label={artistFilterLabel} onChange={handleAlbumArtistFilter} options={selectableAlbumArtists} @@ -268,6 +287,7 @@ export const SubsonicAlbumFilters = ({ disabled={isGenreDisabled} displayCountType="album" height={220} + isLoading={genreListQuery.isFetching} label={genreFilterLabel} onChange={handleGenresFilter} options={genreList} diff --git a/src/renderer/features/shared/components/list-refresh-button.tsx b/src/renderer/features/shared/components/list-refresh-button.tsx index d27dacf80..8f97743de 100644 --- a/src/renderer/features/shared/components/list-refresh-button.tsx +++ b/src/renderer/features/shared/components/list-refresh-button.tsx @@ -1,3 +1,4 @@ +import { useIsMutating } from '@tanstack/react-query'; import { useCallback } from 'react'; import { eventEmitter } from '/@/renderer/events/event-emitter'; @@ -10,9 +11,16 @@ interface ListRefreshButtonProps { } export const ListRefreshButton = ({ disabled, listKey }: ListRefreshButtonProps) => { + const isRefreshing = useIsMutating({ mutationKey: getListRefreshMutationKey(listKey) }) > 0; + const handleRefresh = useCallback(() => { eventEmitter.emit('ITEM_LIST_REFRESH', { key: listKey }); }, [listKey]); - return ; + return ; }; + +export const LIST_REFRESH_MUTATION_KEY = 'item-list-refresh'; + +export const getListRefreshMutationKey = (listKey: string) => + [LIST_REFRESH_MUTATION_KEY, listKey] as const; diff --git a/src/renderer/features/shared/components/refresh-button.tsx b/src/renderer/features/shared/components/refresh-button.tsx index 01a606626..9076b555a 100644 --- a/src/renderer/features/shared/components/refresh-button.tsx +++ b/src/renderer/features/shared/components/refresh-button.tsx @@ -2,9 +2,11 @@ import { useTranslation } from 'react-i18next'; import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; -interface RefreshButtonProps extends ActionIconProps {} +interface RefreshButtonProps extends ActionIconProps { + loading?: boolean; +} -export const RefreshButton = ({ onClick, ...props }: RefreshButtonProps) => { +export const RefreshButton = ({ loading, onClick, ...props }: RefreshButtonProps) => { const { t } = useTranslation(); return ( @@ -14,6 +16,7 @@ export const RefreshButton = ({ onClick, ...props }: RefreshButtonProps) => { size: 'lg', ...props.iconProps, }} + loading={loading} onClick={onClick} tooltip={{ label: t('common.refresh', { postProcess: 'sentenceCase' }), diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index 9569461b8..955a4fa43 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -1,10 +1,10 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; -import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; +import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; import { ArtistMultiSelectRow, GenreMultiSelectRow, @@ -22,7 +22,12 @@ import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select'; import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; -import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types'; +import { + AlbumArtistListSort, + GenreListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; interface JellyfinSongFiltersProps { disableArtistFilter?: boolean; @@ -41,7 +46,20 @@ export const JellyfinSongFilters = ({ // Despite the fact that getTags returns genres, it only returns genre names. // We prefer using IDs, hence the double query - const genreListQuery = useGenreList(); + const genreListQuery = useQuery( + genresQueries.list({ + options: { + gcTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, + query: { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }), + ); const genreList = useMemo(() => { if (!genreListQuery.data) return []; diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index 7ec90e607..ccc5309d3 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -1,10 +1,10 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; -import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; +import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; import { ArtistMultiSelectRow, GenreMultiSelectRow, @@ -21,7 +21,12 @@ import { SegmentedControl } from '/@/shared/components/segmented-control/segment import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; -import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types'; +import { + AlbumArtistListSort, + GenreListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; interface NavidromeSongFiltersProps { disableArtistFilter?: boolean; @@ -38,7 +43,20 @@ export const NavidromeSongFilters = ({ const { query, setArtistIds, setCustom, setFavorite, setGenreId, setMaxYear, setMinYear } = useSongListFilters(); - const genreListQuery = useGenreList(); + const genreListQuery = useQuery( + genresQueries.list({ + options: { + gcTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, + query: { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }), + ); const genreList = useMemo(() => { if (!genreListQuery?.data) return []; @@ -279,6 +297,7 @@ export const NavidromeSongFilters = ({