import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react"; import { useScrollFade } from "../lib/useScrollFade"; // 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`. const savedScroll = new Map(); // 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). function restoreScroll(el: HTMLElement, top: number): () => void { if (top <= 0) { el.scrollTop = 0; return () => {}; } let raf = 0; let frames = 0; let cancelled = false; const tick = () => { if (cancelled) return; if (el.scrollHeight - el.clientHeight >= top) el.scrollTop = top; if (el.scrollTop >= top - 1 || frames++ > 90) return; // reached, or gave up after ~1.5s raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => { cancelled = true; cancelAnimationFrame(raf); }; } // 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, fadeTop = true, }: { children: ReactNode; scrollKey?: string; // Off when the scroller's top is a sticky element (a table's sticky header) — see useScrollFade. fadeTop?: boolean; }) { const fade = useScrollFade(20, fadeTop); const fadeRef = fade.ref; 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 fade.ref 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], ); 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. const onScroll = () => { savedScroll.set(key, el.scrollTop); }; el.addEventListener("scroll", onScroll, { passive: true }); return () => { cancelRestore(); el.removeEventListener("scroll", onScroll); }; }, [scrollKey]); return (
{children}
); }