fix(favorites): sync empty-state on cache hits to avoid blank view

Switching Favorites<->Watchlist (props swap in place, no remount) left the
page blank with only the tab buttons: the empty-state was written as a side
effect inside the queryFn, which React Query skips on cache hits, and a reset
effect then cleared it — so on a cache-served switch nothing repopulated it,
every list was hidden by hideIfEmpty, and the empty message never rendered.

Each list now reports emptiness once its query settles (incl. cache hits) via
a new optional onEmptyStateChange callback on InfiniteScrollingCollectionList
(mobile + TV), reporting null while loading so a switch never flashes a stale
state. The parent derives the aggregate empty-state from that (null = not
settled yet, so the message stays hidden during a switch).

The reset effect is removed entirely, not kept: React runs child effects
before parent effects within a commit, so a parent reset setEmptyState(false)
would clobber the children's reported values on the same render. Dropping it
and using the per-list callbacks (tri-state null/true/false) is what makes the
switch — including a cache-served one — resolve to the correct state. Applies
to both mobile and TV Favorites.
This commit is contained in:
Gauvain
2026-07-01 22:31:15 +02:00
parent 17a591cd3c
commit 0cac1f8779
4 changed files with 95 additions and 69 deletions

View File

@@ -7,7 +7,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
// PNG ASSET // PNG ASSET
import heart from "@/assets/icons/heart.fill.png"; import heart from "@/assets/icons/heart.fill.png";
@@ -23,7 +23,9 @@ type FavoriteTypes =
| "Video" | "Video"
| "BoxSet" | "BoxSet"
| "Playlist"; | "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>; // `null` = not settled yet (loading/unknown); avoids flashing the empty
// message during a favorites/watchlist switch before the new queries resolve.
type EmptyState = Record<FavoriteTypes, boolean | null>;
interface FavoritesProps { interface FavoritesProps {
/** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */ /** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */
@@ -48,12 +50,12 @@ export const Favorites = ({
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const pageSize = 20; const pageSize = 20;
const [emptyState, setEmptyState] = useState<EmptyState>({ const [emptyState, setEmptyState] = useState<EmptyState>({
Series: false, Series: null,
Movie: false, Movie: null,
Episode: false, Episode: null,
Video: false, Video: null,
BoxSet: false, BoxSet: null,
Playlist: false, Playlist: null,
}); });
const fetchFavoritesByType = useCallback( const fetchFavoritesByType = useCallback(
@@ -76,42 +78,28 @@ export const Favorites = ({
limit: limit, limit: limit,
includeItemTypes: [itemType], includeItemTypes: [itemType],
}); });
const items = response.data.Items || []; return response.data.Items || [];
// Update empty state for this specific type only for the first page
if (startIndex === 0) {
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
}
return items;
}, },
[api, user, filter], [api, user, filter],
); );
// Reset empty state when the account or active view changes. `filter` // Emptiness is reported by each list once its query settles (incl. cache
// matters because switching the favorites/watchlist toggle swaps this // hits), so it stays correct where a queryFn side effect would go stale.
// component's props in place (no remount), so stale per-type emptiness const setTypeEmpty = useCallback(
// from the previous view must be cleared until the new queries resolve. (type: FavoriteTypes, isEmpty: boolean | null) =>
useEffect(() => { setEmptyState((prev) =>
setEmptyState({ prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty },
Series: false, ),
Movie: false, [],
Episode: false, );
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user, filter]);
// Check if all categories that have been loaded are empty // Show the empty message only once every category has settled AND is empty.
// A `null` (still loading) keeps it hidden, so switching favorites/watchlist
// (props swap in place, no remount) never flashes a stale empty state.
const areAllEmpty = () => { const areAllEmpty = () => {
const loadedCategories = Object.values(emptyState); const categories = Object.values(emptyState);
return ( return (
loadedCategories.length > 0 && categories.length > 0 && categories.every((isEmpty) => isEmpty === true)
loadedCategories.every((isEmpty) => isEmpty)
); );
}; };
@@ -191,6 +179,7 @@ export const Favorites = ({
title={t("favorites.series")} title={t("favorites.series")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
onPressSeeAll={() => seeAll("Series", "Series")} onPressSeeAll={() => seeAll("Series", "Series")}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
@@ -200,6 +189,7 @@ export const Favorites = ({
hideIfEmpty hideIfEmpty
orientation='vertical' orientation='vertical'
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)}
onPressSeeAll={() => seeAll("Movie", "Movies")} onPressSeeAll={() => seeAll("Movie", "Movies")}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
@@ -208,6 +198,7 @@ export const Favorites = ({
title={t("favorites.episodes")} title={t("favorites.episodes")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)}
onPressSeeAll={() => seeAll("Episode", "Episodes")} onPressSeeAll={() => seeAll("Episode", "Episodes")}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
@@ -216,6 +207,7 @@ export const Favorites = ({
title={t("favorites.videos")} title={t("favorites.videos")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)}
onPressSeeAll={() => seeAll("Video", "Videos")} onPressSeeAll={() => seeAll("Video", "Videos")}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
@@ -224,6 +216,7 @@ export const Favorites = ({
title={t("favorites.boxsets")} title={t("favorites.boxsets")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)}
onPressSeeAll={() => seeAll("BoxSet", "Boxsets")} onPressSeeAll={() => seeAll("BoxSet", "Boxsets")}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
@@ -232,6 +225,7 @@ export const Favorites = ({
title={t("favorites.playlists")} title={t("favorites.playlists")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)}
onPressSeeAll={() => seeAll("Playlist", "Playlists")} onPressSeeAll={() => seeAll("Playlist", "Playlists")}
/> />
</View> </View>

View File

@@ -6,7 +6,7 @@ import type {
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -30,7 +30,9 @@ type FavoriteTypes =
| "Video" | "Video"
| "BoxSet" | "BoxSet"
| "Playlist"; | "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>; // `null` = not settled yet (loading/unknown); avoids flashing the empty
// message during a favorites/watchlist switch before the new queries resolve.
type EmptyState = Record<FavoriteTypes, boolean | null>;
export const Favorites = () => { export const Favorites = () => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
@@ -60,12 +62,12 @@ export const Favorites = () => {
const emptyTextKey = `${emptyNamespace}.noData`; const emptyTextKey = `${emptyNamespace}.noData`;
const [emptyState, setEmptyState] = useState<EmptyState>({ const [emptyState, setEmptyState] = useState<EmptyState>({
Series: false, Series: null,
Movie: false, Movie: null,
Episode: false, Episode: null,
Video: false, Video: null,
BoxSet: false, BoxSet: null,
Playlist: false, Playlist: null,
}); });
const fetchFavoritesByType = useCallback( const fetchFavoritesByType = useCallback(
@@ -88,36 +90,28 @@ export const Favorites = () => {
limit: limit, limit: limit,
includeItemTypes: [itemType], includeItemTypes: [itemType],
}); });
const items = response.data.Items || []; return response.data.Items || [];
if (startIndex === 0) {
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
}
return items;
}, },
[api, user, filter], [api, user, filter],
); );
useEffect(() => { // Emptiness is reported by each list once its query settles (incl. cache
setEmptyState({ // hits), so it stays correct where a queryFn side effect would go stale.
Series: false, const setTypeEmpty = useCallback(
Movie: false, (type: FavoriteTypes, isEmpty: boolean | null) =>
Episode: false, setEmptyState((prev) =>
Video: false, prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty },
BoxSet: false, ),
Playlist: false, [],
}); );
}, [api, user, viewType]);
// Show the empty message only once every category has settled AND is empty.
// A `null` (still loading) keeps it hidden, so switching favorites/watchlist
// never flashes a stale empty state.
const areAllEmpty = () => { const areAllEmpty = () => {
const loadedCategories = Object.values(emptyState); const categories = Object.values(emptyState);
return ( return (
loadedCategories.length > 0 && categories.length > 0 && categories.every((isEmpty) => isEmpty === true)
loadedCategories.every((isEmpty) => isEmpty)
); );
}; };
@@ -235,6 +229,7 @@ export const Favorites = () => {
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
isFirstSection={!watchlistEnabled} isFirstSection={!watchlistEnabled}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies} queryFn={fetchFavoriteMovies}
@@ -243,6 +238,7 @@ export const Favorites = () => {
hideIfEmpty hideIfEmpty
orientation='vertical' orientation='vertical'
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes} queryFn={fetchFavoriteEpisodes}
@@ -250,6 +246,7 @@ export const Favorites = () => {
title={t("favorites.episodes")} title={t("favorites.episodes")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos} queryFn={fetchFavoriteVideos}
@@ -257,6 +254,7 @@ export const Favorites = () => {
title={t("favorites.videos")} title={t("favorites.videos")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets} queryFn={fetchFavoriteBoxsets}
@@ -264,6 +262,7 @@ export const Favorites = () => {
title={t("favorites.boxsets")} title={t("favorites.boxsets")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists} queryFn={fetchFavoritePlaylists}
@@ -271,6 +270,7 @@ export const Favorites = () => {
title={t("favorites.playlists")} title={t("favorites.playlists")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)}
/> />
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -32,6 +32,13 @@ interface Props extends ViewProps {
onPressSeeAll?: () => void; onPressSeeAll?: () => void;
enabled?: boolean; enabled?: boolean;
onLoaded?: () => void; onLoaded?: () => void;
/**
* Reports emptiness whenever the query settles (incl. cache hits):
* `null` while loading (unknown), otherwise whether the list is empty.
* Lets a parent derive an aggregate empty-state reactively instead of via a
* queryFn side effect, which React Query skips when it serves cache.
*/
onEmptyStateChange?: (isEmpty: boolean | null) => void;
} }
export const InfiniteScrollingCollectionList: React.FC<Props> = ({ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
@@ -45,6 +52,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onPressSeeAll, onPressSeeAll,
enabled = true, enabled = true,
onLoaded, onLoaded,
onEmptyStateChange,
...props ...props
}) => { }) => {
const effectivePageSize = Math.max(1, pageSize); const effectivePageSize = Math.max(1, pageSize);
@@ -103,6 +111,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return deduped; return deduped;
}, [data]); }, [data]);
// Report emptiness on every settle (incl. cache hits). Callback held in a ref
// so an inline parent callback doesn't retrigger the effect each render.
const onEmptyStateChangeRef = useRef(onEmptyStateChange);
onEmptyStateChangeRef.current = onEmptyStateChange;
useEffect(() => {
onEmptyStateChangeRef.current?.(isLoading ? null : allItems.length === 0);
}, [isLoading, allItems.length]);
const snapOffsets = useMemo(() => { const snapOffsets = useMemo(() => {
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px) const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
return allItems.map((_, index) => index * itemWidth); return allItems.map((_, index) => index * itemWidth);

View File

@@ -6,7 +6,7 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -42,6 +42,13 @@ interface Props extends ViewProps {
isFirstSection?: boolean; isFirstSection?: boolean;
onItemFocus?: (item: BaseItemDto) => void; onItemFocus?: (item: BaseItemDto) => void;
parentId?: string; parentId?: string;
/**
* Reports emptiness whenever the query settles (incl. cache hits):
* `null` while loading (unknown), otherwise whether the list is empty.
* Lets a parent derive an aggregate empty-state reactively instead of via a
* queryFn side effect, which React Query skips when it serves cache.
*/
onEmptyStateChange?: (isEmpty: boolean | null) => void;
} }
type Typography = ReturnType<typeof useScaledTVTypography>; type Typography = ReturnType<typeof useScaledTVTypography>;
@@ -123,6 +130,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
isFirstSection = false, isFirstSection = false,
onItemFocus, onItemFocus,
parentId, parentId,
onEmptyStateChange,
...props ...props
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
@@ -182,6 +190,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return deduped; return deduped;
}, [data]); }, [data]);
// Report emptiness on every settle (incl. cache hits). Callback held in a ref
// so an inline parent callback doesn't retrigger the effect each render.
const onEmptyStateChangeRef = useRef(onEmptyStateChange);
onEmptyStateChangeRef.current = onEmptyStateChange;
useEffect(() => {
onEmptyStateChangeRef.current?.(isLoading ? null : allItems.length === 0);
}, [isLoading, allItems.length]);
const itemWidth = const itemWidth =
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;