From 716ccf88622161e268943eaef3dd821ea6e3bb9d Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Thu, 27 Mar 2025 14:29:35 -0400 Subject: [PATCH 1/4] Add scrollIntoView to fragment instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../fixtures/fragment-refs/FocusCase.js | 2 +- .../fragment-refs/GetClientRectsCase.js | 2 +- .../fragment-refs/ScrollIntoViewCase.js | 192 +++++++++ .../ScrollIntoViewCaseComplex.js | 50 +++ .../fragment-refs/ScrollIntoViewCaseSimple.js | 14 + .../ScrollIntoViewTargetElement.js | 18 + .../fixtures/fragment-refs/index.js | 2 + fixtures/dom/src/index.js | 15 +- .../src/client/ReactFiberConfigDOM.js | 307 +++++++++++++-- .../__tests__/ReactDOMFragmentRefs-test.js | 372 ++++++++++++++++++ .../src/__tests__/utils/IntersectionMocks.js | 22 ++ .../src/ReactFiberTreeReflection.js | 50 +++ scripts/error-codes/codes.json | 3 +- 13 files changed, 1008 insertions(+), 41 deletions(-) create mode 100644 fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCase.js create mode 100644 fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCaseComplex.js create mode 100644 fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCaseSimple.js create mode 100644 fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewTargetElement.js diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js index baff30895c0e..efd9157b4a5c 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js @@ -3,7 +3,7 @@ import Fixture from '../../Fixture'; const React = window.React; -const {Fragment, useEffect, useRef, useState} = React; +const {Fragment, useRef} = React; export default function FocusCase() { const fragmentRef = useRef(null); diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/GetClientRectsCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/GetClientRectsCase.js index 7b20a0a2e0d6..563f2ad05429 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/GetClientRectsCase.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/GetClientRectsCase.js @@ -2,7 +2,7 @@ import TestCase from '../../TestCase'; import Fixture from '../../Fixture'; const React = window.React; -const {Fragment, useEffect, useRef, useState} = React; +const {Fragment, useRef, useState} = React; export default function GetClientRectsCase() { const fragmentRef = useRef(null); diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCase.js new file mode 100644 index 000000000000..d71500e3b6cf --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCase.js @@ -0,0 +1,192 @@ +import TestCase from '../../TestCase'; +import Fixture from '../../Fixture'; +import ScrollIntoViewCaseComplex from './ScrollIntoViewCaseComplex'; +import ScrollIntoViewCaseSimple from './ScrollIntoViewCaseSimple'; +import ScrollIntoViewTargetElement from './ScrollIntoViewTargetElement'; + +const React = window.React; +const {Fragment, useRef, useState, useEffect} = React; +const ReactDOM = window.ReactDOM; + +function Controls({ + alignToTop, + setAlignToTop, + scrollVertical, + exampleType, + setExampleType, +}) { + return ( +
+ +
+ +
+
+ +
+
+ ); +} + +export default function ScrollIntoViewCase() { + const [exampleType, setExampleType] = useState('simple'); + const [alignToTop, setAlignToTop] = useState(true); + const [caseInViewport, setCaseInViewport] = useState(false); + const fragmentRef = useRef(null); + const testCaseRef = useRef(null); + const noChildRef = useRef(null); + const scrollContainerRef = useRef(null); + + const scrollVertical = () => { + fragmentRef.current.scrollIntoView(alignToTop); + }; + + const scrollVerticalNoChildren = () => { + noChildRef.current.scrollIntoView(alignToTop); + }; + + useEffect(() => { + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + setCaseInViewport(true); + } else { + setCaseInViewport(false); + } + }); + }); + testCaseRef.current.observeUsing(observer); + + const lastRef = testCaseRef.current; + return () => { + lastRef.unobserveUsing(observer); + observer.disconnect(); + }; + }); + + return ( + + + +
  • Toggle alignToTop and click the buttons to scroll
  • +
    + +

    When the Fragment has children:

    +

    + The simple path is that all children are in the same scroll + container. If alignToTop=true|undefined, we will select the first + Fragment host child to call scrollIntoView on. Otherwise we'll call + on the last host child. +

    +

    + In the case of fixed elements and inserted elements or portals + causing fragment siblings to be in different scroll containers, we + split up the host children into groups of scroll containers. If we + hit a fixed element, we'll always attempt to scroll on the first or + last element of the next group, depending on alignToTop value. +

    +

    When the Fragment does not have children:

    +

    + The Fragment still represents a virtual space. We can scroll to the + nearest edge by selecting the host sibling before if + alignToTop=false, or after if alignToTop=true|undefined. We'll fall + back to the other sibling or parent in the case that the preferred + sibling target doesn't exist. +

    +
    + + + + + {exampleType === 'simple' && ( + + + + )} + {exampleType === 'horizontal' && ( +
    + + + +
    + )} + {exampleType === 'multiple' && ( + +
    + + + + + )} + {exampleType === 'empty' && ( + + + + + + )} + + + + + + + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCaseComplex.js b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCaseComplex.js new file mode 100644 index 000000000000..a0ea612d09c4 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCaseComplex.js @@ -0,0 +1,50 @@ +import ScrollIntoViewTargetElement from './ScrollIntoViewTargetElement'; + +const React = window.React; +const {Fragment, useRef, useState, useEffect} = React; +const ReactDOM = window.ReactDOM; + +export default function ScrollIntoViewCaseComplex({ + caseInViewport, + scrollContainerRef, +}) { + const [didMount, setDidMount] = useState(false); + // Hack to portal child into the scroll container + // after the first render. This is to simulate a case where + // an item is portaled into another scroll container. + useEffect(() => { + if (!didMount) { + setDidMount(true); + } + }, []); + return ( + + {caseInViewport && ( + + )} + {didMount && + ReactDOM.createPortal( + , + scrollContainerRef.current + )} + + + + {caseInViewport && ( + + )} + + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCaseSimple.js b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCaseSimple.js new file mode 100644 index 000000000000..ee61cd16290f --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCaseSimple.js @@ -0,0 +1,14 @@ +import ScrollIntoViewTargetElement from './ScrollIntoViewTargetElement'; + +const React = window.React; +const {Fragment} = React; + +export default function ScrollIntoViewCaseSimple() { + return ( + + + + + + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewTargetElement.js b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewTargetElement.js new file mode 100644 index 000000000000..f61668c5cf52 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewTargetElement.js @@ -0,0 +1,18 @@ +const React = window.React; + +export default function ScrollIntoViewTargetElement({color, id, top}) { + return ( +
    + {id} +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/index.js b/fixtures/dom/src/components/fixtures/fragment-refs/index.js index 23b440938cf7..c560b59fbec6 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/index.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/index.js @@ -5,6 +5,7 @@ import IntersectionObserverCase from './IntersectionObserverCase'; import ResizeObserverCase from './ResizeObserverCase'; import FocusCase from './FocusCase'; import GetClientRectsCase from './GetClientRectsCase'; +import ScrollIntoViewCase from './ScrollIntoViewCase'; const React = window.React; @@ -17,6 +18,7 @@ export default function FragmentRefsPage() { + ); } diff --git a/fixtures/dom/src/index.js b/fixtures/dom/src/index.js index 7a23ba2acf94..a334311be0c4 100644 --- a/fixtures/dom/src/index.js +++ b/fixtures/dom/src/index.js @@ -2,14 +2,23 @@ import './polyfills'; import loadReact, {isLocal} from './react-loader'; if (isLocal()) { - Promise.all([import('react'), import('react-dom/client')]) - .then(([React, ReactDOMClient]) => { - if (React === undefined || ReactDOMClient === undefined) { + Promise.all([ + import('react'), + import('react-dom'), + import('react-dom/client'), + ]) + .then(([React, ReactDOM, ReactDOMClient]) => { + if ( + React === undefined || + ReactDOM === undefined || + ReactDOMClient === undefined + ) { throw new Error( 'Unable to load React. Build experimental and then run `yarn dev` again' ); } window.React = React; + window.ReactDOM = ReactDOM; window.ReactDOMClient = ReactDOMClient; }) .then(() => import('./components/App')) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index e3dac2e27e20..c888039a9de8 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -37,17 +37,6 @@ import {runWithFiberInDEV} from 'react-reconciler/src/ReactCurrentFiber'; import hasOwnProperty from 'shared/hasOwnProperty'; import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion'; import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols'; -import { - isFiberContainedByFragment, - isFiberFollowing, - isFiberPreceding, - isFragmentContainedByFiber, - traverseFragmentInstance, - getFragmentParentHostFiber, - getInstanceFromHostFiber, - traverseFragmentInstanceDeeply, - fiberIsPortaledIntoHost, -} from 'react-reconciler/src/ReactFiberTreeReflection'; export { setCurrentUpdatePriority, @@ -69,6 +58,18 @@ import { markNodeAsHoistable, isOwnedInstance, } from './ReactDOMComponentTree'; +import { + traverseFragmentInstance, + getFragmentParentHostFiber, + getInstanceFromHostFiber, + isFiberFollowing, + isFiberPreceding, + getFragmentInstanceSiblings, + traverseFragmentInstanceDeeply, + fiberIsPortaledIntoHost, + isFiberContainedByFragment, + isFragmentContainedByFiber, +} from 'react-reconciler/src/ReactFiberTreeReflection'; import {compareDocumentPositionForEmptyFragment} from 'shared/ReactDOMFragmentRefShared'; export {detachDeletedInstance}; @@ -2813,6 +2814,7 @@ export type FragmentInstanceType = { composed: boolean, }): Document | ShadowRoot | FragmentInstanceType, compareDocumentPosition(otherNode: Instance): number, + scrollIntoView(alignToTop?: boolean): void, }; function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) { @@ -2899,6 +2901,38 @@ function removeEventListenerFromChild( instance.removeEventListener(type, listener, optionsOrUseCapture); return false; } +function normalizeListenerOptions( + opts: ?EventListenerOptionsOrUseCapture, +): string { + if (opts == null) { + return '0'; + } + + if (typeof opts === 'boolean') { + return `c=${opts ? '1' : '0'}`; + } + + return `c=${opts.capture ? '1' : '0'}&o=${opts.once ? '1' : '0'}&p=${opts.passive ? '1' : '0'}`; +} +function indexOfEventListener( + eventListeners: Array, + type: string, + listener: EventListener, + optionsOrUseCapture: void | EventListenerOptionsOrUseCapture, +): number { + for (let i = 0; i < eventListeners.length; i++) { + const item = eventListeners[i]; + if ( + item.type === type && + item.listener === listener && + normalizeListenerOptions(item.optionsOrUseCapture) === + normalizeListenerOptions(optionsOrUseCapture) + ) { + return i; + } + } + return -1; +} // $FlowFixMe[prop-missing] FragmentInstance.prototype.dispatchEvent = function ( this: FragmentInstanceType, @@ -3214,38 +3248,241 @@ function validateDocumentPositionWithFiberTree( return false; } -function normalizeListenerOptions( - opts: ?EventListenerOptionsOrUseCapture, -): string { - if (opts == null) { - return '0'; +// $FlowFixMe[prop-missing] +FragmentInstance.prototype.scrollIntoView = function ( + this: FragmentInstanceType, + alignToTop?: boolean, +): void { + if (typeof alignToTop === 'object') { + throw new Error( + 'FragmentInstance.scrollIntoView() does not support ' + + 'scrollIntoViewOptions. Use the alignToTop boolean instead.', + ); } + // First, get the children nodes + const children: Array = []; + traverseFragmentInstance(this._fragmentFiber, collectChildren, children); - if (typeof opts === 'boolean') { - return `c=${opts ? '1' : '0'}`; + // If there are no children, we can use the parent and siblings to determine a position + if (children.length === 0) { + const hostSiblings = getFragmentInstanceSiblings(this._fragmentFiber); + const targetFiber = + (alignToTop === false + ? hostSiblings[0] || hostSiblings[1] + : hostSiblings[1] || hostSiblings[0]) || + getFragmentParentHostFiber(this._fragmentFiber); + if (targetFiber === null) { + if (__DEV__) { + console.error( + 'You are attempting to scroll a FragmentInstance that has no ' + + 'children, siblings, or parent. No scroll was performed.', + ); + } + return; + } + const target = getInstanceFromHostFiber(targetFiber); + target.scrollIntoView(alignToTop); + return; } - return `c=${opts.capture ? '1' : '0'}&o=${opts.once ? '1' : '0'}&p=${opts.passive ? '1' : '0'}`; + // If there are children, handle them per scroll container + scrollIntoViewByScrollContainer(children, alignToTop !== false); +}; + +function isInstanceScrollable(inst: Instance): 0 | 1 | 2 { + const style = getComputedStyle(inst); + + if (style.position === 'fixed') { + return 1; + } + + if ( + style.overflow === 'auto' || + style.overflow === 'scroll' || + style.overflowY === 'auto' || + style.overflowY === 'scroll' || + style.overflowX === 'auto' || + style.overflowX === 'scroll' + ) { + return 2; + } + + return 0; } -function indexOfEventListener( - eventListeners: Array, - type: string, - listener: EventListener, - optionsOrUseCapture: void | EventListenerOptionsOrUseCapture, -): number { - for (let i = 0; i < eventListeners.length; i++) { - const item = eventListeners[i]; - if ( - item.type === type && - item.listener === listener && - normalizeListenerOptions(item.optionsOrUseCapture) === - normalizeListenerOptions(optionsOrUseCapture) - ) { - return i; +function searchDOMUntilCommonAncestor( + instA: Instance, + instB: Instance, + testFn: (instA: Instance) => T, +): T | null { + // Walk up from instA and count depth + let currentNode: ?Instance = instA; + let depthA = 0; + while (currentNode) { + const result = testFn(currentNode); + if (result) { + return result; } + depthA++; + currentNode = currentNode.parentElement; + } + + // Walk up from instB and count depth + currentNode = instB; + let depthB = 0; + while (currentNode) { + const result = testFn(currentNode); + if (result) { + return result; + } + + depthB++; + currentNode = currentNode.parentElement; + } + + // Reset currentNode to instA and instB + let nodeA: ?Instance = instA; + let nodeB: ?Instance = instB; + + // Align depths + while (depthA > depthB && nodeA) { + nodeA = nodeA.parentElement; + depthA--; + } + while (depthB > depthA && nodeB) { + nodeB = nodeB.parentElement; + depthB--; + } + + // Walk up both nodes to find common ancestor + while (nodeA && nodeB) { + if (nodeA === nodeB) { + return testFn(nodeA); + } + nodeA = nodeA.parentElement; + nodeB = nodeB.parentElement; + } + + return null; +} + +function maybeScrollContainerIntoView( + currentInstance: Instance, + prevInstance: Instance | null, + alignToTop: boolean, + prevContainerIsFixed: boolean, +): boolean { + if (prevInstance === null || prevContainerIsFixed) { + currentInstance.scrollIntoView(alignToTop); + return true; + } + + const currentRect = currentInstance.getBoundingClientRect(); + const prevRect = prevInstance.getBoundingClientRect(); + + // 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 + const canScrollVertical = alignToTop + ? currentRect.top + window.innerHeight > prevRect.top + : currentRect.bottom - window.innerHeight < prevRect.bottom; + const canScrollHorizontal = alignToTop + ? currentRect.left + window.innerWidth > prevRect.left + : currentRect.right - window.innerWidth < prevRect.right; + + if (canScrollVertical && canScrollHorizontal) { + currentInstance.scrollIntoView(alignToTop); + return true; + } + + return false; +} + +function scrollIntoViewByScrollContainer( + children: Array, + alignToTop: boolean, +): void { + if (children.length === 0) { + return; + } + + // 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 + // 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 + let prevScrolledInstance = null; + let prevContainerIsFixed = false; + let currentGroupEnd = alignToTop ? children.length - 1 : 0; + + let i = alignToTop ? children.length - 1 : 0; + // We extend the loop one iteration beyond the actual children to handle the last group + 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; + + if (isLastGroup) { + // We're past the end, treat as new scroll container to complete the last group + isNewScrollContainer = 2; + } else { + const child = children[i]; + const instance = getInstanceFromHostFiber(child); + const prevChild = children[alignToTop ? i + 1 : i - 1]; + + if (prevChild) { + const prevInstance = getInstanceFromHostFiber(prevChild); + if (prevInstance.parentNode === instance.parentNode) { + // If these are DOM siblings, check if either is fixed + isNewScrollContainer = + isInstanceScrollable(prevInstance) === 1 || + isInstanceScrollable(instance) === 1 + ? 1 + : 0; + } else { + isNewScrollContainer = searchDOMUntilCommonAncestor( + instance, + prevInstance, + isInstanceScrollable, + ); + } + } + } + + if (isNewScrollContainer) { + // We found a new scroll container, so scroll the appropriate child from the previous group + let childToScrollIndex; + if (alignToTop) { + childToScrollIndex = isLastGroup ? 0 : currentGroupEnd; + } else { + childToScrollIndex = currentGroupEnd; + } + + if (childToScrollIndex >= 0 && childToScrollIndex < children.length) { + const childToScroll = children[childToScrollIndex]; + const instanceToScroll = + getInstanceFromHostFiber(childToScroll); + + const didScroll = maybeScrollContainerIntoView( + instanceToScroll, + prevScrolledInstance, + alignToTop, + prevContainerIsFixed, + ); + if (didScroll) { + prevScrolledInstance = instanceToScroll; + prevContainerIsFixed = isNewScrollContainer === 1; + } + } + } + + if (!isLastGroup) { + // Start a new group + currentGroupEnd = i; + } + + i += alignToTop ? -1 : 1; } - return -1; } export function createFragmentInstance( diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index 521792d62e95..a8dc6056b180 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -20,6 +20,9 @@ let Activity; let mockIntersectionObserver; let simulateIntersection; let setClientRects; +let setViewportSize; +let setScrollContainerHeight; +let setBoundingClientRect; let assertConsoleErrorDev; function Wrapper({children}) { @@ -40,6 +43,9 @@ describe('FragmentRefs', () => { mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver; simulateIntersection = IntersectionMocks.simulateIntersection; setClientRects = IntersectionMocks.setClientRects; + setBoundingClientRect = IntersectionMocks.setBoundingClientRect; + setViewportSize = IntersectionMocks.setViewportSize; + setScrollContainerHeight = IntersectionMocks.setScrollContainerHeight; assertConsoleErrorDev = require('internal-test-utils').assertConsoleErrorDev; @@ -1836,4 +1842,370 @@ describe('FragmentRefs', () => { }); }); }); + + describe('scrollIntoView', () => { + // @gate enableFragmentRefs + it('does not yet support options', async () => { + const fragmentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(() => { + fragmentRef.current.scrollIntoView({block: 'start'}); + }).toThrowError( + 'FragmentInstance.scrollIntoView() does not support ' + + 'scrollIntoViewOptions. Use the alignToTop boolean instead.', + ); + }); + + describe('with children', () => { + // @gate enableFragmentRefs + it('calls scrollIntoView on the first child by default, or if alignToTop=true', async () => { + const fragmentRef = React.createRef(); + const childARef = React.createRef(); + const childBRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render( + +
    + A +
    +
    + B +
    +
    , + ); + }); + childARef.current.scrollIntoView = jest.fn(); + childBRef.current.scrollIntoView = jest.fn(); + + // Default call + fragmentRef.current.scrollIntoView(); + expect(childARef.current.scrollIntoView).toHaveBeenCalledTimes(1); + expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(0); + + childARef.current.scrollIntoView.mockClear(); + + // alignToTop=true + fragmentRef.current.scrollIntoView(true); + expect(childARef.current.scrollIntoView).toHaveBeenCalledTimes(1); + expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(0); + }); + + // @gate enableFragmentRefs + it('calls scrollIntoView on the last child if alignToTop is false', async () => { + const fragmentRef = React.createRef(); + const childARef = React.createRef(); + const childBRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render( + +
    A
    +
    B
    +
    , + ); + }); + + childARef.current.scrollIntoView = jest.fn(); + childBRef.current.scrollIntoView = jest.fn(); + + fragmentRef.current.scrollIntoView(false); + expect(childARef.current.scrollIntoView).toHaveBeenCalledTimes(0); + expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(1); + }); + + // @gate enableFragmentRefs + it('handles portaled elements -- same scroll container', async () => { + const fragmentRef = React.createRef(); + const childARef = React.createRef(); + const childBRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Test() { + return ( + + {createPortal( +
    + A +
    , + document.body, + )} + +
    + B +
    +
    + ); + } + + await act(() => { + root.render(); + }); + + childARef.current.scrollIntoView = jest.fn(); + childBRef.current.scrollIntoView = jest.fn(); + + // Default call + fragmentRef.current.scrollIntoView(); + expect(childARef.current.scrollIntoView).toHaveBeenCalledTimes(1); + expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(0); + }); + + // @gate enableFragmentRefs + it('handles portaled elements -- different scroll container', async () => { + const fragmentRef = React.createRef(); + const headerChildRef = React.createRef(); + const childARef = React.createRef(); + const childBRef = React.createRef(); + const childCRef = React.createRef(); + const scrollContainerRef = React.createRef(); + const scrollContainerNestedRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Test({mountFragment}) { + return ( + <> +