A cross-page anchor link navigated to the right page but stayed at the top.
GitBook's `scrollToHash` scrolled once, but a soft navigation delivers the
destination asynchronously — content keeps mounting and reflowing for a few
hundred ms after the URL changes — so the single scroll landed before the
target reached its final position, and #4089 routed cross-section navigations
through it. It also scrolled to the top on a transiently-empty hash context.
- scrollToHash re-scrolls to the target on each DOM change until the DOM goes
quiet, mirroring the fragment scrolling the browser does for free during a
full page load. It bails if the visitor scrolls, so we never fight them.
- useScrollPage decides the hash from the context value for same-page anchors
(authoritative, set on click) and from `window.location.hash` for cross-page
navigations (where the context hash is transiently empty during the remount).
Next keeps handling the baseline scroll (top on plain/query navigations, the
top fallback for missing anchors); GitBook only re-asserts the hash scroll it
would otherwise land too early on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collapse scrollToHash to one requestAnimationFrame loop: scroll to the target whenever
it's present, retrying ~1s for it to commit and holding ~0.3s afterward so we scroll
after Next's late scroll-to-top. Drops the separate wheel/touchmove/keydown abort
machinery — the hold window is short enough that a user scrolling within it is a rare,
brief edge, not worth the ceremony.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpGe6QkAF4Y1HDGUD4vbke
Browser diagnostics on the reported n8n case identified the actual blocker. Our scroll
now fires once the target heading commits, but Next's scroll restoration fires a
`window.scrollTo(0, 0)` *after* it during the client navigation, so a one-shot
scrollIntoView loses — whoever scrolls last wins, and Next scrolls last. This is why
none of retry-only / `scroll={false}` / their combination worked: they all scrolled once.
Rework `scrollToHash` to (a) retry across frames until the element exists, then (b)
re-assert the scroll for a short window (~0.3s) so it survives the late reset, bailing
immediately if the user scrolls (wheel / touchmove / keydown) so it never hijacks intent.
Use `behavior: 'instant'` — a smooth animation is trivially interrupted by the reset, and
re-asserting an already-reached instant scroll is a no-op. `scroll={false}` on the Link is
reverted: it neither stopped the reset nor was needed once we out-last it, and it churned
unrelated navigation-scroll behavior.
Still needs a browser check: the console diagnostic should now show scrollY settling on
the heading with no trailing scroll-to-top winning. Regression pass: same-page anchors,
plain page-to-page nav (top), back/forward.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpGe6QkAF4Y1HDGUD4vbke
Browser diagnostics on the reported n8n case showed two cooperating causes, which is
why the earlier single-sided attempts failed:
1. The scroll hooks fire when the hash is set (on link click, still on the previous
page), so the target heading isn't in the DOM yet and scrollToHash missed and never
retried. Fixed in the previous commit by retrying scrollToHash across frames.
2. With the retry in place, our scrollIntoView *does* fire once the heading commits —
but Next's own post-navigation scroll (its hash scrollIntoView plus a scroll-to-top,
see useHash / vercel/next.js#49465) runs after us and wins, snapping back to the top.
Set `scroll={false}` on the internal NextLink so Next stops managing scroll on
client-side navigation and GitBook's ScrollPage/useScrollToHash own it exclusively:
retry to the hash when present, scroll to top otherwise. Neither change works alone —
the retry needs Next to stop overriding it, and scroll={false} needs the retry to find
the late-committing element.
Still needs a browser check: rerunning the console diagnostic should now show our
scrollIntoView fire without a following scroll-to-top, landing on the heading. Because
scroll={false} routes all navigation scroll through ScrollPage, also regression-check
plain page-to-page nav (top), same-page anchors, and back/forward.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpGe6QkAF4Y1HDGUD4vbke
Browser diagnostics on the reported n8n case showed the real cause: the scroll
hooks fire when the hash is set (on link click, still on the previous page), so
`document.getElementById(hash)` misses; by the time the destination heading is in
the DOM, `scrollToHash` is never called again, so the page stays at the top. Next's
own scroll was never involved — an earlier `scroll={false}` attempt changed nothing
and is reverted here.
Make `scrollToHash` retry across animation frames (bounded to ~1s) until the target
element exists, then scroll to it once. A single in-flight retry is tracked and
cancelled if a newer scroll is requested. Hash-less navigation is unaffected
(ScrollPage scrolls to top directly, without calling scrollToHash).
Still needs a browser check: rerunning the console diagnostic should now show
`scrollIntoView` firing on the attempt where the element resolves.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpGe6QkAF4Y1HDGUD4vbke
The first attempt (re-running useScrollToHash on `pathname`) had no effect: the
root-layout `ScrollPage` hook already called `scrollToHash` on every hash+pathname
change, so the scroll was firing — but Next's own default post-navigation scroll
(App Router hash handling is unreliable, see useHash / vercel/next.js#49465) runs
after it and scrolls to top, overriding us. Same-page anchors are unaffected because
Link.tsx handles those manually with preventDefault (no NextLink scroll), and direct
URL loads work via the browser's native hash scroll — which matches the reported
behavior.
Set `scroll={false}` on the internal NextLink so Next stops managing scroll on
client-side navigations; GitBook's ScrollPage then owns it (scroll to the hash when
present, else to the top). Revert the redundant `pathname` dependency added in the
previous commit.
Not verified in a browser: this sandbox's network policy blocks the preview
deployment and the GitBook API, so it needs a browser check on a site with
cross-page anchor links (e.g. n8n) plus a regression pass on ordinary navigation
scroll-to-top and back/forward restoration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpGe6QkAF4Y1HDGUD4vbke
Cross-page anchor links (e.g. a homepage card linking to `/page#heading`)
landed at the top of the destination page on soft navigation, while the same
URL loaded directly scrolled to the heading correctly.
`useScrollToHash` (the per-page safety net in PageClientLayout that scrolls
once the page blocks are rendered) only depended on the navigation hash. The
hash is set at click time while the previous page is still mounted, so the
effect fired before the target element existed. Because sibling pages share the
same `[pagePath]` route, PageClientLayout is reused rather than remounted, so
the effect never re-ran once the destination content committed to the DOM.
Add `pathname` as a dependency so the scroll is re-attempted when the
destination page commits and the target heading is present. The `if (hash)`
guard is unchanged, so hash-less navigations still scroll to top via ScrollPage
and back/forward restoration is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpGe6QkAF4Y1HDGUD4vbke