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 -23
View File
@@ -73,7 +73,8 @@
// a new page inherits the previous page's offset. Must be registered here at
// init, alongside the tracker above, for the same reason. (DR-156)
let shellScroller = $state<HTMLElement>();
useScrollRestore(() => shellScroller, "shell");
// Owned routes scroll inside their own column; the shell box does not.
useScrollRestore(() => (routeOwnsLayout ? undefined : shellScroller), "shell");
// Layout-shell visibility rules live in one pure, unit-tested module
// ($lib/utils/layoutShell) so they can't drift per route/platform.
@@ -357,29 +358,29 @@
scrolling internally. All other top-level pages render directly here, so
this wrapper must scroll and reserve the fixed bottom UI's measured
height so the mini player / bottom nav never overlap the last rows. -->
{#if routeOwnsLayout}
<!-- These routes own their own full-height flex column (header + scroller
+ their own in-flow BottomUi), so the root just clips and steps back. -->
<div class="flex-1 overflow-hidden">
{@render children()}
</div>
{:else}
<!-- Shared header (account menu, desktop nav) as a flex-shrink-0 sibling
above the scroller, so it never eats into the scroller's bounds. -->
{#if showGlobalHeader}
<AppHeader />
{/if}
<!-- Scroller is flex-1/min-h-0; the in-flow BottomUi below is a flex
sibling, so the list is physically bounded above it and can never
render behind it. No measurement, no reserved padding. -->
<div
bind:this={shellScroller}
class="flex-1 overflow-y-auto min-h-0"
style="overscroll-behavior: contain"
>
{@render children()}
</div>
<!-- Shared header (account menu, desktop nav) as a flex-shrink-0 sibling
above the scroller, so it never eats into the scroller's bounds. -->
{#if !routeOwnsLayout && showGlobalHeader}
<AppHeader />
{/if}
<!-- ONE element renders the route, whatever the layout mode; only its
classes change. Routes that own their full-height column (header +
scroller + their own in-flow BottomUi) get a plain clipped box; every
other route gets the shell scroller (flex-1/min-h-0, bounded above the
in-flow BottomUi, so no measurement or reserved padding).
This used to be two branches, each rendering `children`. The page
store that decides the mode can update a flush after the new route
renders, so navigating between the two kinds of route (Search → a
library page) mounted the page under one branch and then *remounted*
it under the other — every load it started, twice (DR-295). -->
<div
bind:this={shellScroller}
class={routeOwnsLayout ? "flex-1 overflow-hidden" : "flex-1 overflow-y-auto min-h-0"}
style={routeOwnsLayout ? undefined : "overscroll-behavior: contain"}
>
{@render children()}
</div>
<!-- Re-authentication modal -->
<ReauthModal isOpen={$needsReauth} />