Skip to content

chore: Revert "Revert "fix: scrollIntoView should respect scroll-margin (#8715)"" #9146

Merged
LFDanLu merged 5 commits intoadobe:mainfrom
nwidynski:fix-scrollintoviewport
Feb 10, 2026
Merged

chore: Revert "Revert "fix: scrollIntoView should respect scroll-margin (#8715)"" #9146
LFDanLu merged 5 commits intoadobe:mainfrom
nwidynski:fix-scrollintoviewport

Conversation

@nwidynski
Copy link
Contributor

This PR refactors scrollIntoView and scrollIntoViewport to support scrollMargin, while working around the regressions mentioned in #8689 (comment). Additionally, a new inline & block option for alignment of a container element has been added.

By leveraging bounding rectangles, the internal calculations are now streamlined with DOMLayoutDelegate and significantly simplified.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

Comment on lines +145 to +152
let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect();
// Account for sub pixel differences from rounding
if ((Math.abs(originalLeft - newLeft) > 1) || (Math.abs(originalTop - newTop) > 1)) {
scrollParents = containingElement ? getScrollParents(containingElement, true) : [];
for (let scrollParent of scrollParents) {
scrollIntoView(scrollParent as HTMLElement, containingElement as HTMLElement, {block: 'center', inline: 'center'});
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we added the bypass for chrome, this now also supports alternative alignments, to center the containingElement. This was missing in the previous PR also 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nwidynski still in the middle of looking at the code, but I tested against the story that https://github.com/adobe/react-spectrum/pull/7717/changes#diff-b741e806d7e03bb7f42427dbe388c23128044ae2c96c612a9bceb9852ec408ab added and it seems to cause some strange scroll behaviors when attempting to use ArrowDown) to scroll an out of view item into view (for instance, Item 20 in that story). It seems to be hitting the last bit of logic in https://github.com/nwidynski/react-spectrum/blob/fc0f5a0fb1c2e15fd9d2d82a67b39feb0d350a89/packages/%40react-aria/utils/src/scrollIntoView.ts#L150, and attempts to scroll the item with respect to the gridlist itself which isn't actually scrollable itself in this story example. Rather, the wrapping div around the gridlist itself is scrollable here so theoretically we should've skipped the gridlist in favor of scrolling of scrolling the item against the wrapping div.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, I suppose in that particular story, the scrollRef of the gridlist is inaccurate because of this wrapping div configuration (should be the wrapping div), perhaps we should allow configuring that...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's been suggested as well #3489
Also related #3965

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for finding those issues. Its a bit unfortunate that without being able to set the scrollRef that this PR's change breaks the above use case, even though it had only worked previously by happenstance (aka we only called scrollIntoView once rather than accounting for the sub-pixel rounding).

if (!isScrollPrevented) {
scrollParents.push(root);
}
let scrollParents = getScrollParents(targetElement, true);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noting that this does not behave like the native equivalent, since it skips overflow:hidden scroll parents. This potential issue also existed before this PR, so I don't think it's a blocker.

Comment on lines 80 to 84
if (shouldScrollBlock && block === 'start') {
y += scrollAreaLeft - scrollPortLeft;
} else if (shouldScrollBlock && block === 'center') {
y += (scrollAreaTop + scrollAreaBottom) / 2 - (scrollPortTop + scrollPortBottom) / 2;
} else if (shouldScrollBlock && block === 'end') {
Copy link
Contributor Author

@nwidynski nwidynski Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Native scrollIntoView only checks for shouldScroll with the nearest alignment option. Should we also do that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can keep this as is, IMO this utility doesn't need to mirror the native behavior and I think we'd prefer more of a scrollIntoViewIfNeeded behavior anyways. Any concerns or thoughts from your end?

Copy link
Contributor Author

@nwidynski nwidynski Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I'm not sure whether this is a public utility or internal. If it's public I think we should aim for closer parity, but provide an option to opt-in or out of IfNeeded behavior. One other issue where this is also not pairing the native version is that intermediate scrollParents below the scrollView also do not get scrolled. I think it would make sense to push the scrollParents loop into scrollIntoView to also align there.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

honestly I'd classify this more of an internal utility (and thus can be more opinionated) since we never documented it for external usage, but it is a bit of a gray area. Making it a public utility and adding an option for IfNeeded would be nice, but it comes with the extra baggage of committing it to being public API and possibly increasing scope/maintenance depending on how much we'd want it to behave like native (as well as maintain behavior with native if that changes). Happy for the rest of the team to weigh in.

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did some preliminary testing and the behavior looks good to me, thanks for the fix! I still need to sit down and go through all the math still haha, but I'll see about getting this into our upcoming test session so we can put it through its paces. Just to share timelines, the team is limited on bandwidth due to some pushes on some end of year docs work so realistically we may not get this in till the new year (which might be when the next release is anyways). Thanks once again for the through fix!

@nwidynski
Copy link
Contributor Author

nwidynski commented Nov 5, 2025

@LFDanLu No problem, i figure that implies the same for the pending review of the ScrollView refactor? At least I won't have much to maintain as long as focus largely remains on docs, haha. You would do me much more of a favor with preliminary feedback to the Carousel RFC, so I could come strong out the gate for the new year with a draft PR ready :)

Carousel's implementation is really quite a lot and I would love to avoid losing most of the detailed implementation insights i currently still got in short term memory 😅

@LFDanLu
Copy link
Member

LFDanLu commented Nov 5, 2025

@nwidynski That's right, the team is currently focused on new docs + getting S2 out of pre-release so we don't have many spare cycles. Thank you for letting me know the Carousel RFC is a higher priority, I'll see if I can get at least a preliminary review by the team squeezed in.

Comment on lines +47 to +50
let viewTop = scrollView === root ? 0 : view.top;
let viewBottom = scrollView === root ? scrollView.clientHeight : view.bottom;
let viewLeft = scrollView === root ? 0 : view.left;
let viewRight = scrollView === root ? scrollView.clientWidth : view.right;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snowystinger Related to https://github.com/adobe/react-spectrum/pull/7717/files#r1999951343, let me know if we need support for pinch-zoom here.

@nwidynski
Copy link
Contributor Author

nwidynski commented Jan 13, 2026

Just wanted to give this a bump so it doesn't get lost. Excuse me if you guys are not back to being fully staffed yet 😅

@LFDanLu
Copy link
Member

LFDanLu commented Feb 3, 2026

I'll see about picking this back up soon hopefully, we ran into something similar via #7717

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wip review

Comment on lines -18 to +19
while (node && node !== document.documentElement) {
do {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noticed that with this change that if checkForOverflow is false, we'd return the root element as one of the scroll parentElements, even if the node provided is the root element. I don't think that case happens right now of in the code since I think we always check for overflow, but was curious what prompted this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I can follow why that is just the case for checkForOverflow=false, since the intention, iirc, was for it to always be included. We pass true in scrollIntoView solely as a runtime optimization to skip unscrollable parent elements, not out of necessity.

Basically all of what this changeset aims to do, is to move the scrollParents.push(root) of scrollIntoView into this helper method. I felt like this is the more appropriate place, in case somebody starts using this helper elsewhere - same goes for isScrollable which previously didn't work for the root element.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial hang up on was that it might be unexpected if someone was previously using this util and didn't expect root elements to be included (though to be fair we never documented this util and I would consider it as an internal util). From what I can tell, I think the original utility and scrollIntoView logic actually didn't want to include the root since it was only handling the overlay case where we had prevented scrolling from happening on the body anyways but with the changes in the PR that assumption no longer holds up so this is probably fine on second thought.

Comment on lines +145 to +152
let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect();
// Account for sub pixel differences from rounding
if ((Math.abs(originalLeft - newLeft) > 1) || (Math.abs(originalTop - newTop) > 1)) {
scrollParents = containingElement ? getScrollParents(containingElement, true) : [];
for (let scrollParent of scrollParents) {
scrollIntoView(scrollParent as HTMLElement, containingElement as HTMLElement, {block: 'center', inline: 'center'});
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nwidynski still in the middle of looking at the code, but I tested against the story that https://github.com/adobe/react-spectrum/pull/7717/changes#diff-b741e806d7e03bb7f42427dbe388c23128044ae2c96c612a9bceb9852ec408ab added and it seems to cause some strange scroll behaviors when attempting to use ArrowDown) to scroll an out of view item into view (for instance, Item 20 in that story). It seems to be hitting the last bit of logic in https://github.com/nwidynski/react-spectrum/blob/fc0f5a0fb1c2e15fd9d2d82a67b39feb0d350a89/packages/%40react-aria/utils/src/scrollIntoView.ts#L150, and attempts to scroll the item with respect to the gridlist itself which isn't actually scrollable itself in this story example. Rather, the wrapping div around the gridlist itself is scrollable here so theoretically we should've skipped the gridlist in favor of scrolling of scrolling the item against the wrapping div.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity were these calculations based off any existing work? (math is hard, and this kind of code always has small pitfalls/non-obvious considerations that reminds me of the grueling overlay positioning code where I always have to start drawing diagrams and what not)

Copy link
Contributor Author

@nwidynski nwidynski Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of it was honestly based on first principle thinking, which was done within the exploration and prep work for the Carousel RFC. Since ScrollDelegate operates within the constraints of the LayoutDelegate interface, and especially with my work on #8696, I already had to come up with the math anyways - and I was already drawing diagrams too 😅

This is why I suggested in that RFC review of yours, that deeper integration might be able to replace scrollIntoView. Looking at the library, which Rob linked in https://github.com/adobe/react-spectrum/pull/7717/changes#r1999951343, I think the calculations largely appear very similar.

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will have to discuss https://github.com/adobe/react-spectrum/pull/9146/changes#r2766507693 with the team since although technically not supported, the scrollable wrapping div case did happen to "work" previously. Otherwise the logic looks good, will see if we can do some more testing in next weeks testing sesion

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied a fix for something we noticed in testing (didn't account for scrollbars) but otherwise looks good! Sorry about the delay

@LFDanLu LFDanLu added this pull request to the merge queue Feb 10, 2026
Merged via the queue into adobe:main with commit fd9e5f6 Feb 10, 2026
27 checks passed
@nwidynski
Copy link
Contributor Author

nwidynski commented Feb 10, 2026

@LFDanLu Thanks for noticing that. I could've sworn to have thought about scrollbar dimensions like 10 times while implementing this, haha. In any way, I've followed up in #9634 since that fix would have broken down in some edge cases.

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.

3 participants