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
+55
View File
@@ -0,0 +1,55 @@
/**
* Load one keyed thing at a time, however many triggers ask for it.
*
* Calls for the key already loading share that load instead of starting their
* own. A caller that knows the data changed (`fresh` — after "mark watched",
* on reconnect, when a filter flips) must not be answered by a load that may
* predate the change, so it gets exactly one re-run once the current load
* ends, however many such callers there were.
*
* Exists because the series page loaded itself six times on every open —
* `onMount`, a mount-time `$effect`, the reachability effect's first run and
* navigation updates each started a full load — putting about six times a
* dozen requests in flight at once.
*
* TRACES: UR-062 | DR-295
*/
export interface CoalescedLoader {
/** Load `key`. `fresh`: the caller knows the data changed. */
load(key: string, options?: { fresh?: boolean }): Promise<void>;
}
interface InFlight {
key: string;
/** Settles when this load and any re-run it owes have finished. */
done: Promise<void>;
rerun: boolean;
}
export function createCoalescedLoader(run: (key: string) => Promise<void>): CoalescedLoader {
let inFlight: InFlight | null = null;
return {
load(key, options = {}) {
if (inFlight && inFlight.key === key) {
if (options.fresh) inFlight.rerun = true;
return inFlight.done;
}
const entry: InFlight = { key, rerun: false, done: Promise.resolve() };
entry.done = (async () => {
try {
await run(key);
while (entry.rerun) {
entry.rerun = false;
await run(key);
}
} finally {
if (inFlight === entry) inFlight = null;
}
})();
inFlight = entry;
return entry.done;
},
};
}