refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
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<string, number>();
|
||||
// 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).
|
||||
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<HTMLElement | null>(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],
|
||||
);
|
||||
|
||||
// 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) return;
|
||||
const onScroll = () => {
|
||||
if (scrollKeyRef.current == null) return;
|
||||
rememberScroll(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("popstate", resolve);
|
||||
const unsubNav = subscribeNav(resolve);
|
||||
return () => {
|
||||
cancelRef.current();
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("popstate", resolve);
|
||||
unsubNav();
|
||||
};
|
||||
}, [resolve]);
|
||||
|
||||
return (
|
||||
<main
|
||||
ref={setRef}
|
||||
style={fade.style}
|
||||
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user