mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
Add scrollIntoView to fragment instances (#32814)
This adds `experimental_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 or more host children: In order to handle the case of children spread between multiple scroll containers, we scroll to each child in reverse order based on the `alignToTop` flag. Due to the complexity of multiple scroll containers and dealing with portals, I've added this under a separate feature flag with an experimental prefix. We may stabilize it along with the other APIs, but this allows us to not block the whole feature on it. This PR was previously implementing a much more complex approach to handling multiple scroll containers and portals. We're going to start with the simple loop and see if we can find any concrete use cases where that doesn't suffice. 01f31d43013ba7f6f54fd8a36990bbafc3c3cc68 is the diff between approaches here.
This commit is contained in:
@@ -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,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 (
|
||||
<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.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 (
|
||||
<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>
|
||||
In order to handle the case where children are split between
|
||||
multiple scroll containers, we call scrollIntoView on each child in
|
||||
reverse order.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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<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 +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'}`;
|
||||
}
|
||||
|
||||
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;
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
// First, get the children nodes
|
||||
const children: Array<Fiber> = [];
|
||||
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<Instance>(targetFiber);
|
||||
target.scrollIntoView(alignToTop);
|
||||
return;
|
||||
}
|
||||
|
||||
let i = resolvedAlignToTop ? children.length - 1 : 0;
|
||||
while (i !== (resolvedAlignToTop ? -1 : children.length)) {
|
||||
const child = children[i];
|
||||
const instance = getInstanceFromHostFiber<Instance>(child);
|
||||
instance.scrollIntoView(alignToTop);
|
||||
i += resolvedAlignToTop ? -1 : 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createFragmentInstance(
|
||||
|
||||
@@ -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(<Fragment ref={fragmentRef} />);
|
||||
});
|
||||
|
||||
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(
|
||||
<React.Fragment ref={fragmentRef}>
|
||||
<div ref={childARef} id="a">
|
||||
A
|
||||
</div>
|
||||
<div ref={childBRef} id="b">
|
||||
B
|
||||
</div>
|
||||
</React.Fragment>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<Fragment ref={fragmentRef}>
|
||||
<div ref={childARef}>A</div>
|
||||
<div ref={childBRef}>B</div>
|
||||
</Fragment>,
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<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 />);
|
||||
});
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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} />);
|
||||
});
|
||||
|
||||
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(
|
||||
<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.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 (
|
||||
<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.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(
|
||||
<div ref={parentRef}>
|
||||
<Wrapper>
|
||||
<Fragment ref={fragmentRef} />
|
||||
</Wrapper>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
parentRef.current.scrollIntoView = jest.fn();
|
||||
fragmentRef.current.experimental_scrollIntoView();
|
||||
expect(parentRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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__;
|
||||
|
||||
@@ -27,6 +27,7 @@ export const {
|
||||
passChildrenWhenCloningPersistedNodes,
|
||||
renameElementSymbol,
|
||||
enableFragmentRefs,
|
||||
enableFragmentRefsScrollIntoView,
|
||||
} = dynamicFlags;
|
||||
|
||||
// The rest of the flags are static for better dead code elimination.
|
||||
|
||||
@@ -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__;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,7 @@ export const {
|
||||
enableComponentPerformanceTrack,
|
||||
enableScrollEndPolyfill,
|
||||
enableFragmentRefs,
|
||||
enableFragmentRefsScrollIntoView,
|
||||
} = dynamicFeatureFlags;
|
||||
|
||||
// On WWW, __EXPERIMENTAL__ is used for a new modern build.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user