/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails reactcore
*/
'use strict';
let React;
let ReactDOMClient;
let ReactDOM;
let createPortal;
let act;
let container;
let Fragment;
let Activity;
let mockIntersectionObserver;
let simulateIntersection;
let setClientRects;
let mockRangeClientRects;
let assertConsoleErrorDev;
let assertConsoleWarnDev;
function Wrapper({children}) {
return children;
}
describe('FragmentRefs', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
Fragment = React.Fragment;
Activity = React.Activity;
ReactDOMClient = require('react-dom/client');
ReactDOM = require('react-dom');
createPortal = ReactDOM.createPortal;
act = require('internal-test-utils').act;
const IntersectionMocks = require('./utils/IntersectionMocks');
mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver;
simulateIntersection = IntersectionMocks.simulateIntersection;
setClientRects = IntersectionMocks.setClientRects;
mockRangeClientRects = IntersectionMocks.mockRangeClientRects;
assertConsoleErrorDev =
require('internal-test-utils').assertConsoleErrorDev;
assertConsoleWarnDev = require('internal-test-utils').assertConsoleWarnDev;
container = document.createElement('div');
document.body.innerHTML = '';
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
// @gate enableFragmentRefs
it('attaches a ref to Fragment', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
,
),
);
expect(container.innerHTML).toEqual(
'',
);
expect(fragmentRef.current).not.toBe(null);
});
// @gate enableFragmentRefs
it('accepts a ref callback', async () => {
let fragmentRef;
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(
(fragmentRef = ref)}>
Hi
,
);
});
expect(fragmentRef._fragmentFiber).toBeTruthy();
});
// @gate enableFragmentRefs
it('is available in effects', async () => {
function Test() {
const fragmentRef = React.useRef(null);
React.useLayoutEffect(() => {
expect(fragmentRef.current).not.toBe(null);
});
React.useEffect(() => {
expect(fragmentRef.current).not.toBe(null);
});
return (
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render( ));
});
// @gate enableFragmentRefs && enableFragmentRefsInstanceHandles
it('attaches fragment handles to nodes', async () => {
const fragmentParentRef = React.createRef();
const fragmentRef = React.createRef();
function Test({show}) {
return (
A
B
C
{show && D
}
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render( ));
const childA = document.querySelector('#childA');
const childB = document.querySelector('#childB');
const childC = document.querySelector('#childC');
expect(childA.reactFragments.has(fragmentRef.current)).toBe(true);
expect(childB.reactFragments.has(fragmentRef.current)).toBe(true);
expect(childC.reactFragments.has(fragmentRef.current)).toBe(false);
expect(childA.reactFragments.has(fragmentParentRef.current)).toBe(true);
expect(childB.reactFragments.has(fragmentParentRef.current)).toBe(true);
expect(childC.reactFragments.has(fragmentParentRef.current)).toBe(true);
await act(() => root.render( ));
const childD = document.querySelector('#childD');
expect(childD.reactFragments.has(fragmentRef.current)).toBe(false);
expect(childD.reactFragments.has(fragmentParentRef.current)).toBe(true);
});
describe('focus methods', () => {
describe('focus()', () => {
// @gate enableFragmentRefs
it('focuses the first focusable child', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('child-b');
document.activeElement.blur();
});
// @gate enableFragmentRefs
it('focuses deeply nested focusable children, depth first', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('grandchild-a');
});
// @gate enableFragmentRefs
it('preserves document order when adding and removing children', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test({showA, showB}) {
return (
{showA && }
{showB && }
);
}
// Render with A as the first focusable child
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('child-a');
document.activeElement.blur();
// A is still the first focusable child, but B is also tracked
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('child-a');
document.activeElement.blur();
// B is now the first focusable child
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('child-b');
document.activeElement.blur();
});
// @gate enableFragmentRefs
it('keeps focus on the first focusable child if already focused', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
A
B
);
}
await act(() => {
root.render( );
});
// Focus the first child manually
document.getElementById('child-a').focus();
expect(document.activeElement.id).toEqual('child-a');
// Calling fragment.focus() should keep focus on child-a,
// not skip to child-b
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('child-a');
document.activeElement.blur();
});
// @gate enableFragmentRefs
it('keeps focus on a nested child if already focused', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
Link
);
}
await act(() => {
root.render( );
});
// Focus the nested input manually
document.getElementById('nested-input').focus();
expect(document.activeElement.id).toEqual('nested-input');
// Calling fragment.focus() should keep focus on nested-input
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('nested-input');
document.activeElement.blur();
});
// @gate enableFragmentRefs
it('focuses the first focusable child in a fieldset', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
Shipping
);
}
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('street');
document.activeElement.blur();
});
});
describe('focusLast()', () => {
// @gate enableFragmentRefs
it('focuses the last focusable child', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focusLast();
});
expect(document.activeElement.id).toEqual('child-c');
document.activeElement.blur();
});
// @gate enableFragmentRefs
it('focuses deeply nested focusable children, depth first', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focusLast();
});
expect(document.activeElement.id).toEqual('grandchild-b');
});
});
describe('blur()', () => {
// @gate enableFragmentRefs
it('removes focus from an element inside of the Fragment', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
A
);
}
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('child-a');
await act(() => {
fragmentRef.current.blur();
});
expect(document.activeElement).toEqual(document.body);
});
// @gate enableFragmentRefs
it('removes focus from a nested element inside of the Fragment', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('nested-input');
await act(() => {
fragmentRef.current.blur();
});
expect(document.activeElement).toEqual(document.body);
});
// @gate enableFragmentRefs
it('removes focus from a portaled element inside of the Fragment', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => {
root.render( );
});
await act(() => {
fragmentRef.current.focus();
});
expect(document.activeElement.id).toEqual('portaled-input');
await act(() => {
fragmentRef.current.blur();
});
expect(document.activeElement).toEqual(document.body);
});
// @gate enableFragmentRefs
it('does not remove focus from elements outside of the Fragment', async () => {
const fragmentRefA = React.createRef();
const fragmentRefB = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
A
B
);
}
await act(() => {
root.render( );
});
await act(() => {
fragmentRefA.current.focus();
});
expect(document.activeElement.id).toEqual('child-a');
await act(() => {
fragmentRefB.current.blur();
});
expect(document.activeElement.id).toEqual('child-a');
});
});
});
describe('events', () => {
describe('add/remove event listeners', () => {
// @gate enableFragmentRefs
it('adds and removes event listeners from children', async () => {
const parentRef = React.createRef();
const fragmentRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
let logs = [];
function handleFragmentRefClicks() {
logs.push('fragmentRef');
}
function Test() {
React.useEffect(() => {
fragmentRef.current.addEventListener(
'click',
handleFragmentRefClicks,
);
return () => {
fragmentRef.current.removeEventListener(
'click',
handleFragmentRefClicks,
);
};
}, []);
return (
);
}
await act(() => {
root.render( );
});
childARef.current.addEventListener('click', () => {
logs.push('A');
});
childBRef.current.addEventListener('click', () => {
logs.push('B');
});
// Clicking on the parent should not trigger any listeners
parentRef.current.click();
expect(logs).toEqual([]);
// Clicking a child triggers its own listeners and the Fragment's
childARef.current.click();
expect(logs).toEqual(['fragmentRef', 'A']);
logs = [];
childBRef.current.click();
expect(logs).toEqual(['fragmentRef', 'B']);
logs = [];
fragmentRef.current.removeEventListener(
'click',
handleFragmentRefClicks,
);
childARef.current.click();
expect(logs).toEqual(['A']);
logs = [];
childBRef.current.click();
expect(logs).toEqual(['B']);
});
// @gate enableFragmentRefs
it('regression: does not detach a registered listener when removing an unregistered one', async () => {
const fragmentRef = React.createRef();
const childRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
let logs = [];
function registeredListener() {
logs.push('registered');
}
function unregisteredListener() {
logs.push('unregistered');
}
await act(() => {
root.render(
child
,
);
});
fragmentRef.current.addEventListener('click', registeredListener);
childRef.current.click();
expect(logs).toEqual(['registered']);
// Regression: removing a listener that was never added must be a no-op.
// It must not detach registered listeners from fragmentInstance,
// causing them to stay attached to DOM even after removeEventListener.
fragmentRef.current.removeEventListener('click', unregisteredListener);
logs = [];
childRef.current.click();
expect(logs).toEqual(['registered']);
fragmentRef.current.removeEventListener('click', registeredListener);
logs = [];
childRef.current.click();
expect(logs).toEqual([]);
});
// @gate enableFragmentRefs
it('adds and removes event listeners from children with multiple fragments', async () => {
const fragmentRef = React.createRef();
const nestedFragmentRef = React.createRef();
const nestedFragmentRef2 = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const childCRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(
,
);
});
let logs = [];
function handleFragmentRefClicks() {
logs.push('fragmentRef');
}
function handleNestedFragmentRefClicks() {
logs.push('nestedFragmentRef');
}
function handleNestedFragmentRef2Clicks() {
logs.push('nestedFragmentRef2');
}
fragmentRef.current.addEventListener('click', handleFragmentRefClicks);
nestedFragmentRef.current.addEventListener(
'click',
handleNestedFragmentRefClicks,
);
nestedFragmentRef2.current.addEventListener(
'click',
handleNestedFragmentRef2Clicks,
);
childBRef.current.click();
// Event bubbles to the parent fragment
expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']);
logs = [];
childARef.current.click();
expect(logs).toEqual(['fragmentRef']);
logs = [];
childCRef.current.click();
expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']);
logs = [];
fragmentRef.current.removeEventListener(
'click',
handleFragmentRefClicks,
);
nestedFragmentRef.current.removeEventListener(
'click',
handleNestedFragmentRefClicks,
);
childCRef.current.click();
expect(logs).toEqual(['nestedFragmentRef2']);
});
// @gate enableFragmentRefs
it('adds an event listener to a newly added child', async () => {
const fragmentRef = React.createRef();
const childRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
let showChild;
function Component() {
const [shouldShowChild, setShouldShowChild] = React.useState(false);
showChild = () => {
setShouldShowChild(true);
};
return (
A
{shouldShowChild && (
B
)}
);
}
await act(() => {
root.render( );
});
expect(fragmentRef.current).not.toBe(null);
expect(childRef.current).toBe(null);
let hasClicked = false;
fragmentRef.current.addEventListener('click', () => {
hasClicked = true;
});
await act(() => {
showChild();
});
expect(childRef.current).not.toBe(null);
childRef.current.click();
expect(hasClicked).toBe(true);
});
// @gate enableFragmentRefs
it('fires a once listener only once across existing children', async () => {
const fragmentRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(
,
);
});
const logs = [];
fragmentRef.current.addEventListener(
'click',
() => {
logs.push('once');
},
{once: true},
);
childARef.current.click();
expect(logs).toEqual(['once']);
logs.length = 0;
childBRef.current.click();
expect(logs).toEqual([]);
});
// @gate enableFragmentRefs
it('does not re-arm a once listener when a new child is inserted', async () => {
const fragmentRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
let showChildB;
function Component() {
const [shouldShowChildB, setShouldShowChildB] = React.useState(false);
showChildB = () => {
setShouldShowChildB(true);
};
return (
A
{shouldShowChildB && (
B
)}
);
}
await act(() => {
root.render( );
});
const logs = [];
fragmentRef.current.addEventListener(
'click',
() => {
logs.push('once');
},
{once: true},
);
childARef.current.click();
expect(logs).toEqual(['once']);
await act(() => {
showChildB();
});
logs.length = 0;
childBRef.current.click();
expect(logs).toEqual([]);
});
// @gate enableFragmentRefs && enableFragmentRefsTextNodes
it('adds an event listener to a newly added text child', async () => {
const fragmentRef = React.createRef();
const parentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
let showText;
function Component() {
const [shouldShowText, setShouldShowText] = React.useState(false);
showText = () => {
setShouldShowText(true);
};
return (
{shouldShowText ? 'Hello' : null}
);
}
await act(() => {
root.render( );
});
const logs = [];
fragmentRef.current.addEventListener('click', () => {
logs.push('fragment');
});
await act(() => {
showText();
});
const textNode = Array.from(parentRef.current.childNodes).find(
node => node.nodeType === 3,
);
expect(textNode).not.toBe(undefined);
textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
expect(logs).toEqual(['fragment']);
});
// @gate enableFragmentRefs && enableFragmentRefsTextNodes
it('removes event listeners from a deleted text child', async () => {
const fragmentRef = React.createRef();
const parentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
let hideText;
function Component() {
const [shouldShowText, setShouldShowText] = React.useState(true);
hideText = () => {
setShouldShowText(false);
};
return (
{shouldShowText ? 'Hello' : null}
);
}
await act(() => {
root.render( );
});
const textNode = Array.from(parentRef.current.childNodes).find(
node => node.nodeType === 3,
);
expect(textNode).not.toBe(undefined);
const logs = [];
fragmentRef.current.addEventListener('click', () => {
logs.push('fragment');
});
textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
expect(logs).toEqual(['fragment']);
await act(() => {
hideText();
});
const detachedHost = document.createElement('div');
document.body.appendChild(detachedHost);
detachedHost.appendChild(textNode);
logs.length = 0;
textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
expect(logs).toEqual([]);
document.body.removeChild(detachedHost);
});
// @gate enableFragmentRefs
it('applies event listeners to host children nested within non-host children', async () => {
const fragmentRef = React.createRef();
const childRef = React.createRef();
const nestedChildRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(
,
);
});
const logs = [];
fragmentRef.current.addEventListener('click', e => {
logs.push(e.target.textContent);
});
expect(logs).toEqual([]);
childRef.current.click();
expect(logs).toEqual(['Host A']);
nestedChildRef.current.click();
expect(logs).toEqual(['Host A', 'Host B']);
});
// @gate enableFragmentRefs
it('allows adding and cleaning up listeners in effects', async () => {
const root = ReactDOMClient.createRoot(container);
let logs = [];
function logClick(e) {
logs.push(e.currentTarget.id);
}
let rerender;
let removeEventListeners;
function Test() {
const fragmentRef = React.useRef(null);
// eslint-disable-next-line no-unused-vars
const [_, setState] = React.useState(0);
rerender = () => {
setState(p => p + 1);
};
removeEventListeners = () => {
fragmentRef.current.removeEventListener('click', logClick);
};
React.useEffect(() => {
fragmentRef.current.addEventListener('click', logClick);
return removeEventListeners;
});
return (
);
}
// The event listener was applied
await act(() => root.render( ));
expect(logs).toEqual([]);
document.querySelector('#child-a').click();
expect(logs).toEqual(['child-a']);
// The event listener can be removed and re-added
logs = [];
await act(rerender);
document.querySelector('#child-a').click();
expect(logs).toEqual(['child-a']);
});
// @gate enableFragmentRefs
it('does not apply removed event listeners to new children', async () => {
const root = ReactDOMClient.createRoot(container);
const fragmentRef = React.createRef(null);
function Test() {
return (
);
}
let logs = [];
function logClick(e) {
logs.push(e.currentTarget.id);
}
await act(() => {
root.render( );
});
fragmentRef.current.addEventListener('click', logClick);
const childA = document.querySelector('#child-a');
childA.click();
expect(logs).toEqual(['child-a']);
logs = [];
fragmentRef.current.removeEventListener('click', logClick);
childA.click();
expect(logs).toEqual([]);
});
// @gate enableFragmentRefs
it('removes a capture listener registered with boolean when removed with options object', async () => {
const fragmentRef = React.createRef(null);
function Test() {
return (
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render( );
});
const logs = [];
function logCapture() {
logs.push('capture');
}
// Register with boolean `true` (capture phase)
fragmentRef.current.addEventListener('click', logCapture, true);
document.querySelector('#child-a').click();
expect(logs).toEqual(['capture']);
logs.length = 0;
// Remove with equivalent options object {capture: true}
// Per DOM spec, these are identical - the listener MUST be removed
fragmentRef.current.removeEventListener('click', logCapture, {
capture: true,
});
document.querySelector('#child-a').click();
// Listener should have been removed - logs must remain empty
expect(logs).toEqual([]);
});
// @gate enableFragmentRefs
it('removes a capture listener registered with options object when removed with boolean', async () => {
const fragmentRef = React.createRef(null);
function Test() {
return (
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render( );
});
const logs = [];
function logCapture() {
logs.push('capture');
}
// Register with options object {capture: true}
fragmentRef.current.addEventListener('click', logCapture, {
capture: true,
});
document.querySelector('#child-b').click();
expect(logs).toEqual(['capture']);
logs.length = 0;
// Remove with boolean `true`
// Per DOM spec, these are identical - the listener MUST be removed
fragmentRef.current.removeEventListener('click', logCapture, true);
document.querySelector('#child-b').click();
// Listener should have been removed - logs must remain empty
expect(logs).toEqual([]);
});
// @gate enableFragmentRefs
it('applies event listeners to portaled children', async () => {
const fragmentRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
{createPortal(
,
document.body,
)}
);
}
await act(() => {
root.render( );
});
const logs = [];
fragmentRef.current.addEventListener('click', e => {
logs.push(e.target.id);
});
childARef.current.click();
expect(logs).toEqual(['child-a']);
logs.length = 0;
childBRef.current.click();
expect(logs).toEqual(['child-b']);
});
// @gate enableFragmentRefs
it('applies event listeners to children portaled in after registration', async () => {
const fragmentRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
let showChildB;
function Test() {
const [shouldShowChildB, setShouldShowChildB] = React.useState(false);
showChildB = () => {
setShouldShowChildB(true);
};
return (
{createPortal(
<>
{shouldShowChildB &&
}
>,
document.body,
)}
);
}
await act(() => {
root.render( );
});
const logs = [];
fragmentRef.current.addEventListener('click', e => {
logs.push(e.target.id);
});
childARef.current.click();
expect(logs).toEqual(['child-a']);
// child-b is inserted into the same portal after the listener was
// registered, so it should be treated like its sibling child-a.
await act(() => {
showChildB();
});
logs.length = 0;
childBRef.current.click();
expect(logs).toEqual(['child-b']);
});
describe('with activity', () => {
// @gate enableFragmentRefs
it('does not apply event listeners to hidden trees', async () => {
const parentRef = React.createRef();
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => {
root.render( );
});
const logs = [];
fragmentRef.current.addEventListener('click', e => {
logs.push(e.target.textContent);
});
const [child1, child2, child3] = parentRef.current.children;
child1.click();
child2.click();
child3.click();
expect(logs).toEqual(['Child 1', 'Child 3']);
});
// @gate enableFragmentRefs
it('applies event listeners to visible trees', async () => {
const parentRef = React.createRef();
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => {
root.render( );
});
const logs = [];
fragmentRef.current.addEventListener('click', e => {
logs.push(e.target.textContent);
});
const [child1, child2, child3] = parentRef.current.children;
child1.click();
child2.click();
child3.click();
expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']);
});
// @gate enableFragmentRefs
it('handles Activity modes switching', async () => {
const fragmentRef = React.createRef();
const fragmentRef2 = React.createRef();
const parentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test({mode}) {
return (
);
}
await act(() => {
root.render( );
});
let logs = [];
fragmentRef.current.addEventListener('click', () => {
logs.push('clicked 1');
});
fragmentRef2.current.addEventListener('click', () => {
logs.push('clicked 2');
});
parentRef.current.lastChild.click();
expect(logs).toEqual(['clicked 1', 'clicked 2']);
logs = [];
await act(() => {
root.render( );
});
parentRef.current.firstChild.click();
parentRef.current.lastChild.click();
expect(logs).toEqual([]);
logs = [];
await act(() => {
root.render( );
});
parentRef.current.lastChild.click();
// Event order is flipped here because the nested child re-registers first
expect(logs).toEqual(['clicked 2', 'clicked 1']);
});
// @gate enableFragmentRefs && enableFragmentRefsTextNodes
it('does not dispatch fragment events from text children while hidden', async () => {
const parentRef = React.createRef();
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test({mode}) {
return (
);
}
await act(() => {
root.render( );
});
const logs = [];
fragmentRef.current.addEventListener('click', e => {
logs.push(
e.target.nodeType === 3
? 'text'
: e.target.id || e.target.tagName,
);
});
const textNode = Array.from(parentRef.current.childNodes).find(
node => node.nodeType === 3,
);
expect(textNode).not.toBe(undefined);
document.getElementById('child').click();
textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
expect(logs).toEqual(['child', 'text']);
logs.length = 0;
await act(() => {
root.render( );
});
document.getElementById('child').click();
textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
expect(logs).toEqual([]);
logs.length = 0;
await act(() => {
root.render( );
});
document.getElementById('child').click();
textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
expect(logs).toEqual(['child', 'text']);
});
});
});
describe('dispatchEvent()', () => {
// @gate enableFragmentRefs
it('fires events on the host parent if bubbles=true', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
let logs = [];
function handleClick(e) {
logs.push([e.type, e.target.id, e.currentTarget.id]);
}
function Test({isMounted}) {
return (
);
}
await act(() => {
root.render( );
});
let isCancelable = !fragmentRef.current.dispatchEvent(
new MouseEvent('click', {bubbles: true}),
);
expect(logs).toEqual([
['click', 'parent', 'parent'],
['click', 'parent', 'grandparent'],
]);
expect(isCancelable).toBe(false);
const fragmentInstanceHandle = fragmentRef.current;
await act(() => {
root.render( );
});
logs = [];
isCancelable = !fragmentInstanceHandle.dispatchEvent(
new MouseEvent('click', {bubbles: true}),
);
expect(logs).toEqual([]);
expect(isCancelable).toBe(false);
logs = [];
isCancelable = !fragmentInstanceHandle.dispatchEvent(
new MouseEvent('click', {bubbles: false}),
);
expect(logs).toEqual([]);
expect(isCancelable).toBe(false);
});
// @gate enableFragmentRefs
it('fires events on self, and only self if bubbles=false', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
let logs = [];
function handleClick(e) {
logs.push([e.type, e.target.id, e.currentTarget.id]);
}
function Test() {
return (
);
}
await act(() => {
root.render( );
});
fragmentRef.current.addEventListener('click', handleClick);
fragmentRef.current.dispatchEvent(
new MouseEvent('click', {bubbles: true}),
);
expect(logs).toEqual([
['click', undefined, undefined],
['click', 'parent', 'parent'],
]);
logs = [];
fragmentRef.current.dispatchEvent(
new MouseEvent('click', {bubbles: false}),
);
expect(logs).toEqual([['click', undefined, undefined]]);
});
});
});
describe('observers', () => {
beforeEach(() => {
mockIntersectionObserver();
});
// @gate enableFragmentRefs
it('attaches intersection observers to children', async () => {
let logs = [];
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
logs.push(entry.target.id);
});
});
function Test({showB}) {
const fragmentRef = React.useRef(null);
React.useEffect(() => {
fragmentRef.current.observeUsing(observer);
const lastRefValue = fragmentRef.current;
return () => {
lastRefValue.unobserveUsing(observer);
};
}, []);
return (
);
}
function simulateAllChildrenIntersecting() {
const parent = container.firstChild;
if (parent) {
const children = Array.from(parent.children).map(child => {
return [child, {y: 0, x: 0, width: 1, height: 1}, 1];
});
simulateIntersection(...children);
}
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render( ));
simulateAllChildrenIntersecting();
expect(logs).toEqual(['childA']);
// Reveal child and expect it to be observed
logs = [];
await act(() => root.render( ));
simulateAllChildrenIntersecting();
expect(logs).toEqual(['childA', 'childB']);
// Hide child and expect it to be unobserved
logs = [];
await act(() => root.render( ));
simulateAllChildrenIntersecting();
expect(logs).toEqual(['childA']);
// Unmount component and expect all children to be unobserved
logs = [];
await act(() => root.render(null));
simulateAllChildrenIntersecting();
expect(logs).toEqual([]);
});
// @gate enableFragmentRefs
it('warns when unobserveUsing() is called with an observer that was not observed', async () => {
const fragmentRef = React.createRef();
const observer = new IntersectionObserver(() => {});
const observer2 = new IntersectionObserver(() => {});
function Test() {
return (
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render( ));
// Warning when there is no attached observer
fragmentRef.current.unobserveUsing(observer);
assertConsoleErrorDev([
'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' +
'instance. First attach the observer with observeUsing()',
]);
// Warning when the attached observer does not match
fragmentRef.current.observeUsing(observer);
fragmentRef.current.unobserveUsing(observer2);
assertConsoleErrorDev([
'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' +
'instance. First attach the observer with observeUsing()',
]);
});
// @gate enableFragmentRefs && enableFragmentRefsInstanceHandles
it('attaches handles to observed elements to allow caching of observers', async () => {
const targetToCallbackMap = new WeakMap();
let cachedObserver = null;
function createObserverIfNeeded(fragmentInstance, onIntersection) {
const callbacks = targetToCallbackMap.get(fragmentInstance);
targetToCallbackMap.set(
fragmentInstance,
callbacks ? [...callbacks, onIntersection] : [onIntersection],
);
if (cachedObserver !== null) {
return cachedObserver;
}
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const fragmentInstances = entry.target.reactFragments;
if (fragmentInstances) {
Array.from(fragmentInstances).forEach(fInstance => {
const cbs = targetToCallbackMap.get(fInstance) || [];
cbs.forEach(callback => {
callback(entry);
});
});
}
targetToCallbackMap.get(entry.target)?.forEach(callback => {
callback(entry);
});
});
});
cachedObserver = observer;
return observer;
}
function IntersectionObserverFragment({onIntersection, children}) {
const fragmentRef = React.useRef(null);
React.useLayoutEffect(() => {
const observer = createObserverIfNeeded(
fragmentRef.current,
onIntersection,
);
fragmentRef.current.observeUsing(observer);
const lastRefValue = fragmentRef.current;
return () => {
lastRefValue.unobserveUsing(observer);
};
}, []);
return {children} ;
}
let logs = [];
function logIntersection(id) {
logs.push(`observe: ${id}`);
}
function ChildWithManualIO({id}) {
const divRef = React.useRef(null);
React.useLayoutEffect(() => {
const observer = createObserverIfNeeded(divRef.current, entry => {
logIntersection(id);
});
observer.observe(divRef.current);
return () => {
observer.unobserve(divRef.current);
};
}, []);
return (
{id}
);
}
function Test() {
return (
<>
logIntersection('grandparent')}>
logIntersection('parentA')}>
A
logIntersection('parentB')}>
B
>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render( ));
simulateIntersection([
container.querySelector('#childA'),
{y: 0, x: 0, width: 1, height: 1},
1,
]);
expect(logs).toEqual(['observe: grandparent', 'observe: parentA']);
logs = [];
simulateIntersection([
container.querySelector('#childB'),
{y: 0, x: 0, width: 1, height: 1},
1,
]);
expect(logs).toEqual(['observe: parentB']);
logs = [];
simulateIntersection([
container.querySelector('#childC'),
{y: 0, x: 0, width: 1, height: 1},
1,
]);
expect(logs).toEqual(['observe: parentB', 'observe: childC']);
});
});
describe('getClientRects', () => {
// @gate enableFragmentRefs
it('returns the bounding client rects of all children', async () => {
const fragmentRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => root.render( ));
setClientRects(childARef.current, [
{
x: 1,
y: 2,
width: 3,
height: 4,
},
{
x: 5,
y: 6,
width: 7,
height: 8,
},
]);
setClientRects(childBRef.current, [{x: 9, y: 10, width: 11, height: 12}]);
const clientRects = fragmentRef.current.getClientRects();
expect(clientRects.length).toBe(3);
expect(clientRects[0].left).toBe(1);
expect(clientRects[1].left).toBe(5);
expect(clientRects[2].left).toBe(9);
});
});
describe('getRootNode', () => {
// @gate enableFragmentRefs
it('returns the root node of the parent', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => root.render( ));
expect(fragmentRef.current.getRootNode()).toBe(document);
});
// The desired behavior here is to return the topmost disconnected element when
// fragment + parent are unmounted. Currently we have a pass during unmount that
// recursively cleans up return pointers of the whole tree. We can change this
// with a future refactor. See: https://github.com/facebook/react/pull/32682#discussion_r2008313082
// @gate enableFragmentRefs
it('returns the topmost disconnected element if the fragment and parent are unmounted', async () => {
const containerRef = React.createRef();
const parentRef = React.createRef();
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test({mounted}) {
return (
);
}
await act(() => root.render( ));
expect(fragmentRef.current.getRootNode()).toBe(document);
const fragmentHandle = fragmentRef.current;
await act(() => root.render( ));
// TODO: The commented out assertion is the desired behavior. For now, we return
// the fragment instance itself. This is currently the same behavior if you unmount
// the fragment but not the parent. See context above.
// expect(fragmentHandle.getRootNode().id).toBe(parentRefHandle.id);
expect(fragmentHandle.getRootNode()).toBe(fragmentHandle);
});
// @gate enableFragmentRefs
it('returns self when only the fragment was unmounted', async () => {
const fragmentRef = React.createRef();
const parentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test({mounted}) {
return (
);
}
await act(() => root.render( ));
expect(fragmentRef.current.getRootNode()).toBe(document);
const fragmentHandle = fragmentRef.current;
await act(() => root.render( ));
expect(fragmentHandle.getRootNode()).toBe(fragmentHandle);
});
});
describe('compareDocumentPosition', () => {
function expectPosition(position, spec) {
const positionResult = {
following: (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
preceding: (position & Node.DOCUMENT_POSITION_PRECEDING) !== 0,
contains: (position & Node.DOCUMENT_POSITION_CONTAINS) !== 0,
containedBy: (position & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0,
disconnected: (position & Node.DOCUMENT_POSITION_DISCONNECTED) !== 0,
implementationSpecific:
(position & Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC) !== 0,
};
expect(positionResult).toEqual(spec);
}
// @gate enableFragmentRefs
it('returns the relationship between the fragment instance and a given node', async () => {
const fragmentRef = React.createRef();
const beforeRef = React.createRef();
const afterRef = React.createRef();
const middleChildRef = React.createRef();
const firstChildRef = React.createRef();
const lastChildRef = React.createRef();
const containerRef = React.createRef();
const disconnectedElement = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => root.render( ));
// document.body is preceding and contains the fragment
expectPosition(
fragmentRef.current.compareDocumentPosition(document.body),
{
preceding: true,
following: false,
contains: true,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
// beforeRef is preceding the fragment
expectPosition(
fragmentRef.current.compareDocumentPosition(beforeRef.current),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
// afterRef is following the fragment
expectPosition(
fragmentRef.current.compareDocumentPosition(afterRef.current),
{
preceding: false,
following: true,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
// firstChildRef is contained by the fragment
expectPosition(
fragmentRef.current.compareDocumentPosition(firstChildRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
// middleChildRef is contained by the fragment
expectPosition(
fragmentRef.current.compareDocumentPosition(middleChildRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
// lastChildRef is contained by the fragment
expectPosition(
fragmentRef.current.compareDocumentPosition(lastChildRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
// containerRef precedes and contains the fragment
expectPosition(
fragmentRef.current.compareDocumentPosition(containerRef.current),
{
preceding: true,
following: false,
contains: true,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(disconnectedElement),
{
preceding: false,
following: true,
contains: false,
containedBy: false,
disconnected: true,
implementationSpecific: true,
},
);
});
// @gate enableFragmentRefs
it('handles fragment instances with one child', async () => {
const fragmentRef = React.createRef();
const beforeRef = React.createRef();
const afterRef = React.createRef();
const containerRef = React.createRef();
const onlyChildRef = React.createRef();
const disconnectedElement = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => root.render( ));
expectPosition(
fragmentRef.current.compareDocumentPosition(beforeRef.current),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(afterRef.current),
{
preceding: false,
following: true,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(onlyChildRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(containerRef.current),
{
preceding: true,
following: false,
contains: true,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(disconnectedElement),
{
preceding: false,
following: true,
contains: false,
containedBy: false,
disconnected: true,
implementationSpecific: true,
},
);
});
// @gate enableFragmentRefs
it('handles empty fragment instances', async () => {
const fragmentRef = React.createRef();
const beforeParentRef = React.createRef();
const beforeRef = React.createRef();
const afterRef = React.createRef();
const afterParentRef = React.createRef();
const containerRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
<>
>
);
}
await act(() => root.render( ));
expectPosition(
fragmentRef.current.compareDocumentPosition(document.body),
{
preceding: true,
following: false,
contains: true,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(beforeRef.current),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(beforeParentRef.current),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(afterRef.current),
{
preceding: false,
following: true,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(afterParentRef.current),
{
preceding: false,
following: true,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(containerRef.current),
{
preceding: false,
following: false,
contains: true,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
});
// @gate enableFragmentRefs
it('handles empty fragments nested inside non-host wrappers', async () => {
const fragmentRef = React.createRef();
const beforeRef = React.createRef();
const afterRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => root.render( ));
expectPosition(
fragmentRef.current.compareDocumentPosition(beforeRef.current),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(afterRef.current),
{
preceding: false,
following: true,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
});
// @gate enableFragmentRefs
it('handles nested children', async () => {
const fragmentRef = React.createRef();
const nestedFragmentRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const childCRef = React.createRef();
document.body.appendChild(container);
const root = ReactDOMClient.createRoot(container);
function Child() {
return (
C
);
}
function Test() {
return (
A
B
);
}
await act(() => root.render( ));
expectPosition(
fragmentRef.current.compareDocumentPosition(childARef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childBRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childCRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
});
// @gate enableFragmentRefs
it('returns disconnected for comparison with an unmounted fragment instance', async () => {
const fragmentRef = React.createRef();
const containerRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test({mount}) {
return (
);
}
await act(() => root.render( ));
const fragmentHandle = fragmentRef.current;
expectPosition(
fragmentHandle.compareDocumentPosition(containerRef.current),
{
preceding: true,
following: false,
contains: true,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
await act(() => {
root.render( );
});
expectPosition(
fragmentHandle.compareDocumentPosition(containerRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: false,
disconnected: true,
implementationSpecific: false,
},
);
});
// @gate enableFragmentRefs
it('compares a root-level Fragment', async () => {
const fragmentRef = React.createRef();
const emptyFragmentRef = React.createRef();
const childRef = React.createRef();
const siblingPrecedingRef = React.createRef();
const siblingFollowingRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
);
}
await act(() => root.render( ));
const fragmentInstance = fragmentRef.current;
if (fragmentInstance == null) {
throw new Error('Expected fragment instance to be non-null');
}
const emptyFragmentInstance = emptyFragmentRef.current;
if (emptyFragmentInstance == null) {
throw new Error('Expected empty fragment instance to be non-null');
}
expectPosition(
fragmentInstance.compareDocumentPosition(childRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentInstance.compareDocumentPosition(siblingPrecedingRef.current),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentInstance.compareDocumentPosition(siblingFollowingRef.current),
{
preceding: false,
following: true,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
emptyFragmentInstance.compareDocumentPosition(childRef.current),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
emptyFragmentInstance.compareDocumentPosition(
siblingPrecedingRef.current,
),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
emptyFragmentInstance.compareDocumentPosition(
siblingFollowingRef.current,
),
{
preceding: false,
following: true,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
});
describe('with portals', () => {
// @gate enableFragmentRefs
it('handles portaled elements', async () => {
const fragmentRef = React.createRef();
const portaledSiblingRef = React.createRef();
const portaledChildRef = React.createRef();
function Test() {
return (
{createPortal(
, container)}
{createPortal(
, container)}
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render( ));
// The sibling is preceding in both the DOM and the React tree
expectPosition(
fragmentRef.current.compareDocumentPosition(
portaledSiblingRef.current,
),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
// The child is contained by in the React tree but not in the DOM
expectPosition(
fragmentRef.current.compareDocumentPosition(portaledChildRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
});
// @gate enableFragmentRefs
it('handles multiple portals to the same element', async () => {
const root = ReactDOMClient.createRoot(container);
const fragmentRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const childCRef = React.createRef();
const childDRef = React.createRef();
const childERef = React.createRef();
function Test() {
const [c, setC] = React.useState(false);
React.useEffect(() => {
setC(true);
});
return (
<>
{createPortal(
{c ? (
) : null}
,
document.body,
)}
{createPortal(
, document.body)}
>
);
}
await act(() => root.render( ));
// Due to effect, order is E / A->B->C->D
expect(document.body.outerHTML).toBe(
'' +
'' +
'
' +
'
' +
'' +
'',
);
expectPosition(
fragmentRef.current.compareDocumentPosition(document.body),
{
preceding: true,
following: false,
contains: true,
containedBy: false,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childARef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
// Contained by in DOM, but following in React tree
expectPosition(
fragmentRef.current.compareDocumentPosition(childBRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childCRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childDRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
// Preceding DOM but following in React tree
expectPosition(
fragmentRef.current.compareDocumentPosition(childERef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
});
// @gate enableFragmentRefs
it('handles empty fragments', async () => {
const fragmentRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
function Test() {
return (
<>
{createPortal( , document.body)}
>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render( ));
expectPosition(
fragmentRef.current.compareDocumentPosition(document.body),
{
preceding: false,
following: false,
contains: true,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childARef.current),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childBRef.current),
{
preceding: false,
following: true,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
});
// @gate enableFragmentRefs
it('positions empty portaled fragments against the portal container', async () => {
const fragmentRef = React.createRef();
const reactParentRef = React.createRef();
const portalTarget = document.createElement('div');
portalTarget.id = 'portal-target';
document.body.appendChild(portalTarget);
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
{createPortal( , portalTarget)}
);
}
await act(() => root.render( ));
// Empty CDP must use the portal container as parent
expectPosition(
fragmentRef.current.compareDocumentPosition(portalTarget),
{
preceding: false,
following: false,
contains: true,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(reactParentRef.current),
{
preceding: true,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
});
});
});
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.scrollIntoView({block: 'start'});
}).toThrowError(
'FragmentInstance.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.scrollIntoView();
expectLast(logs, 'childA');
logs = [];
// alignToTop=true
fragmentRef.current.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.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.scrollIntoView();
expectLast(logs, 'childA');
});
// @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
it('handles portaled elements -- different scroll container', async () => {
const fragmentRef = React.createRef();
const headerChildRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const childCRef = React.createRef();
const scrollContainerRef = React.createRef();
const scrollContainerNestedRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test({mountFragment}) {
return (
<>
{mountFragment && (
{createPortal(
,
document.querySelector('#parent-a'),
)}
{createPortal(
A
,
document.querySelector('#parent-b'),
)}
{createPortal(
B
,
document.querySelector('#parent-b'),
)}
{createPortal(
C
,
document.querySelector('#parent-c'),
)}
)}
>
);
}
await act(() => {
root.render( );
});
// Now that the portal locations exist, mount the fragment
await act(() => {
root.render( );
});
let logs = [];
headerChildRef.current.scrollIntoView = jest.fn(() => {
logs.push('header');
});
childARef.current.scrollIntoView = jest.fn(() => {
logs.push('A');
});
childBRef.current.scrollIntoView = jest.fn(() => {
logs.push('B');
});
childCRef.current.scrollIntoView = jest.fn(() => {
logs.push('C');
});
// Default call
fragmentRef.current.scrollIntoView();
expectLast(logs, 'header');
childARef.current.scrollIntoView.mockClear();
childBRef.current.scrollIntoView.mockClear();
childCRef.current.scrollIntoView.mockClear();
logs = [];
// // alignToTop=false
fragmentRef.current.scrollIntoView(false);
expectLast(logs, 'C');
});
});
describe('without children', () => {
// @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
it('calls scrollIntoView on the next sibling by default, or if alignToTop=true', async () => {
const fragmentRef = React.createRef();
const siblingARef = React.createRef();
const siblingBRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(
,
);
});
siblingARef.current.scrollIntoView = jest.fn();
siblingBRef.current.scrollIntoView = jest.fn();
// Default call
fragmentRef.current.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 && enableFragmentRefsScrollIntoView
it('finds host siblings when the empty fragment is nested in a non-host wrapper', async () => {
const fragmentRef = React.createRef();
const beforeRef = React.createRef();
const afterRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(
,
);
});
beforeRef.current.scrollIntoView = jest.fn();
afterRef.current.scrollIntoView = jest.fn();
// Default / alignToTop=true should use the following host sibling,
// even though the empty fragment's fiber.sibling is null.
fragmentRef.current.scrollIntoView();
expect(beforeRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
expect(afterRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
afterRef.current.scrollIntoView.mockClear();
fragmentRef.current.scrollIntoView(false);
expect(beforeRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
expect(afterRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
});
// @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
it('calls scrollIntoView on the prev sibling if alignToTop is false', async () => {
const fragmentRef = React.createRef();
const siblingARef = React.createRef();
const siblingBRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function C() {
return (
);
}
function Test() {
return (
);
}
await act(() => {
root.render( );
});
siblingARef.current.scrollIntoView = jest.fn();
siblingBRef.current.scrollIntoView = jest.fn();
// alignToTop=false
fragmentRef.current.scrollIntoView(false);
expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(1);
expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
});
// @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
it('calls scrollIntoView on the parent if there are no siblings', async () => {
const fragmentRef = React.createRef();
const parentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(
,
);
});
parentRef.current.scrollIntoView = jest.fn();
fragmentRef.current.scrollIntoView();
expect(parentRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
});
// @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
it('scrolls the host element when the fallback target is a ShadowRoot container', async () => {
const fragmentRef = React.createRef();
const host = document.createElement('div');
container.appendChild(host);
const shadowRoot = host.attachShadow({mode: 'open'});
const root = ReactDOMClient.createRoot(shadowRoot);
await act(() => {
root.render( );
});
// The ShadowRoot's host element marks where the fragment's content
// would appear
host.scrollIntoView = jest.fn();
fragmentRef.current.scrollIntoView();
expect(host.scrollIntoView).toHaveBeenCalledTimes(1);
});
// @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
it('warns without scrolling when the fallback target is a detached DocumentFragment container', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(
document.createDocumentFragment(),
);
await act(() => {
root.render( );
});
expect(() => fragmentRef.current.scrollIntoView()).not.toThrow();
assertConsoleWarnDev(
[
'You are attempting to scroll a FragmentInstance that is only ' +
'mounted inside a detached DocumentFragment. No scroll was ' +
'performed.',
],
{withoutStack: true},
);
});
});
});
describe('with text nodes', () => {
// @gate enableFragmentRefs && enableFragmentRefsTextNodes
it('getClientRects includes text node bounds', async () => {
const restoreRange = mockRangeClientRects([
{x: 0, y: 0, width: 80, height: 16},
]);
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
Hello World
,
),
);
const rects = fragmentRef.current.getClientRects();
expect(rects.length).toBe(1);
expect(rects[0].width).toBe(80);
restoreRange();
});
// @gate enableFragmentRefs && enableFragmentRefsTextNodes
it('getClientRects includes both text and element bounds', async () => {
const restoreRange = mockRangeClientRects([
{x: 0, y: 0, width: 60, height: 16},
]);
const fragmentRef = React.createRef();
const childRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
Text before
Element
Text after
,
),
);
setClientRects(childRef.current, [
{x: 10, y: 10, width: 100, height: 20},
]);
const rects = fragmentRef.current.getClientRects();
// Should have rects from 2 text nodes + 1 element = 3 total
expect(rects.length).toBe(3);
restoreRange();
});
// @gate enableFragmentRefs
it('compareDocumentPosition works with text children', async () => {
const fragmentRef = React.createRef();
const beforeRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
,
),
);
const position = fragmentRef.current.compareDocumentPosition(
beforeRef.current,
);
expect(position & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
});
// @gate enableFragmentRefs
it('focus is a no-op on text-only fragment', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
Text only content
,
),
);
// Should not throw or warn - just a silent no-op
fragmentRef.current.focus();
// Test passes if no error is thrown
});
// @gate enableFragmentRefs
it('focusLast is a no-op on text-only fragment', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
Text only content
,
),
);
// Should not throw or warn - just a silent no-op
fragmentRef.current.focusLast();
});
// @gate enableFragmentRefs && enableFragmentRefsTextNodes
it('warns when observeUsing is called on text-only fragment', async () => {
mockIntersectionObserver();
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
Text only content
,
),
);
const observer = new IntersectionObserver(() => {});
fragmentRef.current.observeUsing(observer);
assertConsoleErrorDev(
[
'observeUsing() was called on a FragmentInstance with only text children. ' +
'Observers do not work on text nodes.',
],
{withoutStack: true},
);
});
// @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
it('scrollIntoView works on text-only fragment using Range API', async () => {
const restoreRange = mockRangeClientRects([
{x: 100, y: 200, width: 80, height: 16},
]);
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
Text content
,
),
);
// Mock window.scrollTo to verify it was called
const originalScrollTo = window.scrollTo;
const scrollToMock = jest.fn();
window.scrollTo = scrollToMock;
fragmentRef.current.scrollIntoView();
// Should have called window.scrollTo for the text node
expect(scrollToMock).toHaveBeenCalled();
window.scrollTo = originalScrollTo;
restoreRange();
});
// @gate enableFragmentRefs && enableFragmentRefsTextNodes && enableFragmentRefsScrollIntoView
it('scrollIntoView scrolls to text siblings of an empty fragment using the Range API', async () => {
const restoreRange = mockRangeClientRects([
{x: 100, y: 200, width: 80, height: 16},
]);
const fragmentRef = React.createRef();
const parentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
Text before
Text after
,
),
);
const parentScrollMock = jest.fn();
parentRef.current.scrollIntoView = parentScrollMock;
// Mock window.scrollTo to verify Range-based text scrolling
const originalScrollTo = window.scrollTo;
const scrollToMock = jest.fn();
window.scrollTo = scrollToMock;
// Default call scrolls to the following text sibling
fragmentRef.current.scrollIntoView();
expect(scrollToMock).toHaveBeenCalledTimes(1);
expect(parentScrollMock).toHaveBeenCalledTimes(0);
scrollToMock.mockClear();
// alignToTop=false scrolls to the preceding text sibling
fragmentRef.current.scrollIntoView(false);
expect(scrollToMock).toHaveBeenCalledTimes(1);
expect(parentScrollMock).toHaveBeenCalledTimes(0);
window.scrollTo = originalScrollTo;
restoreRange();
});
// @gate enableFragmentRefs
it('treats passive:true and passive:false as same listener per DOM spec', async () => {
const fragmentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(
,
);
});
const logs = [];
const handler = () => logs.push('fired');
const child = document.querySelector('#child');
const spy = jest.spyOn(child, 'addEventListener');
// Per DOM spec, listener identity is (type, callback, capture).
// passive is NOT part of the key, so these are the SAME listener.
fragmentRef.current.addEventListener('click', handler, {passive: false});
// Second add is a no-op: same (type, callback, capture) identity.
fragmentRef.current.addEventListener('click', handler, {passive: true});
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('click', handler, {passive: false});
document.querySelector('#child').click();
// First handler fires once (second add was a no-op).
expect(logs).toEqual(['fired']);
// removeEventListener also ignores passive when matching
fragmentRef.current.removeEventListener('click', handler, {
passive: true,
});
logs.length = 0;
document.querySelector('#child').click();
expect(logs).toEqual([]);
});
// @gate enableFragmentRefs
it('removes a listener registered with passive:false when removed with passive:true', async () => {
const fragmentRef = React.createRef(null);
function Test() {
return (
<>
>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(
,
);
});
const logs = [];
function handler() {
logs.push('fired');
}
// Register with passive: false
fragmentRef.current.addEventListener('click', handler, {
passive: false,
});
document.querySelector('#child-x').click();
expect(logs).toEqual(['fired']);
logs.length = 0;
// Remove with passive: true - per DOM spec, passive is NOT part of identity
// so this MUST remove the listener regardless of passive mismatch.
fragmentRef.current.removeEventListener('click', handler, {
passive: true,
});
document.querySelector('#child-x').click();
// Listener removed - no more invocations
expect(logs).toEqual([]);
});
});
});