Skip to content

Add scrollIntoView to fragment instances #32814

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

jackpope
Copy link
Member

@jackpope jackpope commented Apr 3, 2025

This adds experimental_scrollIntoView(alignToTop). It doesn't yet support scrollIntoView(options).

Cases:

  • No host children: Without host children, we represent the virtual space of the Fragment by attempting to scroll to the nearest edge by using its siblings. If the preferred sibling is not found, we'll try the other side, and then the parent.
  • 1 host child: The simplest case where its the equivalent of calling the method on the child element directly
  • Multiple host children in same scroll container:
    • Here we find the first child in the list for alignToTop=true|undefined or the last child alignToTop=false. We call scroll on that element.
  • Multiple host children in multiple scroll containers (fixed positioning or portal-ed into other containers):
    • In order to handle the possibility of children being fixed or portal-ed, where the assumption is that isn't where you want to stop scroll, we work through groups of host children by scroll container and may scroll to multiple elements.
    • scrollIntoView will only be called again if scrolling to the next element wouldn't scroll the previous one out of the viewport.
    • alignToTop=true means iterate in reverse, scrolling the first child of each container
    • alignToTop=false means iterate in normal order, scrolling the last child of each container

Due to the complexity of multiple scroll containers and dealing with portals, I've added this under a separate feature flag with an experimental prefix. We may stabilize it along with the other APIs, but this allows us to not block the whole feature on it.

@github-actions github-actions bot added the React Core Team Opened by a member of the React Core Team label Apr 3, 2025
fiber => {
const hostNode = getHostNodeFromHostFiber(fiber);
const position = getComputedStyle(hostNode).position;
return position === 'sticky' || position === 'fixed';
Copy link
Collaborator

Choose a reason for hiding this comment

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

If a child is sticky or fixed, we really shouldn't need to call scrollIntoView on them because they're always in the viewport. At least fixed. Where as sticky might need it but not because it is sticky but because its parent might be outside the viewport which makes it no different from other nodes.

What makes a group interesting is really the parent of the hostNode, not the hostNode itself. It's the question of whether calling it would be able to shift a different parent than another node.

Unfortunately, the scrollable parent might not be the immediate DOM node parent. It might be any of the parents. In fact, commonly it's the root document.documentElement.

The other issue is that we'd have to call getComputedStyle(...) (checking overflow and position) on every parent above to figure out if it would. However, we can be smarter than that. To know if two nodes are in different scroll parents we only have to answer the question if there are any scrollable things between the shared common ancestor and each of the nodes.

In the common case the shared common ancestor is the parent node and so there are nothing in between and so no need to make a getComputedStyle call. Worst case something is like a portal in document.body whose child is not position stick/fixed and a deep node sibling. In that case we'd have to check every parent of the deep node to figure out if there's a scroll between. But this would be very unusual.

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated to walk up the DOM tree checking for scrollable containers between the instance and the common ancestor with the last instance checked as a way of partitioning the elements.

@sebmarkbage
Copy link
Collaborator

An interesting case is something like:

<div style={{ overflow: 'scroll' }}>
  <div id="a"></div>
  <div style={{ overflow: 'scroll' }}>
     <div id="b"></div>
  </div>
</div>

Then you have a fragment with two portals that render into a and b.

Showing both would require scrolling both the child inside a and the child inside b into view. However, if you call it on a and then b you might scroll a out of view as a result. You could flip it and scroll b into view first and then a since in general we want to show the top edge.

I think technically scrolling b into view is not technically required by the API since we don't guarantee all children all visible since that may be impossible. However, if we didn't then if a is like a portal into a toolbar (for example a breadcrumb) then just showing that isn't sufficient to show the primary content which is in a nested scroll below the toolbar.

So I do think we need to treat this case as first scrolling b into view and then scrolling a into view to attempt to show both.

@jackpope
Copy link
Member Author

@sebmarkbage I added a test case like the two portals example and also added a portal into a scroll container in the fixture. And also changed the semantics around the ordering of scrolling to each container based on alignToTop. alignToTop true (default call) will scroll to the first element in each container, in reverse order. alignToTop false will scroll to the last item in each container in top down order. This follows the example you showed and works in the current fixture.

The remaining thing is checking the viewport to see if we can scroll to the next container without pushing the current one out. I'll work on that but might make it another PR.

@jackpope jackpope force-pushed the fragment-refs-scrollintoview branch from 51e0162 to faecda7 Compare July 14, 2025 15:18
@react-sizebot
Copy link

react-sizebot commented Jul 14, 2025

Comparing: ac7820a...5ede055

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.68 kB 6.68 kB = 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 530.16 kB 530.16 kB = 93.39 kB 93.39 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB = 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js +0.88% 654.53 kB 660.27 kB +0.92% 115.11 kB 116.17 kB
facebook-www/ReactDOM-prod.classic.js +0.91% 674.30 kB 680.43 kB +0.99% 118.29 kB 119.46 kB
facebook-www/ReactDOM-prod.modern.js +0.92% 664.73 kB 670.86 kB +0.97% 116.64 kB 117.77 kB
oss-experimental/react-reconciler/cjs/react-reconciler-reflection.production.js +9.67% 9.93 kB 10.90 kB +8.22% 2.38 kB 2.58 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler-reflection.production.js +9.67% 9.93 kB 10.90 kB +8.22% 2.38 kB 2.58 kB
oss-stable/react-reconciler/cjs/react-reconciler-reflection.production.js +9.67% 9.93 kB 10.90 kB +8.22% 2.38 kB 2.58 kB
oss-experimental/react-reconciler/cjs/react-reconciler-reflection.development.js +9.60% 11.22 kB 12.30 kB +8.74% 2.44 kB 2.65 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler-reflection.development.js +9.60% 11.22 kB 12.30 kB +8.74% 2.44 kB 2.65 kB
oss-stable/react-reconciler/cjs/react-reconciler-reflection.development.js +9.60% 11.22 kB 12.30 kB +8.74% 2.44 kB 2.65 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/react-reconciler/cjs/react-reconciler-reflection.production.js +9.67% 9.93 kB 10.90 kB +8.22% 2.38 kB 2.58 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler-reflection.production.js +9.67% 9.93 kB 10.90 kB +8.22% 2.38 kB 2.58 kB
oss-stable/react-reconciler/cjs/react-reconciler-reflection.production.js +9.67% 9.93 kB 10.90 kB +8.22% 2.38 kB 2.58 kB
oss-experimental/react-reconciler/cjs/react-reconciler-reflection.development.js +9.60% 11.22 kB 12.30 kB +8.74% 2.44 kB 2.65 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler-reflection.development.js +9.60% 11.22 kB 12.30 kB +8.74% 2.44 kB 2.65 kB
oss-stable/react-reconciler/cjs/react-reconciler-reflection.development.js +9.60% 11.22 kB 12.30 kB +8.74% 2.44 kB 2.65 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-prod.js +1.06% 577.59 kB 583.73 kB +1.17% 101.51 kB 102.70 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-prod.js +1.05% 583.09 kB 589.23 kB +1.16% 102.59 kB 103.78 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-profiling.js +0.94% 649.50 kB 655.63 kB +1.06% 111.17 kB 112.35 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-profiling.js +0.94% 655.44 kB 661.57 kB +1.05% 112.31 kB 113.50 kB
facebook-www/ReactDOM-prod.modern.js +0.92% 664.73 kB 670.86 kB +0.97% 116.64 kB 117.77 kB
facebook-www/ReactDOM-prod.classic.js +0.91% 674.30 kB 680.43 kB +0.99% 118.29 kB 119.46 kB
facebook-www/ReactDOMTesting-prod.modern.js +0.90% 679.13 kB 685.26 kB +0.93% 120.28 kB 121.40 kB
facebook-www/ReactDOMTesting-prod.classic.js +0.89% 688.70 kB 694.84 kB +0.91% 121.92 kB 123.03 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js +0.88% 654.53 kB 660.27 kB +0.92% 115.11 kB 116.17 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.production.js +0.86% 668.94 kB 674.68 kB +0.88% 118.68 kB 119.73 kB
facebook-www/ReactDOM-profiling.modern.js +0.83% 739.39 kB 745.52 kB +0.89% 126.80 kB 127.93 kB
facebook-www/ReactDOM-profiling.classic.js +0.82% 747.49 kB 753.63 kB +0.88% 128.17 kB 129.30 kB
oss-experimental/react-dom/cjs/react-dom-profiling.profiling.js +0.80% 720.15 kB 725.88 kB +0.85% 124.77 kB 125.82 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-dev.js +0.65% 1,117.67 kB 1,124.92 kB +0.72% 184.46 kB 185.79 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-dev.js +0.64% 1,133.94 kB 1,141.19 kB +0.71% 187.25 kB 188.59 kB
facebook-www/ReactDOM-dev.modern.js +0.59% 1,237.62 kB 1,244.87 kB +0.64% 204.02 kB 205.33 kB
facebook-www/ReactDOM-dev.classic.js +0.58% 1,246.79 kB 1,254.04 kB +0.63% 205.76 kB 207.05 kB
facebook-www/ReactDOMTesting-dev.modern.js +0.58% 1,254.16 kB 1,261.40 kB +0.64% 207.69 kB 209.03 kB
facebook-www/ReactDOMTesting-dev.classic.js +0.57% 1,263.33 kB 1,270.57 kB +0.64% 209.41 kB 210.75 kB
oss-experimental/react-dom/cjs/react-dom-client.development.js +0.56% 1,202.53 kB 1,209.29 kB +0.63% 200.30 kB 201.55 kB
oss-experimental/react-dom/cjs/react-dom-profiling.development.js +0.55% 1,218.91 kB 1,225.67 kB +0.61% 203.11 kB 204.35 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.development.js +0.55% 1,219.08 kB 1,225.83 kB +0.62% 203.91 kB 205.18 kB

Generated by 🚫 dangerJS against 5ede055

@jackpope jackpope force-pushed the fragment-refs-scrollintoview branch from faecda7 to 22f9647 Compare July 14, 2025 19:20
This adds `scrollIntoView(alignToTop)`. It doesn't yet support `scrollIntoView(options)`.

Cases:
- No host children: Without host children, we represent the virtual space of the Fragment by attempting to scroll to the nearest edge by using its siblings. If the preferred sibling is not found, we'll try the other side, and then the parent.
- 1 host child: The simplest case where its the equivalent of calling the method on the child element directly
- Multiple host children in same scroll container:
    - Here we find the first child in the list for `alignToTop=true|undefined` or the last child `alignToTop=false`. We call scroll on that element.
- Multiple host children in multiple scroll containers (fixed positioning or portal-ed into other containers):
	- In order to handle the possibility of children being fixed or portal-ed, where the assumption is that isn't where you want to stop scroll, we work through groups of host children by scroll container and may scroll to multiple elements.
	- `scrollIntoView` will only be called again if scrolling to the next element wouldn't scroll the previous one out of the viewport.
	- `alignToTop=true` means iterate in reverse, scrolling the first child of each container
	- `alignToTop=false` means iterate in normal order, scrolling the last child of each container
@jackpope jackpope force-pushed the fragment-refs-scrollintoview branch from 03c0543 to 5ede055 Compare August 11, 2025 21:05
};
}

function isInstanceScrollable(inst: Instance): 0 | 1 | 2 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would be nice to have each of these constants represented by something named. The pattern I use elsewhere is:

const A = 0;
const B = 1;
const C = 2;
type Alphabet = 0 | 1 | 2;

function fn(): Alphabet {
}

getFragmentParentHostFiber(this._fragmentFiber);
if (targetFiber === null) {
if (__DEV__) {
console.error(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe a warn is sufficient? This might be a mistake but it's technically not invalid. You might not know and just optimistically do it.

children: Array<Fiber>,
alignToTop: boolean,
): void {
if (children.length === 0) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

You've really already checked this before calling this function. That's also the only caller, so this extra check is unnecessary.

while (i !== (alignToTop ? -2 : children.length + 1)) {
const isLastGroup = i < 0 || i >= children.length;
// 1 = fixed, 2 = scrollable, 0 = neither
let isNewScrollContainer: null | 0 | 1 | 2 = null;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I try to avoid mixing numbers and null since VMs optimize numeric only values differently sometimes. Especially small ones. Another option might be -1.


if (!isLastGroup) {
// Start a new group
currentGroupEnd = i;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This comment is a little confusing because it's not really starting a new group is it? It's just updating the end of the current group.

Since after the isLastGroup is true you're not going to use this value anymore, you don't really need to check that condition at all.

In fact, do you even need this variable to stick around? Isn't always just alignToTop ? i + 1 : i - 1] like you do when getting prevChild anyway.

if (alignToTop) {
childToScrollIndex = isLastGroup ? 0 : currentGroupEnd;
} else {
childToScrollIndex = currentGroupEnd;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Seems suspicious that this doesn't have a isLastGroup check unlike the other one. Seems like they'd need parity in either direction.


// Check if scrolling to current element would push previous element out of viewport
// alignToTop=true: current goes to top, check if prev would still be visible below
// alignToTop=false: current goes to bottom, check if prev would still be visible above
Copy link
Collaborator

Choose a reason for hiding this comment

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

There's something fishy about this logic because you're not accounting for which parents will end up being the ones scrolling.

Like if they're independently scrolling parents.


// Loop through the children, order dependent on alignToTop
// Each time we reach a new scroll container, we look back at the last one
// and scroll the first or last child in that container, depending on alignToTop
Copy link
Collaborator

Choose a reason for hiding this comment

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

I guess one issue with this algorithm is that it doesn't account for groups interleaving. E.g.

<div />
{portal}
<div />

This would end up scrolling the first div, then the portal then the second div as if it's a new which is itself not really an issue if we assume that it has to scroll at all because the second scroll will override the first one.

However, that makes me wonder if the complex logic is needed at all or if it's as simple as just calling scrollIntoView on every child in reverse order?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants