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
+24
View File
@@ -666,6 +666,14 @@ render behind it. There is no measurement and no reserved padding. If you
restructure the shell, preserve the scroll containment — reintroducing padding
math reintroduces the bug.
**The route renders in exactly one element.** `+layout.svelte` switches the
wrapper's *classes* between the shell scroller and the plain clipped box that
layout-owning routes (library, settings, player) get — it must not switch
between two branches that each render `children`. The page store that decides
the mode can update a flush after the new route renders, so two branches
mounted a page under one and then remounted it under the other: every
navigation between the two kinds of route loaded the page twice (DR-295).
### AccountMenu
One component for both breakpoints, anchored to the username/avatar (a real
@@ -719,6 +727,22 @@ hero button labelled `Resume S2E4` / `Play S1E1`.
A season is not a destination: `/library/<seasonId>` redirects to its series
(DR-103). Video library routes collapse to one per library (DR-105).
**The episode list never waits for Next Up or resume.** `resolve_series_view`
(`series_progress.rs`, `with_hints`) returns as soon as the episodes are in;
Next Up and resume are used if they have answered by then and dropped if not,
and `pick_current_episode` falls back to the episodes' own watch state. They
only refine which episode is current, and waiting for them held the list for
the server's 23 s although every episode was cached. Next Up is cache-first
like every other query (03-data-flow).
**One load per item, however many triggers.** The detail page loads through
`createCoalescedLoader` (`utils/coalescedLoader.ts`): calls for the item
already loading share that load, and callers that know the data changed
(`fresh`: reconnect, filter change, mark watched, clear history) get exactly
one re-run after it. `onMount`, a mount-time `$effect`, the reachability
effect's first run and the double mount above used to each start a full load —
six per open, about seventy requests in flight.
`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted
from the component because it had three distinct bugs that markup made
untestable: the strip collapsing to just the current episode while real siblings