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 (
+
+
+ Example Type:
+ setExampleType(e.target.value)}>
+ Simple
+ Multiple Scroll Containers
+ Horizontal
+ Empty Fragment
+
+
+
+
+ Align to Top:
+ setAlignToTop(e.target.checked)}
+ />
+
+
+
+ scrollIntoView()
+
+
+ );
+}
+
+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 (
+ <>
+
+
+
+ {mountFragment && (
+
+ {createPortal(
+ ,
+ document.querySelector('#parent-a'),
+ )}
+ {createPortal(
+
+ A
+
,
+ document.querySelector('#parent-b'),
+ )}
+ {createPortal(
+
+ B
+
,
+ document.querySelector('#parent-b'),
+ )}
+ {createPortal(
+
+ C
+
,
+ document.querySelector('#parent-c'),
+ )}
+
+ )}
+ >
+ );
+ }
+
+ await act(() => {
+ root.render( );
+ });
+ // Now that the portal locations exist, mount the fragment
+ await act(() => {
+ root.render( );
+ });
+
+ setViewportSize(500, 500);
+ setBoundingClientRect(headerChildRef.current, {
+ x: 0,
+ y: 150,
+ width: 100,
+ height: 100,
+ });
+ Object.defineProperty(headerChildRef.current, 'clientHeight', {
+ value: 100,
+ writable: true,
+ });
+ setBoundingClientRect(childARef.current, {
+ x: 0,
+ y: 600, // outside of initial viewport
+ width: 100,
+ height: 100,
+ });
+ Object.defineProperty(childARef.current, 'clientHeight', {
+ value: 100,
+ writable: true,
+ });
+ setBoundingClientRect(childBRef.current, {
+ x: 0,
+ y: 1200, // outside of viewport after scroll to top of scrollContainerAll
+ width: 100,
+ height: 100,
+ });
+ Object.defineProperty(childBRef.current, 'clientHeight', {
+ value: 100,
+ writable: true,
+ });
+ setBoundingClientRect(childCRef.current, {
+ x: 0,
+ y: 1800,
+ width: 100,
+ height: 100,
+ });
+ Object.defineProperty(childCRef.current, 'clientHeight', {
+ value: 100,
+ writable: true,
+ });
+
+ // Make containers scrollable
+ setScrollContainerHeight(scrollContainerRef.current, 100, 200);
+ setScrollContainerHeight(scrollContainerNestedRef.current, 100, 200);
+
+ let logs = [];
+ headerChildRef.current.scrollIntoView = jest.fn(() => {
+ logs.push('header');
+ });
+ childARef.current.scrollIntoView = jest.fn(() => {
+ logs.push('A');
+ });
+ childBRef.current.scrollIntoView = jest.fn(() => {
+ logs.push('B');
+ });
+ childCRef.current.scrollIntoView = jest.fn(() => {
+ logs.push('C');
+ });
+
+ // Default call
+ fragmentRef.current.scrollIntoView();
+ expect(childCRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+ // In the same group as A, we use the first child
+ expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ // Scrolling to A would push C out of the viewport, don't scroll
+ expect(childARef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ // Scrolling to header would push C out of the viewport, don't scroll
+ expect(headerChildRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ expect(logs).toEqual(['C']);
+
+ childARef.current.scrollIntoView.mockClear();
+ childBRef.current.scrollIntoView.mockClear();
+ childCRef.current.scrollIntoView.mockClear();
+ logs = [];
+
+ // // alignToTop=false
+ fragmentRef.current.scrollIntoView(false);
+ expect(headerChildRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+ // In the same group as B, only attempt B which is the last child
+ expect(childARef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ // Previous scroll had fixed parent, scroll to B
+ // even if it would otherwise push prev out of viewport
+ expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+ // Scrolling to C would push A out of the viewport, don't scroll to it
+ expect(childCRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ expect(logs).toEqual(['header', 'B']);
+ });
+ });
+
+ describe('without children', () => {
+ // @gate enableFragmentRefs
+ it('calls scrollIntoView on the next sibling by default, or if alignToTop=true', async () => {
+ const fragmentRef = React.createRef();
+ const siblingARef = React.createRef();
+ const siblingBRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+ await act(() => {
+ root.render(
+ ,
+ );
+ });
+
+ siblingARef.current.scrollIntoView = jest.fn();
+ siblingBRef.current.scrollIntoView = jest.fn();
+
+ // Default call
+ fragmentRef.current.scrollIntoView();
+ expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+
+ siblingBRef.current.scrollIntoView.mockClear();
+
+ // alignToTop=true
+ fragmentRef.current.scrollIntoView(true);
+ expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+ });
+
+ // @gate enableFragmentRefs
+ it('calls scrollIntoView on the prev sibling if alignToTop is false', async () => {
+ const fragmentRef = React.createRef();
+ const siblingARef = React.createRef();
+ const siblingBRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+ function C() {
+ return (
+
+
+
+ );
+ }
+ function Test() {
+ return (
+
+ );
+ }
+ await act(() => {
+ root.render( );
+ });
+
+ siblingARef.current.scrollIntoView = jest.fn();
+ siblingBRef.current.scrollIntoView = jest.fn();
+
+ // alignToTop=false
+ fragmentRef.current.scrollIntoView(false);
+ expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+ expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ });
+
+ // @gate enableFragmentRefs
+ it('calls scrollIntoView on the parent if there are no siblings', async () => {
+ const fragmentRef = React.createRef();
+ const parentRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+ await act(() => {
+ root.render(
+
+
+
+
+
,
+ );
+ });
+
+ parentRef.current.scrollIntoView = jest.fn();
+ fragmentRef.current.scrollIntoView();
+ expect(parentRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
});
diff --git a/packages/react-dom/src/__tests__/utils/IntersectionMocks.js b/packages/react-dom/src/__tests__/utils/IntersectionMocks.js
index d89a683e8e..f34f58ca0d 100644
--- a/packages/react-dom/src/__tests__/utils/IntersectionMocks.js
+++ b/packages/react-dom/src/__tests__/utils/IntersectionMocks.js
@@ -93,3 +93,25 @@ export function setClientRects(target, rects) {
}));
};
}
+
+export function setViewportSize(width, height) {
+ Object.defineProperty(window, 'innerWidth', {
+ value: width,
+ writable: true,
+ });
+ Object.defineProperty(window, 'innerHeight', {
+ value: height,
+ writable: true,
+ });
+}
+
+export function setScrollContainerHeight(target, clientHeight, scrollHeight) {
+ Object.defineProperty(target, 'clientHeight', {
+ value: clientHeight,
+ writable: true,
+ });
+ Object.defineProperty(target, 'scrollHeight', {
+ value: scrollHeight,
+ writable: true,
+ });
+}
diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js
index 49a357adf8..c23c3665cf 100644
--- a/packages/react-reconciler/src/ReactFiberTreeReflection.js
+++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js
@@ -421,6 +421,56 @@ export function fiberIsPortaledIntoHost(fiber: Fiber): boolean {
return foundPortalParent;
}
+export function getFragmentInstanceSiblings(
+ fiber: Fiber,
+): [Fiber | null, Fiber | null] {
+ const result: [Fiber | null, Fiber | null] = [null, null];
+ const parentHostFiber = getFragmentParentHostFiber(fiber);
+ if (parentHostFiber === null) {
+ return result;
+ }
+
+ findFragmentInstanceSiblings(result, fiber, parentHostFiber.child);
+ return result;
+}
+
+function findFragmentInstanceSiblings(
+ result: [Fiber | null, Fiber | null],
+ self: Fiber,
+ child: null | Fiber,
+ foundSelf: boolean = false,
+): boolean {
+ while (child !== null) {
+ if (child === self) {
+ foundSelf = true;
+ if (child.sibling) {
+ child = child.sibling;
+ } else {
+ return true;
+ }
+ }
+ if (child.tag === HostComponent) {
+ if (foundSelf) {
+ result[1] = child;
+ return true;
+ } else {
+ result[0] = child;
+ }
+ } else if (
+ child.tag === OffscreenComponent &&
+ child.memoizedState !== null
+ ) {
+ // Skip hidden subtrees
+ } else {
+ if (findFragmentInstanceSiblings(result, self, child.child, foundSelf)) {
+ return true;
+ }
+ }
+ child = child.sibling;
+ }
+ return false;
+}
+
export function getInstanceFromHostFiber(fiber: Fiber): I {
switch (fiber.tag) {
case HostComponent:
diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json
index a9867c154c..e87d750eca 100644
--- a/scripts/error-codes/codes.json
+++ b/scripts/error-codes/codes.json
@@ -550,5 +550,6 @@
"562": "The render was aborted due to a fatal error.",
"563": "This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources.",
"564": "Unknown command. The debugChannel was not wired up properly.",
- "565": "resolveDebugMessage/closeDebugChannel should not be called for a Request that wasn't kept alive. This is a bug in React."
+ "565": "resolveDebugMessage/closeDebugChannel should not be called for a Request that wasn't kept alive. This is a bug in React.",
+ "566": "FragmentInstance.scrollIntoView() does not support scrollIntoViewOptions. Use the alignToTop boolean instead."
}