diff --git a/e2e/outline-anchors.test.ts b/e2e/outline-anchors.test.ts index fd2c66dc..fbec997f 100644 --- a/e2e/outline-anchors.test.ts +++ b/e2e/outline-anchors.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test' +import { expect, type Page, test } from '@playwright/test' test('outline links align headings below the sticky header, including repeated clicks', async ({ page, @@ -39,3 +39,41 @@ test('outline links align headings below the sticky header, including repeated c .toBeLessThan(2) } }) + +const headingOffset = (page: Page, id: string) => + page.evaluate((id) => { + const heading = document.getElementById(id) + if (!heading || window.scrollY === 0) return Infinity + const padding = + Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0 + const margin = Number.parseFloat(getComputedStyle(heading).scrollMarginTop) || 0 + return Math.abs(heading.getBoundingClientRect().top - padding - margin) + }, id) + +// The router's own hash scroll misses this heading, which streams in after the route commit. +for (const { from, link, id } of [ + { + from: '/docs/accounts', + link: 'article a[href$="/create#create-a-configurable-account"]', + id: 'create-a-configurable-account', + }, +]) { + test(`links from ${from} align #${id} after client-side navigation`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(from) + // Let React hydrate, so the click goes through the router, not a document load. + await page.waitForLoadState('networkidle') + await page.evaluate(() => { + ;(window as Window & { __clientNavigation?: boolean }).__clientNavigation = true + }) + await page.locator(link).first().click() + await expect(page).toHaveURL(new RegExp(`#${id}$`)) + await expect.poll(() => headingOffset(page, id), { timeout: 10_000 }).toBeLessThan(2) + // A full page load would pass through the browser's native hash scroll instead. + expect( + await page.evaluate( + () => (window as Window & { __clientNavigation?: boolean }).__clientNavigation, + ), + ).toBe(true) + }) +} diff --git a/src/components/HashScrollOnNavigate.tsx b/src/components/HashScrollOnNavigate.tsx new file mode 100644 index 00000000..da56bea6 --- /dev/null +++ b/src/components/HashScrollOnNavigate.tsx @@ -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(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 + for (const event of userEvents) window.addEventListener(event, stop, { passive: true }) + const timer = setTimeout(stop, SETTLE_MS) + align() + + return stop + }, [path, hash]) + + return null +} diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 556c3a11..79a52d48 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,5 +1,6 @@ import type { PropsWithChildren } from 'react' import DocsEntryLayout from '../components/DocsEntryLayout' +import { HashScrollOnNavigate } from '../components/HashScrollOnNavigate' import '../styles/globals' import { normalizeProxiedRscFetch } from '../lib/rsc-route-normalization' @@ -31,6 +32,8 @@ export default function Layout( {/* biome-ignore lint/security/noDangerouslySetInnerHtml: static bootstrap must run before the RSC client bundle. */}