feat(nav): restore scroll position per history entry, open forward views at the top

The app scrolls one persistent <main> (PageScroller); its saved offsets were keyed by a
content `scrollKey` that is the same for a Plex page's Discover and grid views, so opening
a Browse-by-genre tile inherited the scrolled-down Discover offset (landing mid-page) and
Back couldn't tell the two views apart.

Tie scroll memory to the history entry instead: every pushState that opens a main-content
view stamps a fresh `sfScroll` id into history.state (setPage, Plex sub-view open); the
initial/legacy entries get one lazily via replaceState. PageScroller now saves/restores per
`${entryId}:${scrollKey}` — the entry id makes Back restore exactly where you left an entry
and a forward navigation (new id) open at the top, while the scrollKey still resets a new
feed filter to the top within the same entry. A small nav bus lets a Plex sub-view open
(which doesn't re-render the scroller) trigger the restore; `history.scrollRestoration` is
set to manual since the browser can't see our inner scroller.

Verified live: genre tile opens the grid at the top and Back returns to the Discover scroll;
feed→plex→Back restores the feed scroll; a feed filter change still resets to the top.

Gate: tsc/lint/knip/prettier clean, 5 new nav-id unit tests, full vitest green.
This commit is contained in:
2026-08-23 02:55:59 +02:00
parent dddaeb9a38
commit 1e5232f47a
4 changed files with 147 additions and 14 deletions
+56
View File
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
//
// The per-history-entry scroll-id helpers + nav bus that back PageScroller's scroll restoration:
// a fresh id per new view (so a forward navigation opens at the top), a stable id per entry (so Back
// restores it), and a bus so a sub-view open that doesn't re-render the scroller still triggers it.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { nextNavId, ensureNavId, peekNavId, subscribeNav, notifyNav } from "./history";
beforeEach(() => {
window.history.replaceState(null, "");
});
describe("nav scroll ids", () => {
it("nextNavId hands out strictly increasing ids", () => {
const a = nextNavId();
const b = nextNavId();
expect(b).toBeGreaterThan(a);
});
it("ensureNavId assigns an id to a stateless entry, then is idempotent", () => {
expect(peekNavId()).toBe(0); // no id yet
const id = ensureNavId();
expect(id).toBeGreaterThan(0);
expect(peekNavId()).toBe(id);
expect(ensureNavId()).toBe(id); // does not reassign
expect((window.history.state as { sfScroll?: number }).sfScroll).toBe(id);
});
it("ensureNavId preserves other history.state keys", () => {
window.history.replaceState({ sfPage: "plex" }, "");
ensureNavId();
expect((window.history.state as { sfPage?: string }).sfPage).toBe("plex");
});
it("a pushed entry keeps its own id (so Back restores the right one)", () => {
const first = ensureNavId();
window.history.pushState({ sfScroll: nextNavId() }, "");
const second = peekNavId();
expect(second).not.toBe(first);
window.history.back();
// jsdom doesn't truly pop synchronously, but the ids are distinct per entry — the invariant we need.
expect(second).toBeGreaterThan(first);
});
it("notifyNav fires subscribers until they unsubscribe", () => {
const cb = vi.fn();
const unsub = subscribeNav(cb);
notifyNav();
expect(cb).toHaveBeenCalledTimes(1);
notifyNav();
expect(cb).toHaveBeenCalledTimes(2);
unsub();
notifyNav();
expect(cb).toHaveBeenCalledTimes(2);
});
});