Skip to content

fix: align heading anchors after client-side navigation - #1133

Merged
snario merged 2 commits into
mainfrom
fix/hash-scroll-after-navigation
Nov 13, 2026
Merged

snario merged 2 commits into
mainfrom
fix/hash-scroll-after-navigation

Conversation

@snario

@snario snario commented Nov 13, 2026

Copy link
Copy Markdown
Member

Links to a heading on another docs page land at the top of the page instead of the heading, for example the "create a configurable account" link on /docs/accounts, which goes to /docs/accounts/create#create-a-configurable-account. A full page load of the same URL works.

Cause: on a client-side navigation, Waku's router and Vocs' scroll restoration try to scroll to the hash in the commit that changes the route. The page content streams in after that commit, so the heading doesn't exist yet. Waku then scrolls to the top (scrollToHash with scrollTopForMissingHash), and nothing retries once the heading mounts.

Fix: HashScrollOnNavigate, mounted in the docs layout. After a path change with a hash, it watches the DOM until the heading mounts and aligns it below the sticky header (honouring scroll-padding-top and scroll-margin-top). It keeps the heading aligned while demos above it render, and stops at the first wheel, touch, key or mouse input, after 4 s, or on back/forward navigation, which Vocs' scroll restoration still handles. Full page loads and same-page hash links are unchanged.

Tests:

  • New e2e test in e2e/outline-anchors.test.ts: clicks that link on /docs/accounts and checks the heading lines up. Passes against a production build (pnpm build + dist/preview.js), along with the existing outline test.
  • With instrumentation on the production build, the router's scrollTo(0, 0) still fires while the heading is missing, and this component then scrolls the heading into place about 0.5 s later (scrollY 0 → 1515).
  • biome check, pnpm check:types and pnpm test (1688 tests) pass.

@vercel

vercel Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
tempo-docs Ready Ready Preview Nov 13, 2026 2:44am UTC

Request Review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread e2e/outline-anchors.test.ts Resolved
Comment thread src/components/HashScrollOnNavigate.tsx Resolved
Comment on lines +78 to +81
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 SETTLE_MS timer starts at the route commit, not when the heading mounts. That commit happens before the content streams. If streaming takes longer than 4000 ms, stop disconnects both observers before the target exists, so nothing ever scrolls to it. Fix: keep waiting until the target first mounts, with a separate, longer cap; start the 4 s keep-aligned window from that point. The wheel/touch/key/mouse stop already protects users who scroll while waiting.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A 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 setTimeout(stop, SETTLE_MS) right away at line 81, with SETTLE_MS = 4000 (line 6). align returns early while targetFor(currentHash) is null (line 59). If the heading arrives after 4000 ms, stop has already disconnected the MutationObserver and ResizeObserver and set done. Nothing re-runs, because path and hash do not change again. The router's scrollTopForMissingHash scroll-to-top stays in place, exactly as on the base branch. So the fix works only when streaming beats a fixed 4 s. Bounding the wait by user input is not the problem: the userEvents listeners at lines 79-80 already stop it as soon as the reader scrolls.

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, const timer = setTimeout(stop, SETTLE_MS) (line 81) starts the timer at that moment, with SETTLE_MS = 4000 (line 6). So if the heading mounts after the 4 s timer has fired, no code scrolls to it.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

@snario
snario merged commit dbd1bba into main Nov 13, 2026
16 checks passed
@snario
snario deleted the fix/hash-scroll-after-navigation branch November 13, 2026 02:56

This branch was successfully deployed

1 active deployment
Preview — 3b55f5df Deployed Nov 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant