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 <jeffvictorli@gmail.com>
This commit is contained in:
Damien Erambert
2026-02-02 20:25:19 -08:00
committed by GitHub
parent 72fc5beb98
commit 55a6ea4fca
11 changed files with 140 additions and 37 deletions
+5 -1
View File
@@ -62,7 +62,11 @@ export const getOptimizedListCount = async <
query: pageQuery, query: pageQuery,
}); });
client.setQueryData(pageQueryKey, pageResult); const keyContainsRandom = JSON.stringify(pageQueryKey).toLowerCase().includes('random');
if (!keyContainsRandom) {
client.setQueryData(pageQueryKey, pageResult);
}
return pageResult.totalRecordCount ?? 0; return pageResult.totalRecordCount ?? 0;
}; };
@@ -1,4 +1,5 @@
import { import {
useMutation,
useQuery, useQuery,
useQueryClient, useQueryClient,
useSuspenseQuery, useSuspenseQuery,
@@ -11,6 +12,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter'; import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
export const getListQueryKeyName = (itemType: LibraryItem): string => { export const getListQueryKeyName = (itemType: LibraryItem): string => {
@@ -293,10 +295,10 @@ export const useItemListInfiniteLoader = ({
[onRangeChangedBase], [onRangeChangedBase],
); );
const refresh = useCallback( const refreshMutation = useMutation({
async (force?: boolean) => { mutationFn: async (force?: boolean) => {
// Invalidate all queries to ensure fresh data // Invalidate all queries to ensure fresh data
await queryClient.invalidateQueries(); queryClient.invalidateQueries();
// Reset the infinite list data // Reset the infinite list data
const currentData = queryClient.getQueryData<{ const currentData = queryClient.getQueryData<{
@@ -320,7 +322,7 @@ export const useItemListInfiniteLoader = ({
} }
// Add a delay to make the refresh visually clear // 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 // Determine which page to refetch based on current visible range
let pageToFetch = 0; let pageToFetch = 0;
@@ -344,7 +346,12 @@ export const useItemListInfiniteLoader = ({
stopIndex, stopIndex,
}); });
}, },
[queryClient, itemsPerPage, onRangeChangedBase, dataQueryKey, totalItemCount, fetchPage], mutationKey: getListRefreshMutationKey(eventKey),
});
const refresh = useCallback(
async (force?: boolean) => refreshMutation.mutateAsync(force),
[refreshMutation],
); );
const updateItems = useCallback( const updateItems = useCallback(
@@ -376,7 +383,7 @@ export const useItemListInfiniteLoader = ({
return; return;
} }
return refresh(true); refreshMutation.mutate(true);
}; };
eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh); eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);
@@ -384,7 +391,7 @@ export const useItemListInfiniteLoader = ({
return () => { return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh); eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
}; };
}, [eventKey, refresh]); }, [eventKey, refreshMutation]);
useEffect(() => { useEffect(() => {
const handleFavorite = (payload: UserFavoriteEventPayload) => { const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -1,4 +1,5 @@
import { import {
useMutation,
useQuery, useQuery,
useQueryClient, useQueryClient,
useSuspenseQuery, useSuspenseQuery,
@@ -10,6 +11,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter'; import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
const getQueryKeyName = (itemType: LibraryItem): string => { const getQueryKeyName = (itemType: LibraryItem): string => {
@@ -83,7 +85,7 @@ export const useItemListPaginatedLoader = ({
[itemsPerPage, startIndex, query], [itemsPerPage, startIndex, query],
); );
const { data, refetch: queryRefetch } = useQuery({ const { data } = useQuery({
gcTime: 1000 * 15, gcTime: 1000 * 15,
placeholderData: { items: getInitialData(itemsPerPage) }, placeholderData: { items: getInitialData(itemsPerPage) },
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
@@ -98,22 +100,20 @@ export const useItemListPaginatedLoader = ({
staleTime: 1000 * 15, staleTime: 1000 * 15,
}); });
const refresh = useCallback( const refreshMutation = useMutation({
async (force?: boolean) => { mutationFn: async (force?: boolean) => {
const queryKey = queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams); const queryKey = queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams);
await queryClient.invalidateQueries();
if (force) { if (force) {
queryClient.setQueryData(queryKey, { queryClient.setQueryData(queryKey, {
items: getInitialData(itemsPerPage), items: getInitialData(itemsPerPage),
}); });
} }
return queryRefetch(); await queryClient.invalidateQueries();
}, },
[queryClient, queryRefetch, queryParams, serverId, itemType, itemsPerPage], mutationKey: getListRefreshMutationKey(eventKey ?? 'paginated'),
); });
const updateItems = useCallback( const updateItems = useCallback(
(indexes: number[], value: object) => { (indexes: number[], value: object) => {
@@ -153,7 +153,7 @@ export const useItemListPaginatedLoader = ({
return; return;
} }
return refresh(true); refreshMutation.mutate(true);
}; };
const handleFavorite = (payload: UserFavoriteEventPayload) => { const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -220,7 +220,7 @@ export const useItemListPaginatedLoader = ({
eventEmitter.off('USER_FAVORITE', handleFavorite); eventEmitter.off('USER_FAVORITE', handleFavorite);
eventEmitter.off('USER_RATING', handleRating); eventEmitter.off('USER_RATING', handleRating);
}; };
}, [data, eventKey, itemType, serverId, refresh, updateItems]); }, [data, eventKey, itemType, refreshMutation, serverId, updateItems]);
return { data: data?.items || [], pageCount, totalItemCount }; return { data: data?.items || [], pageCount, totalItemCount };
}; };
@@ -52,9 +52,12 @@ export const JellyfinAlbumFilters = ({
setMinYear, setMinYear,
} = useAlbumListFilters(); } = useAlbumListFilters();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
const genreListQuery = useQuery( const genreListQuery = useQuery(
genresQueries.list({ genresQueries.list({
options: {
gcTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
query: { query: {
sortBy: GenreListSort.NAME, sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC, sortOrder: SortOrder.ASC,
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; 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 { import {
ArtistMultiSelectRow, ArtistMultiSelectRow,
GenreMultiSelectRow, GenreMultiSelectRow,
@@ -22,7 +22,12 @@ import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; 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 { interface NavidromeAlbumFiltersProps {
disableArtistFilter?: boolean; disableArtistFilter?: boolean;
@@ -54,7 +59,20 @@ export const NavidromeAlbumFilters = ({
setRecentlyPlayed, setRecentlyPlayed,
} = useAlbumListFilters(); } = 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(() => { const genreList = useMemo(() => {
if (!genreListQuery?.data) return []; if (!genreListQuery?.data) return [];
@@ -333,6 +351,7 @@ export const NavidromeAlbumFilters = ({
<VirtualMultiSelect <VirtualMultiSelect
displayCountType="album" displayCountType="album"
height={220} height={220}
isLoading={genreListQuery.isFetching}
label={genreFilterLabel} label={genreFilterLabel}
onChange={handleGenreChange} onChange={handleGenreChange}
options={genreList} options={genreList}
@@ -1,11 +1,11 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { ChangeEvent, useCallback, useMemo } from 'react'; import { ChangeEvent, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; 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 { import {
ArtistMultiSelectRow, ArtistMultiSelectRow,
GenreMultiSelectRow, GenreMultiSelectRow,
@@ -21,7 +21,12 @@ import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; 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 SubsonicAlbumFiltersProps { interface SubsonicAlbumFiltersProps {
disableArtistFilter?: boolean; disableArtistFilter?: boolean;
@@ -90,7 +95,20 @@ export const SubsonicAlbumFilters = ({
[isArtistDisabled, setAlbumArtist], [isArtistDisabled, setAlbumArtist],
); );
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(() => { const genreList = useMemo(() => {
if (!genreListQuery?.data) return []; if (!genreListQuery?.data) return [];
@@ -252,6 +270,7 @@ export const SubsonicAlbumFilters = ({
disabled={isArtistDisabled} disabled={isArtistDisabled}
displayCountType="album" displayCountType="album"
height={300} height={300}
isLoading={albumArtistListQuery.isFetching}
label={artistFilterLabel} label={artistFilterLabel}
onChange={handleAlbumArtistFilter} onChange={handleAlbumArtistFilter}
options={selectableAlbumArtists} options={selectableAlbumArtists}
@@ -268,6 +287,7 @@ export const SubsonicAlbumFilters = ({
disabled={isGenreDisabled} disabled={isGenreDisabled}
displayCountType="album" displayCountType="album"
height={220} height={220}
isLoading={genreListQuery.isFetching}
label={genreFilterLabel} label={genreFilterLabel}
onChange={handleGenresFilter} onChange={handleGenresFilter}
options={genreList} options={genreList}
@@ -1,3 +1,4 @@
import { useIsMutating } from '@tanstack/react-query';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { eventEmitter } from '/@/renderer/events/event-emitter'; import { eventEmitter } from '/@/renderer/events/event-emitter';
@@ -10,9 +11,16 @@ interface ListRefreshButtonProps {
} }
export const ListRefreshButton = ({ disabled, listKey }: ListRefreshButtonProps) => { export const ListRefreshButton = ({ disabled, listKey }: ListRefreshButtonProps) => {
const isRefreshing = useIsMutating({ mutationKey: getListRefreshMutationKey(listKey) }) > 0;
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
eventEmitter.emit('ITEM_LIST_REFRESH', { key: listKey }); eventEmitter.emit('ITEM_LIST_REFRESH', { key: listKey });
}, [listKey]); }, [listKey]);
return <RefreshButton disabled={disabled} onClick={handleRefresh} />; return <RefreshButton disabled={disabled} loading={isRefreshing} onClick={handleRefresh} />;
}; };
export const LIST_REFRESH_MUTATION_KEY = 'item-list-refresh';
export const getListRefreshMutationKey = (listKey: string) =>
[LIST_REFRESH_MUTATION_KEY, listKey] as const;
@@ -2,9 +2,11 @@ import { useTranslation } from 'react-i18next';
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; 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(); const { t } = useTranslation();
return ( return (
@@ -14,6 +16,7 @@ export const RefreshButton = ({ onClick, ...props }: RefreshButtonProps) => {
size: 'lg', size: 'lg',
...props.iconProps, ...props.iconProps,
}} }}
loading={loading}
onClick={onClick} onClick={onClick}
tooltip={{ tooltip={{
label: t('common.refresh', { postProcess: 'sentenceCase' }), label: t('common.refresh', { postProcess: 'sentenceCase' }),
@@ -1,10 +1,10 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; 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 { import {
ArtistMultiSelectRow, ArtistMultiSelectRow,
GenreMultiSelectRow, GenreMultiSelectRow,
@@ -22,7 +22,12 @@ import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select'; import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; 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 { interface JellyfinSongFiltersProps {
disableArtistFilter?: boolean; disableArtistFilter?: boolean;
@@ -41,7 +46,20 @@ export const JellyfinSongFilters = ({
// Despite the fact that getTags returns genres, it only returns genre names. // Despite the fact that getTags returns genres, it only returns genre names.
// We prefer using IDs, hence the double query // 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(() => { const genreList = useMemo(() => {
if (!genreListQuery.data) return []; if (!genreListQuery.data) return [];
@@ -1,10 +1,10 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; 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 { import {
ArtistMultiSelectRow, ArtistMultiSelectRow,
GenreMultiSelectRow, GenreMultiSelectRow,
@@ -21,7 +21,12 @@ import { SegmentedControl } from '/@/shared/components/segmented-control/segment
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; 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 { interface NavidromeSongFiltersProps {
disableArtistFilter?: boolean; disableArtistFilter?: boolean;
@@ -38,7 +43,20 @@ export const NavidromeSongFilters = ({
const { query, setArtistIds, setCustom, setFavorite, setGenreId, setMaxYear, setMinYear } = const { query, setArtistIds, setCustom, setFavorite, setGenreId, setMaxYear, setMinYear } =
useSongListFilters(); 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(() => { const genreList = useMemo(() => {
if (!genreListQuery?.data) return []; if (!genreListQuery?.data) return [];
@@ -279,6 +297,7 @@ export const NavidromeSongFilters = ({
<VirtualMultiSelect <VirtualMultiSelect
displayCountType="song" displayCountType="song"
height={220} height={220}
isLoading={genreListQuery.isFetching}
label={genreFilterLabel} label={genreFilterLabel}
onChange={handleGenreChange} onChange={handleGenreChange}
options={genreList} options={genreList}
@@ -159,6 +159,7 @@ export const SubsonicSongFilters = ({
disabled={isArtistDisabled} disabled={isArtistDisabled}
displayCountType="song" displayCountType="song"
height={300} height={300}
isLoading={albumArtistListQuery.isFetching}
label={artistFilterLabel} label={artistFilterLabel}
onChange={handleArtistFilter} onChange={handleArtistFilter}
options={selectableAlbumArtists} options={selectableAlbumArtists}
@@ -175,6 +176,7 @@ export const SubsonicSongFilters = ({
disabled={isGenreDisabled} disabled={isGenreDisabled}
displayCountType="song" displayCountType="song"
height={220} height={220}
isLoading={genreListQuery.isFetching}
label={genreFilterLabel} label={genreFilterLabel}
onChange={handleGenresFilter} onChange={handleGenresFilter}
options={genreList} options={genreList}