fix(nav): stop back-to-top dying on the first visit to a lazy page
It looked <main> up when it bound, which the lazy pages won. On a first visit to the channel page or Plex the chunk is still loading, so the Suspense fallback is on screen, there is no <main> to find, and the effect gave up — its dep never changes again, so the button stayed dead for that whole visit and only worked once the chunk was cached. It now reads the scroller off the scroll event instead, captured on the document: scroll doesn't bubble but it does capture, so there is nothing to look up and nothing to race. Also 50% bigger, as asked (44 -> 64).
This commit is contained in:
@@ -4,29 +4,43 @@ import { ArrowUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// A floating "back to top" button that fades in once the current page's scroll container is
|
||||
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside its
|
||||
// own <main class="overflow-y-auto"> (the normal pages share App's; the channel page has its own),
|
||||
// so it targets the live <main> and re-binds whenever `dep` changes (i.e. on navigation). Portaled
|
||||
// to <body> so its fixed position is viewport-relative regardless of transformed ancestors.
|
||||
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
|
||||
// <main class="overflow-y-auto"> — the normal pages share App's, the channel page renders its own
|
||||
// — so this follows whichever one is mounted. Portaled to <body> so its fixed position is
|
||||
// viewport-relative regardless of transformed ancestors.
|
||||
export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; threshold?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const [show, setShow] = useState(false);
|
||||
const scrollerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Listen on the document in the CAPTURE phase and read the scroller off the event, rather than
|
||||
// looking <main> up when this binds. Scroll events don't bubble, but they do capture — and the
|
||||
// lookup was a race the lazy pages lost: on the FIRST visit to the channel page its chunk is
|
||||
// still loading, so the Suspense fallback is on screen, there is no <main> to find, and the
|
||||
// effect gave up for good (its dep never changes again). The button then stayed dead for that
|
||||
// whole visit and came back only once the chunk was cached. Nothing to find, nothing to race.
|
||||
useEffect(() => {
|
||||
const onScroll = (e: Event) => {
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (!el || el.tagName !== "MAIN") return;
|
||||
scrollerRef.current = el;
|
||||
setShow(el.scrollTop > threshold);
|
||||
};
|
||||
document.addEventListener("scroll", onScroll, { passive: true, capture: true });
|
||||
return () => document.removeEventListener("scroll", onScroll, { capture: true });
|
||||
}, [threshold]);
|
||||
|
||||
// On navigation, re-read where the new page sits: usually the top (so hide), but some pages
|
||||
// restore their scroll position. Missing <main> here is now harmless — a page that hasn't
|
||||
// rendered hasn't scrolled either, and the listener above picks it up the moment it does.
|
||||
useEffect(() => {
|
||||
const el = document.querySelector("main");
|
||||
scrollerRef.current = el;
|
||||
if (!el) {
|
||||
setShow(false);
|
||||
return;
|
||||
}
|
||||
const onScroll = () => setShow(el.scrollTop > threshold);
|
||||
onScroll(); // a freshly-bound page starts at the top → hidden
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
setShow(!!el && el.scrollTop > threshold);
|
||||
}, [dep, threshold]);
|
||||
|
||||
const toTop = () => scrollerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
const toTop = () =>
|
||||
(scrollerRef.current ?? document.querySelector("main"))?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
|
||||
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
|
||||
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
|
||||
@@ -34,11 +48,11 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
||||
return createPortal(
|
||||
<button
|
||||
onClick={toTop}
|
||||
className={`fixed bottom-5 right-5 z-30 inline-flex items-center justify-center w-11 h-11 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
|
||||
className={`fixed bottom-5 right-5 z-30 inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
|
||||
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<ArrowUp className="w-5 h-5" aria-hidden="true" />
|
||||
<ArrowUp className="w-7 h-7" aria-hidden="true" />
|
||||
<span className="sr-only">{t("common.backToTop")}</span>
|
||||
</button>,
|
||||
document.body
|
||||
|
||||
Reference in New Issue
Block a user