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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user