diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js index baff30895c..efd9157b4a 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 7b20a0a2e0..563f2ad054 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 0000000000..d71500e3b6 --- /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 0000000000..a0ea612d09 --- /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 0000000000..ee61cd1629 --- /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 0000000000..f61668c5cf --- /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 23b440938c..c560b59fbe 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 7a23ba2acf..a334311be0 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 e3dac2e27e..c888039a9d 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 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; } - if (typeof opts === 'boolean') { - return `c=${opts ? '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; } - return `c=${opts.capture ? '1' : '0'}&o=${opts.once ? '1' : '0'}&p=${opts.passive ? '1' : '0'}`; + 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 521792d62e..a8dc6056b1 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 ( + <> +