From eb208af991e542a134277965fe32495a066d7aa3 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sat, 8 Aug 2026 19:14:03 +0700 Subject: [PATCH] Make the section links actually arrive, not just navigate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit got the header off a routed page and onto the homepage, but the reader still landed at the top rather than at the section they clicked. Two causes, found by instrumenting rather than guessing again: - The polling used requestAnimationFrame, which is throttled to zero frames in a hidden or backgrounded tab. A correctness path must not be built on the animation clock; it now uses a timer. - scrollIntoView({behavior: 'smooth'}) is itself animation-driven and silently does nothing under the same conditions — measured: the element sat at top: 4041 for three seconds while scrollY stayed at 7, and behavior: 'auto' moved it instantly. Behaviour is now chosen rather than assumed: instant when the document is hidden or the reader has asked for reduced motion, smooth otherwise. Arriving instantly beats not arriving. Verified all eight header links from a routed page and from the homepage, plus the footer, and checked that #/quantum and the external docs link are unaffected. Co-Authored-By: Claude Opus 5 --- apps/website/src/components/Footer.tsx | 20 ++++++++-- apps/website/src/components/Navigation.tsx | 44 ++++++++++++++++------ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/apps/website/src/components/Footer.tsx b/apps/website/src/components/Footer.tsx index fd2a1102c2..e1534d01ff 100644 --- a/apps/website/src/components/Footer.tsx +++ b/apps/website/src/components/Footer.tsx @@ -3,6 +3,16 @@ import { motion } from 'framer-motion' import { Link } from 'react-router-dom' import { useI18n } from '../i18n/context' +// Smooth scrolling is animation-driven, so it silently does nothing when +// animations are not running — a hidden or backgrounded tab, or a reader who has +// asked their system for reduced motion. Getting there instantly is always better +// than not getting there at all. +function scrollBehaviour(): ScrollBehavior { + const reduced = typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-reduced-motion: reduce)').matches + return reduced || document.hidden ? 'auto' : 'smooth' +} + // Under HashRouter a bare `#section` is read as a route, so these links used to // dump the reader on the homepage without scrolling to what they clicked. This // sends them home when needed and then finds the section once it exists. @@ -11,13 +21,15 @@ function goToSection(e: React.MouseEvent, id: string) { const hash = window.location.hash const onHome = hash === '' || hash === '#' || hash === '#/' if (!onHome) window.location.hash = '#/' + // Timer, not requestAnimationFrame: rAF does not fire in a hidden tab, and + // sections further down the homepage mount lazily. let tries = 0 - const findIt = () => { + const tick = () => { const el = document.getElementById(id) - if (el) { el.scrollIntoView({ behavior: 'smooth' }); return } - if (++tries < 40) requestAnimationFrame(findIt) + if (el) { el.scrollIntoView({ behavior: scrollBehaviour() }); return } + if (++tries < 50) setTimeout(tick, 80) } - requestAnimationFrame(findIt) + tick() } export default function Footer() { diff --git a/apps/website/src/components/Navigation.tsx b/apps/website/src/components/Navigation.tsx index ed5dc58356..eb5e15e8c0 100644 --- a/apps/website/src/components/Navigation.tsx +++ b/apps/website/src/components/Navigation.tsx @@ -30,6 +30,36 @@ const PAGES: PageLink[] = [ { href: DOCS_URL, en: 'Docs', ru: 'Документация', note: 'Full documentation', noteRu: 'Полная документация', external: true }, ] +// Smooth scrolling is animation-driven, so it silently does nothing when +// animations are not running — a hidden or backgrounded tab, or a reader who has +// asked their system for reduced motion. Getting there instantly is always better +// than not getting there at all. +function scrollBehaviour(): ScrollBehavior { + const reduced = typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-reduced-motion: reduce)').matches + return reduced || document.hidden ? 'auto' : 'smooth' +} + +// Find a section that may not have mounted yet, then scroll to it. +// +// Uses a timer rather than requestAnimationFrame on purpose: rAF is throttled to +// zero in a background or hidden tab, so a correctness path built on it simply +// never runs there. Sections further down the homepage are also lazily rendered, +// so the window has to be generous — 4 seconds of 80ms polls, which stops as soon +// as the element appears. +function scrollToSectionWhenReady(id: string) { + let tries = 0 + const tick = () => { + const el = document.getElementById(id) + if (el) { + el.scrollIntoView({ behavior: scrollBehaviour() }) + return + } + if (++tries < 50) setTimeout(tick, 80) + } + tick() +} + export default memo(function Navigation() { const { t, lang } = useI18n() const [active, setActive] = useState('hero') @@ -74,20 +104,10 @@ export default memo(function Navigation() { const onHome = hash === '' || hash === '#' || hash === '#/' if (!onHome) { window.location.hash = '#/' - // One frame is not enough: the homepage has to mount before the target - // element exists. Poll briefly instead of guessing a delay. - let tries = 0 - const findIt = () => { - const el = document.getElementById(id) - if (el) { el.scrollIntoView({ behavior: 'smooth' }); return } - if (++tries < 40) requestAnimationFrame(findIt) - } - requestAnimationFrame(findIt) + scrollToSectionWhenReady(id) return } - setTimeout(() => { - document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }) - }, 100) + scrollToSectionWhenReady(id) }, []) // Handle escape key to close menu