diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts index 272b5fc0752d6..78c756f812d06 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts @@ -323,6 +323,22 @@ export default class HIRBuilder { ], }); } + if (node.name === 'this') { + CompilerError.throwDiagnostic({ + severity: ErrorSeverity.UnsupportedJS, + category: ErrorCategory.UnsupportedSyntax, + reason: '`this` is not supported syntax', + description: + 'React Compiler does not support compiling functions that use `this`', + details: [ + { + kind: 'error', + message: '`this` was used here', + loc: node.loc ?? GeneratedSource, + }, + ], + }); + } const originalName = node.name; let name = originalName; let index = 0; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts index c673ac53d9b10..a8fb837262e74 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts @@ -984,7 +984,7 @@ export function printAliasingEffect(effect: AliasingEffect): string { case 'MutateConditionally': case 'MutateTransitive': case 'MutateTransitiveConditionally': { - return `${effect.kind} ${printPlaceForAliasEffect(effect.value)}`; + return `${effect.kind} ${printPlaceForAliasEffect(effect.value)}${effect.kind === 'Mutate' && effect.reason?.kind === 'AssignCurrentProperty' ? ' (assign `.current`)' : ''}`; } case 'MutateFrozen': { return `MutateFrozen ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`; diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts index 9d1733c77a9a7..b53026a4d4b87 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts @@ -27,7 +27,7 @@ import { } from '../HIR/visitors'; import {assertExhaustive, getOrInsertWith} from '../Utils/utils'; import {Err, Ok, Result} from '../Utils/Result'; -import {AliasingEffect} from './AliasingEffects'; +import {AliasingEffect, MutationReason} from './AliasingEffects'; /** * This pass builds an abstract model of the heap and interprets the effects of the @@ -101,6 +101,7 @@ export function inferMutationAliasingRanges( transitive: boolean; kind: MutationKind; place: Place; + reason: MutationReason | null; }> = []; const renders: Array<{index: number; place: Place}> = []; @@ -176,6 +177,7 @@ export function inferMutationAliasingRanges( effect.kind === 'MutateTransitive' ? MutationKind.Definite : MutationKind.Conditional, + reason: null, place: effect.value, }); } else if ( @@ -190,6 +192,7 @@ export function inferMutationAliasingRanges( effect.kind === 'Mutate' ? MutationKind.Definite : MutationKind.Conditional, + reason: effect.kind === 'Mutate' ? (effect.reason ?? null) : null, place: effect.value, }); } else if ( @@ -241,6 +244,7 @@ export function inferMutationAliasingRanges( mutation.transitive, mutation.kind, mutation.place.loc, + mutation.reason, errors, ); } @@ -267,6 +271,7 @@ export function inferMutationAliasingRanges( functionEffects.push({ kind: 'Mutate', value: {...place, loc: node.local.loc}, + reason: node.mutationReason, }); } } @@ -507,6 +512,7 @@ export function inferMutationAliasingRanges( true, MutationKind.Conditional, into.loc, + null, ignoredErrors, ); for (const from of tracked) { @@ -580,6 +586,7 @@ type Node = { transitive: {kind: MutationKind; loc: SourceLocation} | null; local: {kind: MutationKind; loc: SourceLocation} | null; lastMutated: number; + mutationReason: MutationReason | null; value: | {kind: 'Object'} | {kind: 'Phi'} @@ -599,6 +606,7 @@ class AliasingState { transitive: null, local: null, lastMutated: 0, + mutationReason: null, value, }); } @@ -697,6 +705,7 @@ class AliasingState { transitive: boolean, startKind: MutationKind, loc: SourceLocation, + reason: MutationReason | null, errors: CompilerError, ): void { const seen = new Map(); @@ -717,6 +726,7 @@ class AliasingState { if (node == null) { continue; } + node.mutationReason ??= reason; node.lastMutated = Math.max(node.lastMutated, index); if (end != null) { node.id.mutableRange.end = makeInstructionId( diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ecma/error.reserved-words.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ecma/error.reserved-words.expect.md index a6ee8a798b583..986fb8a5b26f1 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ecma/error.reserved-words.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ecma/error.reserved-words.expect.md @@ -24,9 +24,18 @@ function useThing(fn) { ``` Found 1 error: -Error: Expected a non-reserved identifier name - -`this` is a reserved word in JavaScript and cannot be used as an identifier name. +Error: `this` is not supported syntax + +React Compiler does not support compiling functions that use `this` + +error.reserved-words.ts:8:28 + 6 | + 7 | if (ref.current === null) { +> 8 | ref.current = function (this: unknown, ...args) { + | ^^^^^^^^^^^^^ `this` was used here + 9 | return fnRef.current.call(this, ...args); + 10 | }; + 11 | } ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-ref-in-effect-hint.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-ref-in-effect-hint.expect.md new file mode 100644 index 0000000000000..c89e773f3212c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-ref-in-effect-hint.expect.md @@ -0,0 +1,37 @@ + +## Input + +```javascript +// Fixture to test that we show a hint to name as `ref` or `-Ref` when attempting +// to assign .current inside an effect +function Component({foo}) { + useEffect(() => { + foo.current = true; + }, [foo]); +} + +``` + + +## Error + +``` +Found 1 error: + +Error: This value cannot be modified + +Modifying component props or hook arguments is not allowed. Consider using a local variable instead. + +error.assign-ref-in-effect-hint.ts:5:4 + 3 | function Component({foo}) { + 4 | useEffect(() => { +> 5 | foo.current = true; + | ^^^ `foo` cannot be modified + 6 | }, [foo]); + 7 | } + 8 | + +Hint: If this value is a Ref (value returned by `useRef()`), rename the variable to end in "Ref". +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-ref-in-effect-hint.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-ref-in-effect-hint.js new file mode 100644 index 0000000000000..1546734959648 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-ref-in-effect-hint.js @@ -0,0 +1,7 @@ +// Fixture to test that we show a hint to name as `ref` or `-Ref` when attempting +// to assign .current inside an effect +function Component({foo}) { + useEffect(() => { + foo.current = true; + }, [foo]); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-mutate-context-in-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-mutate-context-in-callback.expect.md index 9467aa21e0319..142f538a0dd35 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-mutate-context-in-callback.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-mutate-context-in-callback.expect.md @@ -38,6 +38,8 @@ error.invalid-mutate-context-in-callback.ts:12:4 13 | }; 14 | return
; 15 | } + +Hint: If this value is a Ref (value returned by `useRef()`), rename the variable to end in "Ref". ``` \ No newline at end of file diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js index baff30895c0e0..efd9157b4a5ce 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 7b20a0a2e0d67..563f2ad054294 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 0000000000000..3b1f21ef686aa --- /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 ( +
+ +
+ +
+
+ +
+
+ ); +} + +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 0000000000000..a0ea612d09c40 --- /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 0000000000000..ee61cd16290f9 --- /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 0000000000000..f61668c5cf525 --- /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 23b440938cf7a..c560b59fbec6a 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 7a23ba2acf944..a334311be0c4b 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 e3dac2e27e206..74b03d8fe0577 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 521792d62e95f..35c10fe0f073e 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 ( + <> +