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
@@ -14,6 +14,7 @@ import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { setNavigator } from "../lib/nav";
import { nextNavId, notifyNav } from "../lib/history";
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
@@ -128,10 +129,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by
// FeedFiltersProvider) are left intact.
window.history.pushState(
{ sfPage: next },
{ sfPage: next, sfScroll: nextNavId() },
"",
urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }),
);
notifyNav();
},
[markDirty],
);
+42 -12
View File
@@ -1,9 +1,12 @@
import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
import { useScrollFade } from "../lib/useScrollFade";
import { ensureNavId, peekNavId, subscribeNav } from "../lib/history";
// Per-view saved scroll offsets. Module-level so a view's position survives its scroller
// unmounting — opening a channel page swaps in a different shell/scroller — and is restored when
// the same view returns. Keyed by the shell's `scrollKey`.
// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
// exact history entry, so Back restores where you left it and a forward navigation (a new entry) opens
// at the top; the scrollKey ties it to the logical content, so a new feed filter still starts at the
// top WITHIN the same entry. Module-level so a view's position survives its scroller unmounting
// (opening a channel page swaps in a different shell/scroller) and is restored when it returns.
const savedScroll = new Map<string, number>();
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
@@ -85,23 +88,50 @@ export default function PageScroller({
[fadeRef],
);
// Live scrollKey for the event/nav-bus callbacks (which are bound once, not per scrollKey).
const scrollKeyRef = useRef(scrollKey);
scrollKeyRef.current = scrollKey;
const lastComposite = useRef<string | null>(null);
const cancelRef = useRef<() => void>(() => {});
// Restore (or reset to top) whenever the (history entry + content) identity changes — a page/sub-view
// navigation, a Back/Forward, or a feed filter change. Idempotent: a no-op when the composite is
// unchanged, so it's safe to call from several triggers.
const resolve = useCallback(() => {
const el = elRef.current;
if (!el || scrollKeyRef.current == null) return;
const key = `${ensureNavId()}:${scrollKeyRef.current}`;
if (key === lastComposite.current) return;
lastComposite.current = key;
cancelRef.current();
cancelRef.current = restoreScroll(el, savedScroll.get(key) ?? 0);
}, []);
// Trigger 1: mount + a content-key change (page switch, feed filter) — both re-render this component.
useLayoutEffect(() => {
resolve();
}, [scrollKey, resolve]);
// Trigger 2: Back/Forward (popstate) and a Plex sub-view open (nav bus) — neither necessarily
// re-renders this component. Also records the position continuously (not at unmount): the scroller's
// scrollTop is unreliable by the time an unmount cleanup runs, so a save-on-scroll survives teardown.
useLayoutEffect(() => {
const el = elRef.current;
if (!el || scrollKey == null) return;
const key = scrollKey;
const want = savedScroll.get(key) ?? 0;
const cancelRestore = restoreScroll(el, want);
// Record position continuously (not at unmount): the scroller's scrollTop is unreliable by the
// time an unmount cleanup runs, and a save-on-scroll survives whatever tears down last.
if (!el) return;
const onScroll = () => {
savedScroll.set(key, el.scrollTop);
if (scrollKeyRef.current == null) return;
savedScroll.set(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
};
el.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("popstate", resolve);
const unsubNav = subscribeNav(resolve);
return () => {
cancelRestore();
cancelRef.current();
el.removeEventListener("scroll", onScroll);
window.removeEventListener("popstate", resolve);
unsubNav();
};
}, [scrollKey]);
}, [resolve]);
return (
<main
+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);
});
});
+46 -1
View File
@@ -12,6 +12,44 @@
import { useEffect, useRef, useState } from "react";
import { urlWithParams } from "./urlState";
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
// Every navigation entry carries a stable numeric id in history.state.sfScroll. PageScroller
// remembers scroll offsets per (entry id + view key) so Back restores exactly where the user left
// an entry, while a forward navigation (a new id) starts at the top. We drive scroll ourselves, so
// the browser's own window-scroll restoration (which can't see our inner <main> scroller) is off.
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual";
}
let navCounter = 0;
/** A fresh entry id — stamp it into the state of every pushState that opens a new main-content view. */
export function nextNavId(): number {
return ++navCounter;
}
/** The current entry's scroll id, assigning one (via replaceState) to entries that predate this — the
* initial load, or an entry pushed without one — so the id survives Back/Forward. */
export function ensureNavId(): number {
const st = (window.history.state ?? {}) as { sfScroll?: number };
if (typeof st.sfScroll === "number") return st.sfScroll;
const id = nextNavId();
window.history.replaceState({ ...st, sfScroll: id }, "");
return id;
}
/** Read the current entry's scroll id without assigning one (0 if none). */
export function peekNavId(): number {
const st = window.history.state as { sfScroll?: number } | null;
return typeof st?.sfScroll === "number" ? st.sfScroll : 0;
}
const navListeners = new Set<() => void>();
/** Fire after a pushState that opens a new view but does NOT re-render PageScroller (a Plex sub-view
* open): lets the scroller run its restore/reset without waiting on a React render it won't get. */
export function notifyNav(): void {
navListeners.forEach((cb) => cb());
}
export function subscribeNav(cb: () => void): () => void {
navListeners.add(cb);
return () => navListeners.delete(cb);
}
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
@@ -61,7 +99,14 @@ export function useHistorySubview<T>(
open: (next: T) => {
setView(next);
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
window.history.pushState({ ...window.history.state, _sub: next }, "", href);
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
window.history.pushState(
{ ...window.history.state, _sub: next, sfScroll: nextNavId() },
"",
href,
);
notifyNav();
},
back: () => window.history.back(),
};