From 6b66e62148d54ec850f82c285e5b4daade84c0ab Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Mon, 11 Aug 2025 16:32:31 -0400 Subject: [PATCH] Add flag enableFragmentRefsScrollIntoView, prefix with experimental_ --- .../fragment-refs/ScrollIntoViewCase.js | 4 +- .../src/client/ReactFiberConfigDOM.js | 73 ++++++++++--------- .../__tests__/ReactDOMFragmentRefs-test.js | 40 +++++----- packages/shared/ReactFeatureFlags.js | 1 + .../ReactFeatureFlags.native-fb-dynamic.js | 1 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + scripts/error-codes/codes.json | 2 +- 13 files changed, 70 insertions(+), 58 deletions(-) diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCase.js index d71500e3b6..a01c4e7ef0 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCase.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/ScrollIntoViewCase.js @@ -55,11 +55,11 @@ export default function ScrollIntoViewCase() { const scrollContainerRef = useRef(null); const scrollVertical = () => { - fragmentRef.current.scrollIntoView(alignToTop); + fragmentRef.current.experimental_scrollIntoView(alignToTop); }; const scrollVerticalNoChildren = () => { - noChildRef.current.scrollIntoView(alignToTop); + noChildRef.current.experimental_scrollIntoView(alignToTop); }; useEffect(() => { diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index c888039a9d..5e11f2c727 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -124,6 +124,7 @@ import { enableSrcObject, enableViewTransition, enableHydrationChangeEvent, + enableFragmentRefsScrollIntoView, } from 'shared/ReactFeatureFlags'; import { HostComponent, @@ -3248,46 +3249,48 @@ function validateDocumentPositionWithFiberTree( return false; } -// $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 (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); - // 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.', - ); + // 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; } - const target = getInstanceFromHostFiber(targetFiber); - target.scrollIntoView(alignToTop); - return; - } - // If there are children, handle them per scroll container - scrollIntoViewByScrollContainer(children, alignToTop !== false); -}; + // If there are children, handle them per scroll container + scrollIntoViewByScrollContainer(children, alignToTop !== false); + }; +} function isInstanceScrollable(inst: Instance): 0 | 1 | 2 { const style = getComputedStyle(inst); diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index a8dc6056b1..1918449b90 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -1844,7 +1844,7 @@ describe('FragmentRefs', () => { }); describe('scrollIntoView', () => { - // @gate enableFragmentRefs + // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView it('does not yet support options', async () => { const fragmentRef = React.createRef(); const root = ReactDOMClient.createRoot(container); @@ -1853,15 +1853,15 @@ describe('FragmentRefs', () => { }); expect(() => { - fragmentRef.current.scrollIntoView({block: 'start'}); + fragmentRef.current.experimental_scrollIntoView({block: 'start'}); }).toThrowError( - 'FragmentInstance.scrollIntoView() does not support ' + + 'FragmentInstance.experimental_scrollIntoView() does not support ' + 'scrollIntoViewOptions. Use the alignToTop boolean instead.', ); }); describe('with children', () => { - // @gate enableFragmentRefs + // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView it('calls scrollIntoView on the first child by default, or if alignToTop=true', async () => { const fragmentRef = React.createRef(); const childARef = React.createRef(); @@ -1883,19 +1883,19 @@ describe('FragmentRefs', () => { childBRef.current.scrollIntoView = jest.fn(); // Default call - fragmentRef.current.scrollIntoView(); + fragmentRef.current.experimental_scrollIntoView(); expect(childARef.current.scrollIntoView).toHaveBeenCalledTimes(1); expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(0); childARef.current.scrollIntoView.mockClear(); // alignToTop=true - fragmentRef.current.scrollIntoView(true); + fragmentRef.current.experimental_scrollIntoView(true); expect(childARef.current.scrollIntoView).toHaveBeenCalledTimes(1); expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(0); }); - // @gate enableFragmentRefs + // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView it('calls scrollIntoView on the last child if alignToTop is false', async () => { const fragmentRef = React.createRef(); const childARef = React.createRef(); @@ -1913,12 +1913,12 @@ describe('FragmentRefs', () => { childARef.current.scrollIntoView = jest.fn(); childBRef.current.scrollIntoView = jest.fn(); - fragmentRef.current.scrollIntoView(false); + fragmentRef.current.experimental_scrollIntoView(false); expect(childARef.current.scrollIntoView).toHaveBeenCalledTimes(0); expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(1); }); - // @gate enableFragmentRefs + // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView it('handles portaled elements -- same scroll container', async () => { const fragmentRef = React.createRef(); const childARef = React.createRef(); @@ -1950,12 +1950,12 @@ describe('FragmentRefs', () => { childBRef.current.scrollIntoView = jest.fn(); // Default call - fragmentRef.current.scrollIntoView(); + fragmentRef.current.experimental_scrollIntoView(); expect(childARef.current.scrollIntoView).toHaveBeenCalledTimes(1); expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(0); }); - // @gate enableFragmentRefs + // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView it('handles portaled elements -- different scroll container', async () => { const fragmentRef = React.createRef(); const headerChildRef = React.createRef(); @@ -2086,7 +2086,7 @@ describe('FragmentRefs', () => { }); // Default call - fragmentRef.current.scrollIntoView(); + fragmentRef.current.experimental_scrollIntoView(); expect(childCRef.current.scrollIntoView).toHaveBeenCalledTimes(1); // In the same group as A, we use the first child expect(childBRef.current.scrollIntoView).toHaveBeenCalledTimes(0); @@ -2102,7 +2102,7 @@ describe('FragmentRefs', () => { logs = []; // // alignToTop=false - fragmentRef.current.scrollIntoView(false); + fragmentRef.current.experimental_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); @@ -2116,7 +2116,7 @@ describe('FragmentRefs', () => { }); describe('without children', () => { - // @gate enableFragmentRefs + // @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(); @@ -2138,19 +2138,19 @@ describe('FragmentRefs', () => { siblingBRef.current.scrollIntoView = jest.fn(); // Default call - fragmentRef.current.scrollIntoView(); + fragmentRef.current.experimental_scrollIntoView(); expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(0); expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(1); siblingBRef.current.scrollIntoView.mockClear(); // alignToTop=true - fragmentRef.current.scrollIntoView(true); + fragmentRef.current.experimental_scrollIntoView(true); expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(0); expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(1); }); - // @gate enableFragmentRefs + // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView it('calls scrollIntoView on the prev sibling if alignToTop is false', async () => { const fragmentRef = React.createRef(); const siblingARef = React.createRef(); @@ -2182,12 +2182,12 @@ describe('FragmentRefs', () => { siblingBRef.current.scrollIntoView = jest.fn(); // alignToTop=false - fragmentRef.current.scrollIntoView(false); + fragmentRef.current.experimental_scrollIntoView(false); expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(1); expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(0); }); - // @gate enableFragmentRefs + // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView it('calls scrollIntoView on the parent if there are no siblings', async () => { const fragmentRef = React.createRef(); const parentRef = React.createRef(); @@ -2203,7 +2203,7 @@ describe('FragmentRefs', () => { }); parentRef.current.scrollIntoView = jest.fn(); - fragmentRef.current.scrollIntoView(); + fragmentRef.current.experimental_scrollIntoView(); expect(parentRef.current.scrollIntoView).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index fb997f239a..add44f786e 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 f3c828b260..0b59327b34 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 a30a95b749..88f434fa85 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 9bc357f5a7..3cae738acd 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 3d9997bda5..0f62f7b92c 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 9cd9ac4ab5..0b8eb16b57 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 b12ffd3331..e38e8c2208 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 a5657f5ece..601d3799d1 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 9db791cf8f..482e991c8f 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 e87d750eca..c19b95db78 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -551,5 +551,5 @@ "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.", - "566": "FragmentInstance.scrollIntoView() does not support scrollIntoViewOptions. Use the alignToTop boolean instead." + "566": "FragmentInstance.experimental_scrollIntoView() does not support scrollIntoViewOptions. Use the alignToTop boolean instead." }