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,
};
}