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..3b1f21ef686a
--- /dev/null
+++ b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCase.js
@@ -0,0 +1,184 @@
+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.experimental_scrollIntoView(alignToTop);
+ };
+
+ const scrollVerticalNoChildren = () => {
+ noChildRef.current.experimental_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:
+
+ In order to handle the case where children are split between
+ multiple scroll containers, we call scrollIntoView on each child in
+ reverse order.
+
+ 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..74b03d8fe057 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};
@@ -123,6 +124,7 @@ import {
enableSrcObject,
enableViewTransition,
enableHydrationChangeEvent,
+ enableFragmentRefsScrollIntoView,
} from 'shared/ReactFeatureFlags';
import {
HostComponent,
@@ -2813,6 +2815,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 +2902,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 +3249,55 @@ function validateDocumentPositionWithFiberTree(
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'}`;
-}
+if (enableFragmentRefsScrollIntoView) {
+ // $FlowFixMe[prop-missing]
+ FragmentInstance.prototype.experimental_scrollIntoView = function (
+ this: FragmentInstanceType,
+ alignToTop?: boolean,
+ ): void {
+ if (typeof alignToTop === 'object') {
+ throw new Error(
+ 'FragmentInstance.experimental_scrollIntoView() does not support ' +
+ 'scrollIntoViewOptions. Use the alignToTop boolean instead.',
+ );
+ }
+ // First, get the children nodes
+ const children: Array = [];
+ traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
+
+ const resolvedAlignToTop = alignToTop !== false;
+
+ // 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 = resolvedAlignToTop
+ ? hostSiblings[1] ||
+ hostSiblings[0] ||
+ getFragmentParentHostFiber(this._fragmentFiber)
+ : hostSiblings[0] || hostSiblings[1];
+
+ if (targetFiber === null) {
+ if (__DEV__) {
+ console.warn(
+ '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;
+ }
-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;
+ let i = resolvedAlignToTop ? children.length - 1 : 0;
+ while (i !== (resolvedAlignToTop ? -1 : children.length)) {
+ const child = children[i];
+ const instance = getInstanceFromHostFiber(child);
+ instance.scrollIntoView(alignToTop);
+ i += resolvedAlignToTop ? -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..35c10fe0f073 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
@@ -1836,4 +1836,323 @@ describe('FragmentRefs', () => {
});
});
});
+
+ describe('scrollIntoView', () => {
+ function expectLast(arr, test) {
+ expect(arr[arr.length - 1]).toBe(test);
+ }
+ // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
+ it('does not yet support options', async () => {
+ const fragmentRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+ await act(() => {
+ root.render( );
+ });
+
+ expect(() => {
+ fragmentRef.current.experimental_scrollIntoView({block: 'start'});
+ }).toThrowError(
+ 'FragmentInstance.experimental_scrollIntoView() does not support ' +
+ 'scrollIntoViewOptions. Use the alignToTop boolean instead.',
+ );
+ });
+
+ describe('with children', () => {
+ // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
+ it('settles scroll 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
+
+ ,
+ );
+ });
+
+ let logs = [];
+ childARef.current.scrollIntoView = jest.fn().mockImplementation(() => {
+ logs.push('childA');
+ });
+ childBRef.current.scrollIntoView = jest.fn().mockImplementation(() => {
+ logs.push('childB');
+ });
+
+ // Default call
+ fragmentRef.current.experimental_scrollIntoView();
+ expectLast(logs, 'childA');
+ logs = [];
+ // alignToTop=true
+ fragmentRef.current.experimental_scrollIntoView(true);
+ expectLast(logs, 'childA');
+ });
+
+ // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
+ 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
+ ,
+ );
+ });
+
+ const logs = [];
+ childARef.current.scrollIntoView = jest.fn().mockImplementation(() => {
+ logs.push('childA');
+ });
+ childBRef.current.scrollIntoView = jest.fn().mockImplementation(() => {
+ logs.push('childB');
+ });
+
+ fragmentRef.current.experimental_scrollIntoView(false);
+ expectLast(logs, 'childB');
+ });
+
+ // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
+ 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( );
+ });
+
+ const logs = [];
+ childARef.current.scrollIntoView = jest.fn().mockImplementation(() => {
+ logs.push('childA');
+ });
+ childBRef.current.scrollIntoView = jest.fn().mockImplementation(() => {
+ logs.push('childB');
+ });
+
+ // Default call
+ fragmentRef.current.experimental_scrollIntoView();
+ expectLast(logs, 'childA');
+ });
+
+ // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
+ 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( );
+ });
+
+ 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.experimental_scrollIntoView();
+ expectLast(logs, 'header');
+
+ childARef.current.scrollIntoView.mockClear();
+ childBRef.current.scrollIntoView.mockClear();
+ childCRef.current.scrollIntoView.mockClear();
+
+ logs = [];
+
+ // // alignToTop=false
+ fragmentRef.current.experimental_scrollIntoView(false);
+ expectLast(logs, 'C');
+ });
+ });
+
+ describe('without children', () => {
+ // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
+ 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.experimental_scrollIntoView();
+ expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+
+ siblingBRef.current.scrollIntoView.mockClear();
+
+ // alignToTop=true
+ fragmentRef.current.experimental_scrollIntoView(true);
+ expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+ });
+
+ // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
+ 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.experimental_scrollIntoView(false);
+ expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+ expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
+ });
+
+ // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
+ 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.experimental_scrollIntoView();
+ expect(parentRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
});
diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js
index 49a357adf826..c23c3665cf83 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/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js
index fb997f239ab8..add44f786e18 100644
--- a/packages/shared/ReactFeatureFlags.js
+++ b/packages/shared/ReactFeatureFlags.js
@@ -152,6 +152,7 @@ export const transitionLaneExpirationMs = 5000;
export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableFragmentRefs = __EXPERIMENTAL__;
+export const enableFragmentRefsScrollIntoView = __EXPERIMENTAL__;
// -----------------------------------------------------------------------------
// Ready for next major.
diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
index f3c828b260fe..0b59327b3493 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
@@ -25,4 +25,5 @@ export const enableEagerAlternateStateNodeCleanup = __VARIANT__;
export const passChildrenWhenCloningPersistedNodes = __VARIANT__;
export const renameElementSymbol = __VARIANT__;
export const enableFragmentRefs = __VARIANT__;
+export const enableFragmentRefsScrollIntoView = __VARIANT__;
export const enableComponentPerformanceTrack = __VARIANT__;
diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js
index a30a95b74993..88f434fa855c 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js
@@ -27,6 +27,7 @@ export const {
passChildrenWhenCloningPersistedNodes,
renameElementSymbol,
enableFragmentRefs,
+ enableFragmentRefsScrollIntoView,
} = dynamicFlags;
// The rest of the flags are static for better dead code elimination.
diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js
index 9bc357f5a7d0..3cae738acd49 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-oss.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js
@@ -73,6 +73,7 @@ export const enableDefaultTransitionIndicator: boolean = false;
export const ownerStackLimit = 1e4;
export const enableFragmentRefs: boolean = false;
+export const enableFragmentRefsScrollIntoView: boolean = false;
// Profiling Only
export const enableProfilerTimer: boolean = __PROFILE__;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
index 3d9997bda51e..0f62f7b92ced 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
@@ -75,6 +75,7 @@ export const enableDefaultTransitionIndicator: boolean = false;
export const ownerStackLimit = 1e4;
export const enableFragmentRefs: boolean = false;
+export const enableFragmentRefsScrollIntoView: boolean = false;
// TODO: This must be in sync with the main ReactFeatureFlags file because
// the Test Renderer's value must be the same as the one used by the
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
index 9cd9ac4ab570..0b8eb16b5785 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
@@ -68,6 +68,7 @@ export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;
export const enableFragmentRefs = false;
+export const enableFragmentRefsScrollIntoView = false;
export const ownerStackLimit = 1e4;
// Flow magic to verify the exports of this file match the original version.
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
index b12ffd3331b1..e38e8c220864 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
@@ -82,6 +82,7 @@ export const enableHydrationChangeEvent: boolean = false;
export const enableDefaultTransitionIndicator: boolean = false;
export const enableFragmentRefs: boolean = false;
+export const enableFragmentRefsScrollIntoView: boolean = false;
export const ownerStackLimit = 1e4;
// Flow magic to verify the exports of this file match the original version.
diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js
index a5657f5ece41..601d3799d145 100644
--- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js
@@ -35,6 +35,7 @@ export const enableViewTransition: boolean = __VARIANT__;
export const enableComponentPerformanceTrack: boolean = __VARIANT__;
export const enableScrollEndPolyfill: boolean = __VARIANT__;
export const enableFragmentRefs: boolean = __VARIANT__;
+export const enableFragmentRefsScrollIntoView: boolean = __VARIANT__;
// TODO: These flags are hard-coded to the default values used in open source.
// Update the tests so that they pass in either mode, then set these
diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js
index 9db791cf8f9c..482e991c8f0b 100644
--- a/packages/shared/forks/ReactFeatureFlags.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.www.js
@@ -33,6 +33,7 @@ export const {
enableComponentPerformanceTrack,
enableScrollEndPolyfill,
enableFragmentRefs,
+ enableFragmentRefsScrollIntoView,
} = dynamicFeatureFlags;
// On WWW, __EXPERIMENTAL__ is used for a new modern build.
diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json
index a9867c154c66..c19b95db788d 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.experimental_scrollIntoView() does not support scrollIntoViewOptions. Use the alignToTop boolean instead."
}