Skip to content

Scroll the table of contents to the active page on client-side navigation - #4534

Open
Cypher-Aura-19 wants to merge 1 commit into
GitbookIO:mainfrom
Cypher-Aura-19:fix/sidebar-active-item-scroll
Open

Scroll the table of contents to the active page on client-side navigation#4534
Cypher-Aura-19 wants to merge 1 commit into
GitbookIO:mainfrom
Cypher-Aura-19:fix/sidebar-active-item-scroll

Conversation

@Cypher-Aura-19

@Cypher-Aura-19 Cypher-Aura-19 commented Aug 22, 2026

Copy link
Copy Markdown

Fixes #4461

What

The sidebar did not scroll the current page into view when navigating client-side. On a full page load the active page was scrolled into view correctly, but after clicking through to a page further down the table of contents the sidebar stayed at its previous offset, so the page you were reading was off-screen.

How

ScrollContainer's auto-scroll effect depended on the active prop:

}, [active]);

TableOfContents passes active="[data-active=true]" — a constant selector string. The dependency was therefore identical on every render, so the effect only ever ran on mount. A full page load mounts the container (scroll worked); a client-side navigation keeps it mounted while the items update their own data-active attributes, so the effect never re-fired. SiteSectionList passes a dynamic active={#${currentSection.id}} and was not affected, which is why the bug was specific to the table of contents.

Rather than add a second scrolling mechanism, this retriggers the existing one: the resolved active element is tracked in state and the scroll effect is keyed on that element, so it re-runs exactly when the selector starts matching a different item.

A MutationObserver scoped to the container keeps that state in sync. It is needed because there is no React signal available here — the active item is identified by CSS selector, the items set their own data-active, and the container itself does not re-render on navigation. It also covers the case where the group holding the new active item expands (and renders it) only after the navigation. This follows existing usage in useScrollOverflow.ts and TableOfContentsScript.tsx, and avoids any setTimeout or arbitrary delay — the timing comes from React's own lifecycle.

Because the effect is keyed on the element's identity, React's Object.is bailout means repeated observer callbacks for the same active item do not re-scroll, so the visitor's own scroll position is left alone and there are no scroll loops.

Two further points:

  • Scrolling is now skipped when the active item is already fully visible, so moving between neighbouring pages no longer shifts the list for no reason.
  • The centering math is unchanged and still uses behavior: 'auto'; it moved to scrollIntoContainer.ts so it can be unit-tested without pulling in the component's dependency graph (the same split as categorizeVariants.ts). Only the sidebar's own scroll container is moved — the window is never scrolled.

No dependencies were added and group expansion state is untouched, so the fixes from #4391 and #4404 are unaffected.

Visual verification

The GitBook preview deployment is still awaiting maintainer approval, so these recordings use a local browser reproduction harness with the actual ScrollContainer implementation rather than the production GitBook site.

Before

Client-side navigation changes the active page, but the sidebar keeps its previous scroll position. The newly active page remains outside the visible sidebar area.

gitbook-4461-before.webm

After

With this change, the same client-side navigation automatically brings the newly active page into view inside the sidebar.

gitbook-4461-after.webm

Already-visible item

When navigating to an item that is already visible, the sidebar keeps its current position instead of scrolling unnecessarily.

gitbook-4461-visible-item.webm

Testing

Tests actually run:

  • bun test src/components/primitives/scrollIntoContainer.test.ts in packages/gitbook — 13 tests pass. They cover the visibility check (fully visible, above, below, clipped by the bottom edge, flush with the edges, measured against the container box rather than the viewport), the centering offsets, and resolveActiveItem — including the regression itself, that a navigation resolves a different element, plus items outside the container being ignored.
  • oxlint on the changed files — no warnings.
  • oxfmt --check on the changed files — correctly formatted.
  • tsc --noEmit in packages/gitbook — no new errors (two errors in AdaptiveVisitorContextProvider.tsx and imageFonts.ts are present on an unmodified tree too).

These were focused checks, not a full CI-equivalent run of the workspace.

Manual verification was done in Chromium (Playwright) against a harness importing the real scrollIntoContainer.ts helpers and mounting both the old and new effect, rather than through localhost:3000/url/... — I do not have GitBook API credentials to run the dev server against a published site, so I could not verify against real content. All measurements below are read back from live layout:

Scenario Result
Client-side navigation to a deep page — old effect scrollTop 0 → 0, active item at 1200px, not visible (reproduces the bug)
Client-side navigation to a deep page — new effect scrollTop 1121, item at 79–119px of a 200px viewport, visible
Repeated deep → deep navigations both scroll into view (scrollTop 721, then 1400)
Navigating to an already-visible item scrollTop unchanged, 0 scrollTo() calls
Manual scroll to 400, then navigate new active item brought into view (scrollTop 241)
Window scrolling window.scrollY 0 → 0 while the container scrolled 0 → 1321
Mount with a deep active item scrolled into view — full-load behavior preserved

Worth a maintainer's eye on real content: the MutationObserver scope, in case there are table-of-contents attribute updates I have not anticipated.

…tion

ScrollContainer's auto-scroll effect depended on the `active` prop, which the
table of contents passes as the constant selector "[data-active=true]". The
dependency therefore never changed and the effect only ran on mount: a full
page load scrolled the active page into view, but a client-side navigation
left the sidebar at its previous offset, hiding the page being viewed.

Track the resolved active element in state and key the scroll effect on that
element instead, so the existing scroll behavior retriggers whenever the
selector starts matching a different item. A MutationObserver scoped to the
container keeps it in sync, because the items set their own `data-active` and
the group holding the new active item may expand only afterwards — the
container itself does not re-render on navigation. Relying on the element's
identity means React bails out while it is unchanged, so repeated observer
callbacks do not re-scroll and the visitor's own scroll position is kept.

Also skip scrolling when the active item is already fully visible, so moving
between neighbouring pages no longer shifts the list for no reason.

The geometry now lives in scrollIntoContainer.ts so it can be unit-tested
without pulling in the component's dependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2b1638c

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

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.

Sidebar does not scroll the active page into view on client-side navigation

1 participant