Add scrollIntoView to fragment instances

This adds `scrollIntoView(alignToTop)`. It doesn't yet support `scrollIntoView(options)`.

Cases:
- No host children: Without host children, we represent the virtual space of the Fragment by attempting to scroll to the nearest edge by using its siblings. If the preferred sibling is not found, we'll try the other side, and then the parent.
- 1 host child: The simplest case where its the equivalent of calling the method on the child element directly
- Multiple host children in same scroll container:
    - Here we find the first child in the list for `alignToTop=true|undefined` or the last child `alignToTop=false`. We call scroll on that element.
- Multiple host children in multiple scroll containers (fixed positioning or portal-ed into other containers):
	- In order to handle the possibility of children being fixed or portal-ed, where the assumption is that isn't where you want to stop scroll, we work through groups of host children by scroll container and may scroll to multiple elements.
	- `scrollIntoView` will only be called again if scrolling to the next element wouldn't scroll the previous one out of the viewport.
	- `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
This commit is contained in:
Jack Pope
2025-08-27 15:26:46 -04:00
parent b870042915
commit 716ccf8862
13 changed files with 1008 additions and 41 deletions
@@ -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);
@@ -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);
@@ -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 (
<div>
<label>
Example Type:
<select
value={exampleType}
onChange={e => setExampleType(e.target.value)}>
<option value="simple">Simple</option>
<option value="multiple">Multiple Scroll Containers</option>
<option value="horizontal">Horizontal</option>
<option value="empty">Empty Fragment</option>
</select>
</label>
<div>
<label>
Align to Top:
<input
type="checkbox"
checked={alignToTop}
onChange={e => setAlignToTop(e.target.checked)}
/>
</label>
</div>
<div>
<button onClick={scrollVertical}>scrollIntoView()</button>
</div>
</div>
);
}
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 (
<Fragment ref={testCaseRef}>
<TestCase title="ScrollIntoView">
<TestCase.Steps>
<li>Toggle alignToTop and click the buttons to scroll</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
<p>When the Fragment has children:</p>
<p>
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.
</p>
<p>
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.
</p>
<p>When the Fragment does not have children:</p>
<p>
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.
</p>
</TestCase.ExpectedResult>
<Fixture>
<Fixture.Controls>
<Controls
alignToTop={alignToTop}
setAlignToTop={setAlignToTop}
scrollVertical={scrollVertical}
exampleType={exampleType}
setExampleType={setExampleType}
/>
</Fixture.Controls>
{exampleType === 'simple' && (
<Fragment ref={fragmentRef}>
<ScrollIntoViewCaseSimple />
</Fragment>
)}
{exampleType === 'horizontal' && (
<div
style={{
display: 'flex',
overflowX: 'auto',
flexDirection: 'row',
border: '1px solid #ccc',
padding: '1rem 10rem',
marginBottom: '1rem',
width: '100%',
whiteSpace: 'nowrap',
justifyContent: 'space-between',
}}>
<Fragment ref={fragmentRef}>
<ScrollIntoViewCaseSimple />
</Fragment>
</div>
)}
{exampleType === 'multiple' && (
<Fragment>
<div
style={{
height: '50vh',
overflowY: 'auto',
border: '1px solid black',
marginBottom: '1rem',
}}
ref={scrollContainerRef}
/>
<Fragment ref={fragmentRef}>
<ScrollIntoViewCaseComplex
caseInViewport={caseInViewport}
scrollContainerRef={scrollContainerRef}
/>
</Fragment>
</Fragment>
)}
{exampleType === 'empty' && (
<Fragment>
<ScrollIntoViewTargetElement
color="lightyellow"
id="ABOVE EMPTY FRAGMENT"
/>
<Fragment ref={fragmentRef}></Fragment>
<ScrollIntoViewTargetElement
color="lightblue"
id="BELOW EMPTY FRAGMENT"
/>
</Fragment>
)}
<Fixture.Controls>
<Controls
alignToTop={alignToTop}
setAlignToTop={setAlignToTop}
scrollVertical={scrollVertical}
exampleType={exampleType}
setExampleType={setExampleType}
/>
</Fixture.Controls>
</Fixture>
</TestCase>
</Fragment>
);
}
@@ -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 (
<Fragment>
{caseInViewport && (
<div
style={{position: 'fixed', top: 0, backgroundColor: 'red'}}
id="header">
Fixed header
</div>
)}
{didMount &&
ReactDOM.createPortal(
<ScrollIntoViewTargetElement color="red" id="FROM_PORTAL" />,
scrollContainerRef.current
)}
<ScrollIntoViewTargetElement color="lightgreen" id="A" />
<ScrollIntoViewTargetElement color="lightcoral" id="B" />
<ScrollIntoViewTargetElement color="lightblue" id="C" />
{caseInViewport && (
<div
style={{
position: 'fixed',
bottom: 0,
backgroundColor: 'purple',
}}
id="footer">
Fixed footer
</div>
)}
</Fragment>
);
}
@@ -0,0 +1,14 @@
import ScrollIntoViewTargetElement from './ScrollIntoViewTargetElement';
const React = window.React;
const {Fragment} = React;
export default function ScrollIntoViewCaseSimple() {
return (
<Fragment>
<ScrollIntoViewTargetElement color="lightyellow" id="SCROLLABLE-1" />
<ScrollIntoViewTargetElement color="lightpink" id="SCROLLABLE-2" />
<ScrollIntoViewTargetElement color="lightcyan" id="SCROLLABLE-3" />
</Fragment>
);
}
@@ -0,0 +1,18 @@
const React = window.React;
export default function ScrollIntoViewTargetElement({color, id, top}) {
return (
<div
id={id}
style={{
height: 500,
minWidth: 300,
backgroundColor: color,
marginTop: top ? '50vh' : 0,
marginBottom: 100,
flexShrink: 0,
}}>
{id}
</div>
);
}
@@ -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() {
<ResizeObserverCase />
<FocusCase />
<GetClientRectsCase />
<ScrollIntoViewCase />
</FixtureSet>
);
}
+12 -3
View File
@@ -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'))
+272 -35
View File
@@ -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<StoredEventListener>,
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<Fiber> = [];
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<Instance>(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<StoredEventListener>,
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<T>(
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<Fiber>,
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<Instance>(child);
const prevChild = children[alignToTop ? i + 1 : i - 1];
if (prevChild) {
const prevInstance = getInstanceFromHostFiber<Instance>(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<Instance>(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(
@@ -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(<Fragment ref={fragmentRef} />);
});
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(
<React.Fragment ref={fragmentRef}>
<div ref={childARef} id="a">
A
</div>
<div ref={childBRef} id="b">
B
</div>
</React.Fragment>,
);
});
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(
<Fragment ref={fragmentRef}>
<div ref={childARef}>A</div>
<div ref={childBRef}>B</div>
</Fragment>,
);
});
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 (
<Fragment ref={fragmentRef}>
{createPortal(
<div ref={childARef} id="child-a">
A
</div>,
document.body,
)}
<div ref={childBRef} id="child-b">
B
</div>
</Fragment>
);
}
await act(() => {
root.render(<Test />);
});
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 (
<>
<div id="header" style={{position: 'fixed'}}>
<div id="parent-a" />
</div>
<div id="parent-b" />
<div
id="scroll-container"
ref={scrollContainerRef}
style={{overflow: 'scroll'}}>
<div id="parent-c" />
<div
id="scroll-container-nested"
ref={scrollContainerNestedRef}
style={{overflow: 'scroll'}}>
<div id="parent-d" />
</div>
</div>
{mountFragment && (
<Fragment ref={fragmentRef}>
{createPortal(
<div ref={headerChildRef} id="header-content">
Header
</div>,
document.querySelector('#parent-a'),
)}
{createPortal(
<div ref={childARef} id="child-a">
A
</div>,
document.querySelector('#parent-b'),
)}
{createPortal(
<div ref={childBRef} id="child-b">
B
</div>,
document.querySelector('#parent-b'),
)}
{createPortal(
<div ref={childCRef} id="child-c">
C
</div>,
document.querySelector('#parent-c'),
)}
</Fragment>
)}
</>
);
}
await act(() => {
root.render(<Test mountFragment={false} />);
});
// Now that the portal locations exist, mount the fragment
await act(() => {
root.render(<Test mountFragment={true} />);
});
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(
<div>
<Wrapper>
<div ref={siblingARef} />
</Wrapper>
<Fragment ref={fragmentRef} />
<div ref={siblingBRef} />
</div>,
);
});
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 (
<Wrapper>
<div id="C" ref={siblingARef} />
</Wrapper>
);
}
function Test() {
return (
<div id="A">
<div id="B" />
<C />
<Fragment ref={fragmentRef} />
<div id="D" ref={siblingBRef} />
<div id="E" />
</div>
);
}
await act(() => {
root.render(<Test />);
});
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(
<div ref={parentRef}>
<Wrapper>
<Fragment ref={fragmentRef} />
</Wrapper>
</div>,
);
});
parentRef.current.scrollIntoView = jest.fn();
fragmentRef.current.scrollIntoView();
expect(parentRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
});
});
});
});
@@ -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,
});
}
@@ -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<I>(fiber: Fiber): I {
switch (fiber.tag) {
case HostComponent:
+2 -1
View File
@@ -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."
}