diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index 802b8a1d..f3a5e7f7 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -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], ); diff --git a/frontend/src/components/PageScroller.tsx b/frontend/src/components/PageScroller.tsx index d73fcc78..2789c0a7 100644 --- a/frontend/src/components/PageScroller.tsx +++ b/frontend/src/components/PageScroller.tsx @@ -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(); // 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(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 (
{ + 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); + }); +}); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 7bb6f2b7..fc82face 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -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
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( 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(), };