From 40cb8fed9e043c2ebbe9f860f31d027c3bbc1f17 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 30 Jun 2026 10:30:36 +0200 Subject: [PATCH] fix(tv): keep "See All" detail stable and Back returning to library list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home "See All" detail opens in the libraries tab. When that tab already had its index in the stack (e.g. after a previous See All + Back), the stack became [index, detail], which the native bottom tab reliably auto-popped back to index — the detail "bounced" to the library list ~0.5s after opening. - Pass a `fromSeeAll` flag from the See All button. - On the detail page, collapse the libraries stack to [detail] when deep-linked on top of the index, so the native tab has nothing to auto-pop to. - Intercept TV Back (while focused) to route to the library list so the user can switch libraries, instead of exiting the tab to home. Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd --- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 45 +++++++++++++++++-- .../InfiniteScrollingCollectionList.tv.tsx | 10 +++-- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 9a2239f6..cdb1a181 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -12,11 +12,16 @@ import { import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useLocalSearchParams, useNavigation } from "expo-router"; +import { + useFocusEffect, + useLocalSearchParams, + useNavigation, +} from "expo-router"; import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { + BackHandler, FlatList, Platform, ScrollView, @@ -80,8 +85,9 @@ const Page = () => { sortBy?: string; sortOrder?: string; filterBy?: string; + fromSeeAll?: string; }; - const { libraryId } = searchParams; + const { libraryId, fromSeeAll } = searchParams; const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); @@ -112,6 +118,22 @@ const Page = () => { const { t } = useTranslation(); const router = useRouter(); const { showOptions } = useTVOptionModal(); + + // When this library detail was opened from the home "See All" button, its + // libraries stack is just [detail], so the default TV Back would exit to home. + // Intercept Back (scoped to while this screen is focused via useFocusEffect) and + // route to the library list instead, so the user can switch libraries. Normal + // entries from the list keep their native pop-to-list behavior. + useFocusEffect( + useCallback(() => { + if (!Platform.isTV || fromSeeAll !== "true") return; + const sub = BackHandler.addEventListener("hardwareBackPress", () => { + router.replace("/(auth)/(tabs)/(libraries)"); + return true; + }); + return () => sub.remove(); + }, [fromSeeAll, router]), + ); const { showItemActions } = useTVItemActionModal(); // TV Filter queries @@ -269,6 +291,23 @@ const Page = () => { }); }, [library]); + // If this See-All detail was deep-linked on top of the libraries index, collapse + // the libraries stack to just this screen. Otherwise the stack is [index, detail], + // which the native bottom tab reliably auto-pops back to the index (the detail + // "bounces" to the library list ~0.5s after opening). With [detail] alone it stays + // put, and Back is handled explicitly by the fromSeeAll interceptor above. + const didCollapseRef = useRef(false); + useEffect(() => { + if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current) + return; + const state = navigation.getState(); + if (state?.routes && state.routes.length > 1) { + didCollapseRef.current = true; + const top = state.routes[state.routes.length - 1]; + navigation.reset({ index: 0, routes: [top] } as any); + } + }, [navigation, fromSeeAll]); + const fetchItems = useCallback( async ({ pageParam, diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 346e9c0d..6a25535c 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -201,16 +201,18 @@ export const InfiniteScrollingCollectionList: React.FC = ({ const handleSeeAllPress = useCallback(() => { if (!parentId) return; - // Use the relative "/[libraryId]" pathname (same as getItemNavigation) so the - // library detail is pushed within the current tab's stack. The fully-qualified - // "/(auth)/(tabs)/(libraries)/[libraryId]" path is a cross-tab navigation that - // only switches to the libraries tab and drops the nested screen push. + // Navigate into the library detail (lives in the libraries tab) sorted by most + // recently added. The `fromSeeAll` flag tells the detail page to (a) collapse + // the libraries stack so the native tab can't auto-pop it back to the list, and + // (b) intercept Back to route to the library list so the user can switch + // libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx. router.push({ pathname: "/[libraryId]", params: { libraryId: parentId, sortBy: SortByOption.DateCreated, sortOrder: SortOrderOption.Descending, + fromSeeAll: "true", }, } as any); }, [router, parentId]);