From 1504c26d0979f3e8d3ccbc2c9b6de86862d1e505 Mon Sep 17 00:00:00 2001 From: Liam Horne Date: Thu, 12 Nov 2026 21:28:58 -0500 Subject: [PATCH 1/2] fix: align heading anchors after client-side navigation --- e2e/outline-anchors.test.ts | 23 +++++++ src/components/HashScrollOnNavigate.tsx | 90 +++++++++++++++++++++++++ src/pages/docs/_layout.tsx | 2 + 3 files changed, 115 insertions(+) create mode 100644 src/components/HashScrollOnNavigate.tsx diff --git a/e2e/outline-anchors.test.ts b/e2e/outline-anchors.test.ts index fd2c66dcc..36c9a5ddc 100644 --- a/e2e/outline-anchors.test.ts +++ b/e2e/outline-anchors.test.ts @@ -39,3 +39,26 @@ test('outline links align headings below the sticky header, including repeated c .toBeLessThan(2) } }) + +test('links to a heading on another page align it after client-side navigation', async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/docs/accounts') + await page.locator('article a[href$="/create#create-a-configurable-account"]').first().click() + await expect(page).toHaveURL(/\/docs\/accounts\/create#create-a-configurable-account$/) + await expect + .poll( + async () => + page.evaluate(() => { + const heading = document.getElementById('create-a-configurable-account') + 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) + }), + { timeout: 10_000 }, + ) + .toBeLessThan(2) +}) diff --git a/src/components/HashScrollOnNavigate.tsx b/src/components/HashScrollOnNavigate.tsx new file mode 100644 index 000000000..da56bea60 --- /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/docs/_layout.tsx b/src/pages/docs/_layout.tsx index 169d59527..901dd78de 100644 --- a/src/pages/docs/_layout.tsx +++ b/src/pages/docs/_layout.tsx @@ -5,6 +5,7 @@ import DocsHeader from '../../components/DocsHeader' import DocsPageActions from '../../components/DocsPageActions' import DocsSectionNav from '../../components/DocsSectionNav' import DocsSidebarDrawer from '../../components/DocsSidebarDrawer' +import { HashScrollOnNavigate } from '../../components/HashScrollOnNavigate' import { usePageSettled } from '../../lib/pageSettled' import { normalizeRscFetchUrl } from '../../lib/rsc-route-normalization' import * as ui from './_layout.recipes' @@ -61,6 +62,7 @@ export default function DocsLayout( + {props.children} From 3b55f5df560d8c4d5a9240f7fb5285ac44b07272 Mon Sep 17 00:00:00 2001 From: Liam Horne Date: Thu, 12 Nov 2026 21:38:45 -0500 Subject: [PATCH 2/2] fix: mount anchor alignment at the root; make the e2e test require client-side navigation --- e2e/outline-anchors.test.ts | 61 +++++++++++++++++++++++-------------- src/pages/_layout.tsx | 3 ++ src/pages/docs/_layout.tsx | 2 -- 3 files changed, 41 insertions(+), 25 deletions(-) diff --git a/e2e/outline-anchors.test.ts b/e2e/outline-anchors.test.ts index 36c9a5ddc..fbec997ff 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, @@ -40,25 +40,40 @@ test('outline links align headings below the sticky header, including repeated c } }) -test('links to a heading on another page align it after client-side navigation', async ({ - page, -}) => { - await page.setViewportSize({ width: 1440, height: 900 }) - await page.goto('/docs/accounts') - await page.locator('article a[href$="/create#create-a-configurable-account"]').first().click() - await expect(page).toHaveURL(/\/docs\/accounts\/create#create-a-configurable-account$/) - await expect - .poll( - async () => - page.evaluate(() => { - const heading = document.getElementById('create-a-configurable-account') - 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) - }), - { timeout: 10_000 }, - ) - .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/pages/_layout.tsx b/src/pages/_layout.tsx index 556c3a116..79a52d485 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. */}