perf(series): the episode list no longer waits on the server

Opening Frasier on a Fairphone took ~5 s to render the episode list
although every episode was cached. Three causes:

- resolve_series_view waited for Next Up and resume before returning the
  episodes, and Next Up was server-first. The episode list now returns as
  soon as the episodes are in (with_hints); hints that have answered are
  used, late ones dropped, and the picker falls back to local watch state.
  Next Up is cache-first like every other query.
- The page loaded itself six times per open: onMount plus a mount-time
  $effect, the reachability effect's first run posing as a reconnect, and
  a double mount. All triggers now share one coalesced load per item
  (createCoalescedLoader); refresh triggers get one re-run after it.
- The root layout rendered the route in two branches that each rendered
  children; the page store deciding between them updates a flush late, so
  navigating Search -> library page mounted the page twice. One element
  now renders the route and only its classes change.

On the device: one load per open, seasons from cache in 14 ms, episodes
and the Resume button up in under a second (was ~5 s).
This commit is contained in:
2026-09-24 04:45:44 +02:00
parent c0545a245f
commit a676f4aba8
7 changed files with 334 additions and 67 deletions
+26 -16
View File
@@ -1000,22 +1000,32 @@ impl MediaRepository for HybridRepository {
series_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
// Next Up is dynamic, so the server's answer is preferred — but when the
// server cannot answer, the cache's stands in. It used to be server-only,
// and the TV landing page loads Next Up in one `Promise.all` with its
// other rows, so offline that single failure blanked the whole page with
// Continue Watching and Latest sitting in the cache (DR-294).
// TRACES: UR-002 | DR-294 | UT-261
match self.online.get_next_up_episodes(series_id, limit).await {
Ok(items) => Ok(items.without_excluded()),
Err(e) => {
debug!("[HybridRepo] Next Up from server failed ({e}); using the cache");
self.offline
.get_next_up_episodes(series_id, limit)
.await
.map(ExcludeHidden::without_excluded)
}
}
// Cache-first like every other query: the local answer is computed
// from the same watch state the cache refreshes from the server in the
// background (`user_data_mirror_query`), and whichever answers first
// with content wins. It used to wait for the server outright, which
// held the series page's episode list for 2-3 s on a phone. An empty
// local answer still defers to the server, and a failed server falls
// back to the cache — offline, the TV page's Next Up row must not blank
// the page (DR-294).
// TRACES: UR-002 | DR-013, DR-294 | UT-261
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
let series = series_id.map(str::to_string);
let series_for_server = series.clone();
let cache_future =
Self::cache_leg(
async move { offline.get_next_up_episodes(series.as_deref(), limit).await },
)
.await;
let server_future = async move {
online
.get_next_up_episodes(series_for_server.as_deref(), limit)
.await
};
Self::parallel_race(cache_future, server_future).await
}
async fn get_recently_played_audio(
+94 -13
View File
@@ -287,24 +287,56 @@ pub async fn resolve_series_view(
repo: &dyn MediaRepository,
series_id: &str,
) -> Result<SeriesView, RepoError> {
let (episodes, next_up, resume) = futures_util::join!(
fetch_series_episodes(repo, series_id),
async {
repo.get_next_up_episodes(Some(series_id), Some(1))
.await
.unwrap_or_default()
},
async {
repo.get_resume_items(Some(series_id), Some(10))
.await
.unwrap_or_default()
},
);
let (episodes, (next_up, resume)) = with_hints(fetch_series_episodes(repo, series_id), async {
futures_util::join!(
async {
repo.get_next_up_episodes(Some(series_id), Some(1))
.await
.unwrap_or_default()
},
async {
repo.get_resume_items(Some(series_id), Some(10))
.await
.unwrap_or_default()
},
)
})
.await;
let episodes = episodes?;
let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
Ok(SeriesView { episodes, current })
}
/// Run `primary` and `hints` together, but never hold `primary` back for
/// `hints`: once `primary` is ready, the hints are taken if they have already
/// answered and dropped (`H::default()`) if not.
///
/// For the series view the primary is the episode list and the hints are Next
/// Up and resume, which only refine which episode is "current" — and the
/// picker falls back to the episodes' own watch state without them. Waiting
/// for them made the episode list wait for the server (2-3 s on a phone)
/// although every episode was in the cache in 50 ms. The cache legs of the
/// hints usually answer before the episodes do, so they are normally kept.
///
/// TRACES: UR-062 | DR-101, DR-295
async fn with_hints<P, H>(
primary: impl std::future::Future<Output = P>,
hints: impl std::future::Future<Output = H>,
) -> (P, H)
where
H: Default,
{
use futures_util::future::{select, Either};
use futures_util::FutureExt;
let primary = std::pin::pin!(primary);
let hints = std::pin::pin!(hints);
match select(primary, hints).await {
Either::Left((primary, hints)) => (primary, hints.now_or_never().unwrap_or_default()),
Either::Right((hints, primary)) => (primary.await, hints),
}
}
/// Resolve the current episode, fetching everything the policy needs.
///
/// Next Up and resume are best-effort: offline they fail or come back empty, and
@@ -404,6 +436,55 @@ mod tests {
assert_eq!(episodes.len(), 9, "every season but the failing one");
}
/// The episode list must not wait for Next Up or resume.
///
/// The series page rendered its episodes only once Next Up had come back
/// from the server — 2-3 s on a phone while the page's other requests were
/// in flight — although every episode was in the cache after 50 ms. Those
/// two only refine which episode is "current", and the picker falls back
/// to the episodes' own watch state without them.
///
/// TRACES: UR-062 | DR-101, DR-295
#[tokio::test]
async fn the_episode_list_does_not_wait_for_slow_hints() {
let started = std::time::Instant::now();
let (episodes, hints) = with_hints(
async {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
vec![episode("e1", 1, 1)]
},
async {
tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
vec![episode("from-server", 1, 2)]
},
)
.await;
let elapsed = started.elapsed();
assert_eq!(episodes.len(), 1);
assert!(hints.is_empty(), "late hints are dropped, not waited for");
assert!(
elapsed < std::time::Duration::from_millis(500),
"the episode list waited {elapsed:?} for Next Up / resume"
);
}
/// Hints that are already in (a cache answer) are used.
///
/// TRACES: UR-062 | DR-101, DR-295
#[tokio::test]
async fn hints_that_answer_first_are_kept() {
let (_, hints) = with_hints(
async {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
vec![episode("e1", 1, 1)]
},
async { vec![episode("cached", 1, 2)] },
)
.await;
assert_eq!(hints.len(), 1);
}
fn watched(mut item: MediaItem) -> MediaItem {
item.user_data = Some(UserData {
is_played: Some(true),