Skip to content

Fix anchor links not scrolling to target on client-side navigation (RND-11844)#4396

Open
zenoachtig wants to merge 8 commits into
mainfrom
claude/tender-allen-f9l6hh
Open

Fix anchor links not scrolling to target on client-side navigation (RND-11844)#4396
zenoachtig wants to merge 8 commits into
mainfrom
claude/tender-allen-f9l6hh

Conversation

@zenoachtig

@zenoachtig zenoachtig commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Linear: RND-11844 — "Anchor links on docs site fail to scroll to target" (reported by n8n, https://docs.n8n.io/).

Overview

  • Clicking an in-app link to an anchor on another page (e.g. a homepage card → /connect/connect-to-n8n-mcp-server#connecting-codex-cli-to-n8n-mcp-server) navigated to the right page but stayed at the top instead of scrolling to the heading.
  • Loading the same URL directly, and same-page #anchor clicks, already worked — only cross-page anchor navigation was broken.

Root cause

GitBook manages scroll-to-hash itself (ScrollPage / useScrollToHashscrollToHash), because App Router's native hash handling is unreliable (useHash.tsx, vercel/next.js#49465). Confirmed with in-browser instrumentation:

  • A soft navigation delivers the destination asynchronously — its content keeps mounting and reflowing for a few hundred ms after the URL changes (measured ~932px of shift ~800ms after landing on the reported page). scrollToHash scrolled only once, so it landed before the target reached its final position. #4089 then routed cross-section navigations through this same one-shot path, turning "slightly off" into "stuck at the top".
  • ScrollPage also scrolled to the top on a transiently-empty hash context (the useHash value is briefly empty while a section remounts).

The fix

  • scrollToHash now re-scrolls to the target on every DOM change until the DOM goes quiet (~400ms) — the same fragment scrolling the browser does for free during a full page load, which soft navigation loses. This handles the target mounting late and content reflowing above it. It bails the moment the visitor scrolls, so we never fight them.
  • useScrollPage decides the hash from the authoritative source per case: the context hash for same-page anchors (set on click, before Next commits the URL), and 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 and query-only navigations, and the top fallback for a missing/stale anchor — so GitBook only re-asserts the hash scroll it would otherwise land too early on. (An earlier revision disabled Next's scroll with scroll={false}; that was dropped, since the re-assertion wins the target position without it and disabling Next regressed query-only navigations and the missing-anchor fallback.)

Verification

Verified in the local dev server and on the Vercel preview against the reported n8n case, plus regressions:

Case Result
Cross-page anchor (the bug) ✅ lands on the heading
Same-page anchor ✅ scrolls to target
Plain page → page navigation ✅ lands at top
Browser back / forward ✅ scroll position restored

CI notes

  • Argos "changes detected" builds are navigation scroll-position snapshot diffs from the fix — review/accept as new baselines.
  • The recurring docs.cherry-ai.com › Home visual failure is a cookie-consent-dialog timeout, unrelated to this change — worth confirming it also fails on main.

— Authored by Claude

…ND-11844)

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
@linear-code

linear-code Bot commented Jul 14, 2026

Copy link
Copy Markdown

RND-11844

@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d414f09

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
gitbook Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@argos-ci

argos-ci Bot commented Jul 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
customers-v2-cloudflare (Inspect) ⚠️ Changes detected (Review) 1 changed, 1 ignored Jul 14, 2026, 6:36 PM
customers-v2-vercel (Inspect) ✅ No changes detected 4 ignored Jul 14, 2026, 6:34 PM
v2-cloudflare (Inspect) ⚠️ Changes detected (Review) 11 changed, 2 ignored Jul 14, 2026, 6:38 PM
v2-vercel (Inspect) ⚠️ Changes detected (Review) 2 changed, 6 ignored Jul 14, 2026, 6:36 PM

…hors land on target (RND-11844)

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
RND-11844)

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
…target (RND-11844)

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 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
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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d40ef2060

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/gitbook/src/components/primitives/Link.tsx Outdated
Comment thread packages/gitbook/src/components/hooks/useScrollPage.ts Outdated
Comment thread packages/gitbook/src/components/hooks/useScrollPage.ts Outdated
@zenoachtig
zenoachtig force-pushed the claude/tender-allen-f9l6hh branch from 7d40ef2 to 749b977 Compare July 14, 2026 16:52
)

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>
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.

2 participants