Skip to content

ScrollSpy: deterministic active-section detection#42557

Merged
mdo merged 8 commits into
v6-devfrom
mdo/scrollspy-rewrite
Jul 1, 2026
Merged

ScrollSpy: deterministic active-section detection#42557
mdo merged 8 commits into
v6-devfrom
mdo/scrollspy-rewrite

Conversation

@mdo

@mdo mdo commented Jun 25, 2026

Copy link
Copy Markdown
Member

Problem

ScrollSpy chose the active section with a scroll-direction flag + a sequential offsetTop tiebreaker that mutated state while walking IntersectionObserver entries. IO delivers entries in no guaranteed order, so when several sections shared the viewport (e.g. the docs right-rail TOC) the highlight was wrong. Coarse defaults also left stale gaps, smooth scroll suppressed the URL hash, focus wasn't managed, and ids with dots/special characters threw.

Change

Detection is now IntersectionObserver-native and deterministic — no scroll-position polling and no per-frame getBoundingClientRect() sweep:

  • The observer root is collapsed (via rootMargin) to a strip from the top of the scroll root down to an activation line. A small Set of currently-intersecting sections is maintained from the IO entries, and the active section is the deepest of them. Work happens only when a section crosses the line — a handful of times per page traversal, not every animation frame.
  • Content gaps: when nothing crosses the line, the last activated section stays active.
  • Top of page: before any section crosses the line, the first section stays active.
  • Bottom of page: an invisible, aria-hidden bottom sentinel (its own observer) activates the last section even when its top never reaches the line — replacing the old scrollTop math.
  • Target resolution: ids are resolved with decodeURIComponent + getElementById (scoped with contains), so dotted/colon/slash/percent-encoded ids resolve without escaping.
  • Smooth-scroll settle: scrollend is now registered as a real native event (added to EventHandler), with a transient scroll-idle fallback. On a smooth-scroll click the settle restores the URL hash via history.replaceState and moves focus to the section (tabindex="-1" + focus({ preventScroll: true })). When the target needs no scroll (already there / reduced motion) it settles immediately instead of stranding the pending navigation.
  • Config: new topMargin option (default 12%, accepts % or px, e.g. 96px below a sticky navbar) positions the activation line; rootMargin is an advanced override that takes precedence and is passed straight to the observer. threshold default is now [0]. The long-deprecated offset/method options are removed. A debounced resize rebuild keeps a px activation line accurate.

Issues / PRs

Verification

  • Full unit suite green (1104), lint clean. Added/updated regression tests: deepest-of-multiple determinism, bottom-sentinel forces last, keep-last across gaps, first-at-top, encoded/malformed id resolution, custom rootMargin passthrough, resize rebuild, immediate settle when already at target, and the settle -> hash/focus behavior.
  • Browser-validated against the built bundle: navbar example (top -> First, through to bottom -> Fifth, with nested menu-parent highlighting) and the docs right-rail TOC with data-bs-top-margin="96px" (continuous scroll advances cleanly through every section with nested h2/h3 highlighting); smooth-scroll click restores the hash and moves focus.

The docs right-rail TOC uses Bootstrap ScrollSpy directly, so it's fixed transitively.

@mdo mdo requested a review from a team as a code owner June 25, 2026 18:36
@mdo mdo mentioned this pull request Jun 26, 2026
8 tasks
@mdo mdo force-pushed the mdo/scrollspy-rewrite branch from 7b08736 to 7aefcb2 Compare June 29, 2026 22:29
mdo added 4 commits June 30, 2026 16:07
Replace the scroll-direction + sequential-offsetTop heuristic (which
depended on IntersectionObserver entry order and mis-highlighted when
several sections shared the viewport) with a deterministic geometric
model: the active section is the deepest one whose top has scrolled to
or above an activation line near the top of the scroll root, read fresh
from getBoundingClientRect on each (rAF-throttled) scroll. At the bottom
the last section wins; above the first nothing is active.

Also: settle on scrollend (with a scroll-idle fallback) to restore the
URL hash via replaceState and move focus to the target after a
smooth-scroll navigation; escape section ids via parseSelector so dotted
/special-character ids no longer throw; add a topMargin option for the
activation line.

Supersedes #41016. Fixes #37858, #39198, #39248, #36387, #40526.
@mdo mdo force-pushed the mdo/scrollspy-rewrite branch from ddcd3ec to 29d393f Compare June 30, 2026 23:08
mdo added 4 commits June 30, 2026 16:14
- Add 'Fpath' to the cspell dictionary (from the #%2Fpath encoded-id example)
- Bump the four JS bundlewatch thresholds for the rewritten ScrollSpy
- Add option-derived activation-line tests (topMargin %/px, derived rootMargin
  with/without a scroll root, non-numeric topMargin, explicit rootMargin,
  overflow state, observer rebuild no-op, resize listener add/remove) to lift
  branch coverage from 88.63% back over the 89% threshold (now 89.21%)
Mirror the migration.mdx ScrollSpy changelog in the migration skill:
IntersectionObserver rewrite / activation line, removed offset+method
options, new topMargin, rootMargin default null, threshold default [0],
smoothScroll hash+focus restore, and getElementById id resolution.
Keeps the skill and guide consistent as this PR lands ScrollSpy.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Inbox

Development

Successfully merging this pull request may close these issues.

1 participant