@samitouri / QOS-React / commits / fa5212c188

[DOM] Handle scrolling of empty Fragments below containers (#37061)

e.g. `render(<Fragment ref={...}>{children}</Fragment>)` where `children` contain no Host Components. For `Document` we just noop because some part of the document is always in view. For `ShadowRoot` we use its `host`. Any other `DocumentFragment` without a `host` issues a warning because there's nothing to scroll. This matches the existing warning when you call `scrollIntoView(false)` on an empty Fragment without siblings. Notably this also applies to `<html><body /></html>` in `children` because we skip `HostSingleton` at the moment. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Sebastian "Sebbie" Silbermann committed Jul 20, 2026 at 22:40 UTC fa5212c188a1598cbc3a29b9abd753c83373a565
2 files changed +69 -6
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+28 -6
@@ -3614,12 +3614,34 @@ if (enableFragmentRefsScrollIntoView) {
3614 const target = getInstanceFromHostFiber<Instance | Container>(
3615 targetFiber,
3616 );
3617 - // TODO: If the parent host fiber is a HostRoot, the target is a
3618 - // Container which can be a Document or DocumentFragment. Those have no
3619 - // scrollIntoView method, so this crashes at runtime.
3620 - // $FlowFixMe[prop-missing]
3621 - target.scrollIntoView(alignToTop);
3622 - return;
3617 + // If the parent host fiber is a HostRoot, the target is a Container
3618 + // which is not necessarily an Element with a scrollIntoView method.
3619 + if (target.nodeType === DOCUMENT_NODE) {
3620 + // A Document is always in view.
3621 + } else if (target.nodeType === DOCUMENT_FRAGMENT_NODE) {
3622 + const fragment = target as any as DocumentFragment;
3623 + // ShadowRoot always has a host: https://dom.spec.whatwg.org/#ref-for-concept-documentfragment-host%E2%91%A5
3624 + // A generic DocumentFragment doesn't implement this property but conceptually
3625 + // host is a nullable Element: https://dom.spec.whatwg.org/#concept-documentfragment-host
3626 + const host =
3627 + 'host' in fragment ? (fragment as any as ShadowRoot).host : null;
3628 + if (host !== null) {
3629 + // The ShadowRoot's host element marks the position where the
3630 + // fragment's content would appear.
3631 + host.scrollIntoView(alignToTop);
3632 + } else if (__DEV__) {
3633 + console.warn(
3634 + 'You are attempting to scroll a FragmentInstance that is only ' +
3635 + 'mounted inside a detached DocumentFragment. No scroll was ' +
3636 + 'performed.',
3637 + );
3638 + }
3639 + return;
3640 + } else {
3641 + // Narrowed down to Element by nodeType check above, but Flow doesn't know that.
3642 + const element = target as any as Element;
3643 + element.scrollIntoView(alignToTop);
3644 + }
3645 }
3646
3647 let i = resolvedAlignToTop ? children.length - 1 : 0;
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+41
@@ -22,6 +22,7 @@ let simulateIntersection;
22 let setClientRects;
23 let mockRangeClientRects;
24 let assertConsoleErrorDev;
25 +let assertConsoleWarnDev;
26
27 function Wrapper({children}) {
28 return children;
@@ -44,6 +45,7 @@ describe('FragmentRefs', () => {
45 mockRangeClientRects = IntersectionMocks.mockRangeClientRects;
46 assertConsoleErrorDev =
47 require('internal-test-utils').assertConsoleErrorDev;
48 + assertConsoleWarnDev = require('internal-test-utils').assertConsoleWarnDev;
49
50 container = document.createElement('div');
51 document.body.innerHTML = '';
@@ -2588,6 +2590,45 @@ describe('FragmentRefs', () => {
2590 fragmentRef.current.scrollIntoView();
2591 expect(parentRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
2592 });
2593 +
2594 + // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
2595 + it('scrolls the host element when the fallback target is a ShadowRoot container', async () => {
2596 + const fragmentRef = React.createRef();
2597 + const host = document.createElement('div');
2598 + container.appendChild(host);
2599 + const shadowRoot = host.attachShadow({mode: 'open'});
2600 + const root = ReactDOMClient.createRoot(shadowRoot);
2601 + await act(() => {
2602 + root.render(<Fragment ref={fragmentRef} />);
2603 + });
2604 +
2605 + // The ShadowRoot's host element marks where the fragment's content
2606 + // would appear
2607 + host.scrollIntoView = jest.fn();
2608 + fragmentRef.current.scrollIntoView();
2609 + expect(host.scrollIntoView).toHaveBeenCalledTimes(1);
2610 + });
2611 +
2612 + // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
2613 + it('warns without scrolling when the fallback target is a detached DocumentFragment container', async () => {
2614 + const fragmentRef = React.createRef();
2615 + const root = ReactDOMClient.createRoot(
2616 + document.createDocumentFragment(),
2617 + );
2618 + await act(() => {
2619 + root.render(<Fragment ref={fragmentRef} />);
2620 + });
2621 +
2622 + expect(() => fragmentRef.current.scrollIntoView()).not.toThrow();
2623 + assertConsoleWarnDev(
2624 + [
2625 + 'You are attempting to scroll a FragmentInstance that is only ' +
2626 + 'mounted inside a detached DocumentFragment. No scroll was ' +
2627 + 'performed.',
2628 + ],
2629 + {withoutStack: true},
2630 + );
2631 + });
2632 });
2633 });
2634