From 0cac1f8779b8fcd8817debe9dd7df68702a96a42 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Wed, 1 Jul 2026 22:31:15 +0200 Subject: [PATCH] fix(favorites): sync empty-state on cache hits to avoid blank view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/home/Favorites.tsx | 68 +++++++++---------- components/home/Favorites.tv.tsx | 62 ++++++++--------- .../home/InfiniteScrollingCollectionList.tsx | 16 +++++ .../InfiniteScrollingCollectionList.tv.tsx | 18 ++++- 4 files changed, 95 insertions(+), 69 deletions(-) diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx index 9c876c05..5beea9a0 100644 --- a/components/home/Favorites.tsx +++ b/components/home/Favorites.tsx @@ -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; +// `null` = not settled yet (loading/unknown); avoids flashing the empty +// message during a favorites/watchlist switch before the new queries resolve. +type EmptyState = Record; 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({ - 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")} /> setTypeEmpty("Movie", isEmpty)} onPressSeeAll={() => seeAll("Movie", "Movies")} /> setTypeEmpty("Episode", isEmpty)} onPressSeeAll={() => seeAll("Episode", "Episodes")} /> setTypeEmpty("Video", isEmpty)} onPressSeeAll={() => seeAll("Video", "Videos")} /> setTypeEmpty("BoxSet", isEmpty)} onPressSeeAll={() => seeAll("BoxSet", "Boxsets")} /> setTypeEmpty("Playlist", isEmpty)} onPressSeeAll={() => seeAll("Playlist", "Playlists")} /> diff --git a/components/home/Favorites.tv.tsx b/components/home/Favorites.tv.tsx index 6e25e7fa..89356058 100644 --- a/components/home/Favorites.tv.tsx +++ b/components/home/Favorites.tv.tsx @@ -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; +// `null` = not settled yet (loading/unknown); avoids flashing the empty +// message during a favorites/watchlist switch before the new queries resolve. +type EmptyState = Record; export const Favorites = () => { const typography = useScaledTVTypography(); @@ -60,12 +62,12 @@ export const Favorites = () => { const emptyTextKey = `${emptyNamespace}.noData`; const [emptyState, setEmptyState] = useState({ - 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)} /> { hideIfEmpty orientation='vertical' pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)} /> { title={t("favorites.episodes")} hideIfEmpty pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)} /> { title={t("favorites.videos")} hideIfEmpty pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)} /> { title={t("favorites.boxsets")} hideIfEmpty pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)} /> { title={t("favorites.playlists")} hideIfEmpty pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)} /> diff --git a/components/home/InfiniteScrollingCollectionList.tsx b/components/home/InfiniteScrollingCollectionList.tsx index de4c6462..e31b69cc 100644 --- a/components/home/InfiniteScrollingCollectionList.tsx +++ b/components/home/InfiniteScrollingCollectionList.tsx @@ -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 = ({ @@ -45,6 +52,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ onPressSeeAll, enabled = true, onLoaded, + onEmptyStateChange, ...props }) => { const effectivePageSize = Math.max(1, pageSize); @@ -103,6 +111,14 @@ export const InfiniteScrollingCollectionList: React.FC = ({ 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); diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 1db314f2..1e27fae8 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -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; @@ -123,6 +130,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ isFirstSection = false, onItemFocus, parentId, + onEmptyStateChange, ...props }) => { const typography = useScaledTVTypography(); @@ -182,6 +190,14 @@ export const InfiniteScrollingCollectionList: React.FC = ({ 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;