import { useCallback, useEffectEvent, useLayoutEffect, useRef, useState, type ReactNode, } from "react"; import { useScrollFade } from "../../lib/useScrollFade"; import { FOCUS_REST_ATTRS, useFocusKeeper } from "../../lib/useFocusKeeper"; import { StickyTopProvider } from "./stickyTop"; import { useBackToTopShown, useCornerLift } from "./backToTopState"; import { CONTENT_ID } from "./landmarks"; import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history"; // 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(); // The key embeds an ever-increasing history-entry id, so without a bound this map would grow one // entry per scrolled navigation for the life of the session. Cap it with a small LRU (Map preserves // insertion order): re-insert on write to mark most-recently-used, evict the oldest past the cap. const SCROLL_MEMORY_CAP = 200; function rememberScroll(key: string, top: number): void { savedScroll.delete(key); savedScroll.set(key, top); if (savedScroll.size > SCROLL_MEMORY_CAP) { const oldest = savedScroll.keys().next().value; if (oldest !== undefined) savedScroll.delete(oldest); } } // Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame // or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise // clamp short. Re-apply over a few frames until it sticks (or the content is simply shorter now). // A spot the page can no longer reach — the last stretch of a page scrolled to its very end, whose // Back-to-top spacer only shows once scrolled — goes as far as the page does: it used to be left at // the top. Only a spacer's worth short, and only once the height has held still, is // that answer early; a list still loading is further short than that, and gets the full wait. And // landing there is not the end of it: that scroll is what shows Back to top, whose spacer then // makes the spot reachable, so the loop watches a few frames more and finishes the way. // // The reader scrolling meanwhile — a wheel, a finger, a key, the scrollbar — takes the page over: // the restore stops where they are, instead of pulling them back or dropping them at the end. const SETTLED_FRAMES = 10; const SPACER_REM = 6; function restoreScroll(el: HTMLElement, top: number): () => void { if (top <= 0) { el.scrollTop = 0; return () => {}; } let raf = 0; let frames = 0; let still = 0; let lastMax = -1; let landedAt = -1; let cancelled = false; const stop = () => { cancelled = true; cancelAnimationFrame(raf); el.removeEventListener("wheel", stop); el.removeEventListener("touchstart", stop); el.removeEventListener("pointerdown", stop); document.removeEventListener("keydown", stop, true); }; el.addEventListener("wheel", stop, { passive: true }); el.addEventListener("touchstart", stop, { passive: true }); el.addEventListener("pointerdown", stop); document.addEventListener("keydown", stop, true); const tick = () => { if (cancelled) return; const max = el.scrollHeight - el.clientHeight; if (max >= top) el.scrollTop = top; if (el.scrollTop >= top - 1) return stop(); // reached if (landedAt >= 0) { if (frames - landedAt > SETTLED_FRAMES) return stop(); // no spacer came: the end it is } else { still = max === lastMax ? still + 1 : 0; lastMax = max; const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; const nearEnd = max > 0 && top - max <= SPACER_REM * rem; if ((nearEnd && still >= SETTLED_FRAMES) || frames > 90) { el.scrollTop = max; // as near as the page reaches now landedAt = frames; } } frames++; raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return stop; } // The page's scroll container — the ONE scroller in the app, owned by PageShell. The feed, the // channel page and Plex browse all scroll inside it rather than owning a scroller of their own, // which is why the edge-fade and the scroll restore/reset policy live here once instead of three // times. // // Two invariants this element carries, both learned the hard way: // // - `scrollbar-gutter: stable` is load-bearing, not cosmetic. The feed derives its column count // from this element's clientWidth and its thumbnails are aspect-ratio boxes, so when the content // lands within a scrollbar's width of the viewport height the two feed each other: scrollbar // appears -> 15px narrower -> shorter thumbs -> content fits -> scrollbar goes -> wider -> // taller -> repeat, every frame. Reserving the gutter keeps clientWidth constant and breaks the // loop. On the channel page it also stops the header/logo/buttons shifting sideways when you // switch from the tall Videos tab to the short About tab. // - Consequently the bar STAYS visible here: `.no-scrollbar` (scrollbar-width: none) would zero // the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the // fade next to its scrollbar, not instead of it. // // Scroll restore/reset policy (`scrollKey` identifies the logical view): // - returning to a view with the SAME key — feed → open a channel → Back, feed filters unchanged — // restores where you were (bug 1). The channel page is a different scroller, so the feed's // scroller unmounts and remounts; the saved offset survives in `savedScroll` and is re-applied. // - a NEW key — the feed's filters changed, i.e. a fresh result set — starts at the top (bug 3). // The offset is recorded on every scroll rather than at unmount: by the time an unmount cleanup // runs the scroller's scrollTop has already been zeroed by the teardown, so a cleanup-time read // saves 0 and the restore is lost. Recording live sidesteps that (and StrictMode's synthetic // unmount, which would otherwise persist a 0 too). // // The fade state lives in here rather than in App so that a flip re-renders this component alone — // `children` arrives as an already-built element, so React skips the page subtree underneath. export default function PageScroller({ children, scrollKey, }: { children: ReactNode; scrollKey?: string; }) { // How many sticky elements sit at the scroller's top right now (see ./stickyTop): the top fade is // off while any is mounted — a table layout — and back on the moment none is (a card layout). const [stickyTops, setStickyTops] = useState(0); const registerStickyTop = useCallback(() => { setStickyTops((n) => n + 1); return () => setStickyTops((n) => n - 1); }, []); const { ref: fadeRef, style: fadeStyle } = useScrollFade(20, stickyTops === 0); const fabUp = useBackToTopShown(); const cornerLift = useCornerLift(); const elRef = useRef(null); // Stable identity: an inline ref callback changes every render, which makes React detach and // re-attach the ref each commit — and since fadeRef is a state setter, that re-attach loops it // into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding // once here is safe. const setRef = useCallback( (node: HTMLElement | null) => { elRef.current = node; fadeRef(node); }, [fadeRef], ); // A focused control removed by its own action (a row gone after Approve, a toolbar after Clear // all) leaves the focus in its slot rather than on — see useFocusKeeper. useFocusKeeper(elRef); 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. An effect event: the listeners below are // bound once, not per scrollKey, and still read the live one. const resolve = useEffectEvent(() => { const el = elRef.current; if (!el || scrollKey == null) return; const key = `${ensureNavId()}:${scrollKey}`; if (key === lastComposite.current) return; lastComposite.current = key; cancelRef.current(); cancelRef.current = restoreScroll(el, savedScroll.get(key) ?? 0); }); const remember = useEffectEvent((el: HTMLElement) => { if (scrollKey == null) return; rememberScroll(`${peekNavId()}:${scrollKey}`, el.scrollTop); }); // Trigger 1: mount + a content-key change (page switch, feed filter) — both re-render this component. useLayoutEffect(() => { resolve(); }, [scrollKey]); // 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) return; const onScroll = () => remember(el); const onNav = () => resolve(); el.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("popstate", onNav); const unsubNav = subscribeNav(onNav); return () => { cancelRef.current(); el.removeEventListener("scroll", onScroll); window.removeEventListener("popstate", onNav); unsubNav(); }; }, []); return ( // `id` + `tabIndex={-1}`: the skip link's target (ui/SkipLink) takes the focus, so the next Tab // lands on the page's first control. A click on the page's background focuses it too, which is // the focus RESTING, not a choice a Back restore must respect (`FOCUS_REST_ATTRS`).
{children} {/* While Back to top floats over the corner, the page ends a button's height (3rem + its 1.25rem inset) later, so its last row or pager can scroll out from under it. Only then: a spacer always there would give a full-height layout a scrollbar it does not need. */} {cornerLift > 0 ? ( // The download tray sits in the same corner (and the button, when up, over it).
); }