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