From fb73309433c8258140adaa1ff24923f5cc490c8d Mon Sep 17 00:00:00 2001 From: Gian <58370608+glpecile@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:58:19 -0300 Subject: [PATCH] perf(episode): render before the seasons list resolves --- ...lview-drops-an-offset-set-before-layout.md | 7 ++++ src/features/episode-details/screen/index.tsx | 35 ++++++++++++------- src/features/episode-details/use-episode.ts | 3 -- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/docs/solutions/android-scrollview-drops-an-offset-set-before-layout.md b/docs/solutions/android-scrollview-drops-an-offset-set-before-layout.md index 8867a409..0a1445d2 100644 --- a/docs/solutions/android-scrollview-drops-an-offset-set-before-layout.md +++ b/docs/solutions/android-scrollview-drops-an-offset-set-before-layout.md @@ -56,6 +56,13 @@ view already is (a swipe's momentum end, a jump) or on resize, and the prop diff never sends it mid-gesture or mid-tap. react-native-web ignores the prop; web keeps its layout-effect jump, which has no race there. +The episode pager can first render a single episode while its seasons list is +still loading. On Android, when the list arrives, its scroll view changes key so the full +sequence and the opened episode's offset mount together. Updating the existing +view's offset alongside a wider content container has the same Android race. +This one-time remount resets vertical scroll and expanded content if the user +has already started reading. iOS keeps the same subtree and its reading state. + A settle more than one page from the selected cour is still refused and jumped back, so the URL can't follow a position nobody chose. diff --git a/src/features/episode-details/screen/index.tsx b/src/features/episode-details/screen/index.tsx index 4faa32bf..ec68199f 100644 --- a/src/features/episode-details/screen/index.tsx +++ b/src/features/episode-details/screen/index.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'expo-router'; import { LinearGradient } from 'expo-linear-gradient'; -import { useRef, useState } from 'react'; -import { type ScrollViewInstance, Text, useWindowDimensions, View } from 'react-native'; +import { useDeferredValue, useRef, useState } from 'react'; +import { Platform, type ScrollViewInstance, Text, useWindowDimensions, View } from 'react-native'; import { FadeOut, useReducedMotion } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; // oxlint-disable-next-line no-restricted-imports -- one composed colour, see the call site. @@ -61,7 +61,8 @@ const BOUNDARY_TOLERANCE = 2; */ export function EpisodeScreen({ item, season, number, onBack }: EpisodeScreenProps) { const view = useEpisode(item, season, number); - if (view.isLoading || view.orderLoading) { + // A long-running show's seasons list can arrive well after this episode. + if (view.isLoading) { return ; } return ( @@ -69,7 +70,6 @@ export function EpisodeScreen({ item, season, number, onBack }: EpisodeScreenPro ); } -/** Mounted once the order is known, so `settled` starts on the opened episode. */ function EpisodePager({ item, season, @@ -89,10 +89,19 @@ function EpisodePager({ ); const order = found === -1 ? [{ season, number }] : fullOrder; const index = Math.max(found, 0); - // The page the scroll last rested on: what the mounted window is centred on - // and where `contentOffset` points. Not `index` — that moves when a button - // step starts, and a changed `contentOffset` would cut its scroll short. - const [settled, setSettled] = useState(index); + // The episode the scroll last rested on: what the mounted window is centred + // on and where `contentOffset` points. Not `index` — that moves when a button + // step starts, and a changed `contentOffset` would cut its scroll short. An + // episode rather than a page, so it stays put when the order loads around it. + const [settledEpisode, setSettledEpisode] = useState({ season, number }); + const settled = Math.max( + order.findIndex( + (entry) => entry.season === settledEpisode.season && entry.number === settledEpisode.number, + ), + 0, + ); + // Neighbours mount in a deferred pass, so the opened page commits alone first. + const reach = useDeferredValue(1, 0); function commit(target: EpisodeRef) { router.setParams({ episode: [String(target.season), String(target.number)] }); @@ -103,7 +112,7 @@ function EpisodePager({ if (Math.abs(x - page * width) > BOUNDARY_TOLERANCE) return; const landed = order[page]; if (landed == null) return; - setSettled(page); + setSettledEpisode(landed); if (page === index) return; haptics.selection(); commit(landed); @@ -116,12 +125,12 @@ function EpisodePager({ if (page === -1) return; scroller.current?.scrollTo({ x: page * width, animated: !reduceMotion }); // A jump fires no momentum event. - if (reduceMotion) setSettled(page); + if (reduceMotion) setSettledEpisode(target); commit(target); } - const first = Math.max(Math.min(settled - 1, index), 0); - const last = Math.min(Math.max(settled + 1, index), order.length - 1); + const first = Math.max(Math.min(settled - reach, index), 0); + const last = Math.min(Math.max(settled + reach, index), order.length - 1); return ( settleAt(event.nativeEvent.contentOffset.x)} pagingEnabled ref={scroller} diff --git a/src/features/episode-details/use-episode.ts b/src/features/episode-details/use-episode.ts index 1dfe0d48..583a7541 100644 --- a/src/features/episode-details/use-episode.ts +++ b/src/features/episode-details/use-episode.ts @@ -23,8 +23,6 @@ export interface EpisodeView { tmdbId: number | undefined; /** The sequence this episode sits in (`episodeOrder`); empty until the seasons list loads. */ order: EpisodeRef[]; - /** The seasons list is still in flight, so `order` is not final. */ - orderLoading: boolean; } /** @@ -66,6 +64,5 @@ export function useEpisode( isLoading: episode == null && (seasons.isPending || tmdb.isPending), tmdbId, order: seasons.data == null ? [] : episodeOrder(seasons.data, season), - orderLoading: seasons.isLoading, }; }