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