fix(core): scroll-spy race conditions on rapid/smooth scroll#2968
fix(core): scroll-spy race conditions on rapid/smooth scroll#2968bennypowers merged 3 commits intomainfrom
Conversation
Fixes three bugs in ScrollSpyController: 1. Rapid clicks: setActive() now cancels previous force state via AbortController before establishing new force, preventing stale listeners from releasing force prematurely. 2. Force release timing: replaced await-first-intersection approach with scrollend event listener (+ 3s safety timeout). The old approach released force when any IO fired during smooth scroll, causing intermediate sections to steal the active state. 3. Non-contiguous sections: passedLinks are now sorted by DOM order instead of relying on Set insertion order, which was unreliable when sections had untracked content between them. Also fixes a safeguard bug where the #nextIntersection timeout set `#intersected = false` (should be `true`), which could cause an infinite rAF loop, and fixes the boundary comparison to use rootBounds.top instead of intersectionRect.top so elements exactly at the viewport top are correctly considered "passed". Closes #2911 Closes #2920 Assisted-By: Claude Opus 4.6 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: fa1ed38 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
✅ Deploy Preview for patternfly-elements ready!
To edit notification comments on pull requests, go to your Netlify site settings. |
✅ Commitlint tests passed!More Info{
"valid": true,
"errors": [],
"warnings": [],
"input": "fix(core): scroll-spy race conditions on rapid/smooth scroll"
} |
Assisted-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes ScrollSpyController race conditions that could cause jump-links components to show incorrect active state during rapid and/or smooth scrolling navigation, and adds unit coverage to prevent regressions.
Changes:
- Add force-mode cancellation/cleanup and switch force release from “first IO callback” to
scrollend(with timeout fallback). - Fix intersection wait-loop safeguard and adjust “passed” boundary detection.
- Add a dedicated
ScrollSpyControllertest suite covering rapid clicks, smooth scrolling, and non-contiguous sections.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| core/pfe-core/controllers/scroll-spy-controller.ts | Updates force-mode lifecycle, passed-link ordering, and intersection/threshold logic to prevent active-state races. |
| core/pfe-core/test/scroll-spy-controller.spec.ts | Adds 9 integration-style unit tests validating correct active state across scroll/navigation scenarios. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
Prevents a stale 3s timeout from prematurely resolving a subsequent #nextIntersection() call. Assisted-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Fixes race conditions in
ScrollSpyControllerthat causedpf-jump-linksandrh-jump-linksto show incorrect active state during navigation:setActive()now aborts any pending force-release listeners viaAbortControllerbefore establishing new force, so rapid successive clicks don't leave stale listeners that release force prematurelyscrollendinstead of first IO — The old approach released force when anyIntersectionObservercallback fired, which during smooth scroll meant intermediate sections could steal the active state. Now waits forscrollend(with 3s safety timeout)passedLinkssorted by DOM order — Previously relied onSetinsertion order, which was wrong when scrolling back through non-contiguous sections (sections with untracked content between them)#nextIntersectiontimeout — Was setting#intersected = false(should betrue), which could cause an infinite rAF loop if no IO firesrootBounds.topinstead ofintersectionRect.topso elements exactly at the viewport top are correctly considered "passed"Closes #2911
Closes #2920
Resolves RedHat-UX/red-hat-design-system#2425
Resolves RedHat-UX/red-hat-design-system#2474
Test plan
scroll-spy-controller.spec.ts(all pass)rh-jump-linkscomponent in RHDS dev server (7/8 pass — the 1 "failure" is expected async timing, identical on original controller)🤖 Generated with Claude Code