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
+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),