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 { t } from "i18next";
import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { Text, View } from "react-native";
// PNG ASSET
import heart from "@/assets/icons/heart.fill.png";
@@ -23,7 +23,9 @@ type FavoriteTypes =
| "Video"
| "BoxSet"
| "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 {
/** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */
@@ -48,12 +50,12 @@ export const Favorites = ({
const [user] = useAtom(userAtom);
const pageSize = 20;
const [emptyState, setEmptyState] = useState<EmptyState>({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
Series: null,
Movie: null,
Episode: null,
Video: null,
BoxSet: null,
Playlist: null,
});
const fetchFavoritesByType = useCallback(
@@ -76,42 +78,28 @@ export const Favorites = ({
limit: limit,
includeItemTypes: [itemType],
});
const items = 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;
return response.data.Items || [];
},
[api, user, filter],
);
// Reset empty state when the account or active view changes. `filter`
// matters because switching the favorites/watchlist toggle swaps this
// component's props in place (no remount), so stale per-type emptiness
// from the previous view must be cleared until the new queries resolve.
useEffect(() => {
setEmptyState({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user, filter]);
// Emptiness is reported by each list once its query settles (incl. cache
// hits), so it stays correct where a queryFn side effect would go stale.
const setTypeEmpty = useCallback(
(type: FavoriteTypes, isEmpty: boolean | null) =>
setEmptyState((prev) =>
prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty },
),
[],
);
// 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 loadedCategories = Object.values(emptyState);
const categories = Object.values(emptyState);
return (
loadedCategories.length > 0 &&
loadedCategories.every((isEmpty) => isEmpty)
categories.length > 0 && categories.every((isEmpty) => isEmpty === true)
);
};
@@ -191,6 +179,7 @@ export const Favorites = ({
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
onPressSeeAll={() => seeAll("Series", "Series")}
/>
<InfiniteScrollingCollectionList
@@ -200,6 +189,7 @@ export const Favorites = ({
hideIfEmpty
orientation='vertical'
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)}
onPressSeeAll={() => seeAll("Movie", "Movies")}
/>
<InfiniteScrollingCollectionList
@@ -208,6 +198,7 @@ export const Favorites = ({
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)}
onPressSeeAll={() => seeAll("Episode", "Episodes")}
/>
<InfiniteScrollingCollectionList
@@ -216,6 +207,7 @@ export const Favorites = ({
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)}
onPressSeeAll={() => seeAll("Video", "Videos")}
/>
<InfiniteScrollingCollectionList
@@ -224,6 +216,7 @@ export const Favorites = ({
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)}
onPressSeeAll={() => seeAll("BoxSet", "Boxsets")}
/>
<InfiniteScrollingCollectionList
@@ -232,6 +225,7 @@ export const Favorites = ({
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)}
onPressSeeAll={() => seeAll("Playlist", "Playlists")}
/>
</View>

View File

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

View File

@@ -32,6 +32,13 @@ interface Props extends ViewProps {
onPressSeeAll?: () => void;
enabled?: boolean;
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> = ({
@@ -45,6 +52,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onPressSeeAll,
enabled = true,
onLoaded,
onEmptyStateChange,
...props
}) => {
const effectivePageSize = Math.max(1, pageSize);
@@ -103,6 +111,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return deduped;
}, [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 itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
return allItems.map((_, index) => index * itemWidth);

View File

@@ -6,7 +6,7 @@ import {
useInfiniteQuery,
} from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -42,6 +42,13 @@ interface Props extends ViewProps {
isFirstSection?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
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>;
@@ -123,6 +130,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
isFirstSection = false,
onItemFocus,
parentId,
onEmptyStateChange,
...props
}) => {
const typography = useScaledTVTypography();
@@ -182,6 +190,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return deduped;
}, [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 =
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;