Repository navigation
fix: align heading anchors after client-side navigation #1133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect, useRef } from 'react' | ||
| import { useRouter } from 'waku' | ||
|
|
||
| const SETTLE_MS = 4000 | ||
|
|
||
| function targetFor(hash: string) { | ||
| const raw = hash.replace(/^#/, '') | ||
| if (!raw) return null | ||
| let id = raw | ||
| try { | ||
| id = decodeURIComponent(raw) | ||
| } catch {} | ||
| return document.getElementById(id) ?? document.getElementById(raw) | ||
| } | ||
|
|
||
| /** | ||
| * Align the URL's heading after a client-side navigation to another page. | ||
| * | ||
| * Waku and Vocs scroll to the hash in the commit that changes the route, but a | ||
| * docs page's content streams in after that commit. The heading is missing then, | ||
| * so the router scrolls to the top and nothing retries. This waits for the | ||
| * heading to mount, aligns it below the sticky header (scroll-padding-top), and | ||
| * keeps it aligned while demos above it finish rendering. It stops at the first | ||
| * user scroll input, on back/forward navigation, or after SETTLE_MS. | ||
| */ | ||
| export function HashScrollOnNavigate() { | ||
| const { path, hash } = useRouter() | ||
| const previousPath = useRef<string | null>(null) | ||
| const popstate = useRef(false) | ||
|
|
||
| useEffect(() => { | ||
| const onPopstate = () => { | ||
| popstate.current = true | ||
| } | ||
| window.addEventListener('popstate', onPopstate) | ||
| return () => window.removeEventListener('popstate', onPopstate) | ||
| }, []) | ||
|
|
||
| useEffect(() => { | ||
| const pathChanged = previousPath.current !== null && previousPath.current !== path | ||
| previousPath.current = path | ||
| const wasPopstate = popstate.current | ||
| popstate.current = false | ||
| // A full page load is handled by the browser, a same-page hash by Vocs, and | ||
| // back/forward by Vocs' scroll restoration. | ||
| if (!pathChanged || wasPopstate) return | ||
| const currentHash = hash || window.location.hash | ||
| if (!currentHash || currentHash === '#') return | ||
|
|
||
| let done = false | ||
| let frame = 0 | ||
| const align = () => { | ||
| cancelAnimationFrame(frame) | ||
| frame = requestAnimationFrame(() => { | ||
| if (done) return | ||
| const target = targetFor(currentHash) | ||
| if (!target) return | ||
| const padding = | ||
| Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0 | ||
| const margin = Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0 | ||
| if (Math.abs(target.getBoundingClientRect().top - padding - margin) > 1) | ||
| target.scrollIntoView({ block: 'start', behavior: 'instant' }) | ||
| }) | ||
| } | ||
| const stop = () => { | ||
| if (done) return | ||
| done = true | ||
| cancelAnimationFrame(frame) | ||
| clearTimeout(timer) | ||
| observer.disconnect() | ||
| resizeObserver.disconnect() | ||
| for (const event of userEvents) window.removeEventListener(event, stop) | ||
| } | ||
|
|
||
| const observer = new MutationObserver(align) | ||
| observer.observe(document.body, { childList: true, subtree: true }) | ||
| const resizeObserver = new ResizeObserver(align) | ||
| resizeObserver.observe(document.body) | ||
| const userEvents = ['wheel', 'touchstart', 'keydown', 'mousedown'] as const | ||
|
Comment on lines
+78
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: On slow connections, readers following a cross-page heading link still land at the top whenever the page takes longer than 4 s to stream in. The Why this was flaggedA reader on a slow mobile network clicks a link from one docs page to a heading on another. The effect in src/components/HashScrollOnNavigate.tsx:41-87 runs at the route commit, before the content has streamed. It starts Verification: Pre-existing (partial fix). This happens when the target page's content streams in more than 4 s after the route commit. In src/components/HashScrollOnNavigate.tsx, |
||
| for (const event of userEvents) window.addEventListener(event, stop, { passive: true }) | ||
| const timer = setTimeout(stop, SETTLE_MS) | ||
| align() | ||
|
|
||
| return stop | ||
| }, [path, hash]) | ||
|
|
||
| return null | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.