From fa5212c188a1598cbc3a29b9abd753c83373a565 Mon Sep 17 00:00:00 2001
From: "Sebastian \"Sebbie\" Silbermann"
Date: Mon, 20 Jul 2026 22:40:57 +0200
Subject: [PATCH] [DOM] Handle scrolling of empty Fragments below containers
(#37061)
e.g. `render({children})` 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 `` in `children`
because we skip `HostSingleton` at the moment.
---------
Co-authored-by: Claude Fable 5
---
.../src/client/ReactFiberConfigDOM.js | 34 ++++++++++++---
.../__tests__/ReactDOMFragmentRefs-test.js | 41 +++++++++++++++++++
2 files changed, 69 insertions(+), 6 deletions(-)
diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
index 90922adc3b6..2ab865cea37 100644
--- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
@@ -3614,12 +3614,34 @@ if (enableFragmentRefsScrollIntoView) {
const target = getInstanceFromHostFiber(
targetFiber,
);
- // TODO: If the parent host fiber is a HostRoot, the target is a
- // Container which can be a Document or DocumentFragment. Those have no
- // scrollIntoView method, so this crashes at runtime.
- // $FlowFixMe[prop-missing]
- target.scrollIntoView(alignToTop);
- return;
+ // If the parent host fiber is a HostRoot, the target is a Container
+ // which is not necessarily an Element with a scrollIntoView method.
+ if (target.nodeType === DOCUMENT_NODE) {
+ // A Document is always in view.
+ } else if (target.nodeType === DOCUMENT_FRAGMENT_NODE) {
+ const fragment = target as any as DocumentFragment;
+ // ShadowRoot always has a host: https://dom.spec.whatwg.org/#ref-for-concept-documentfragment-host%E2%91%A5
+ // A generic DocumentFragment doesn't implement this property but conceptually
+ // host is a nullable Element: https://dom.spec.whatwg.org/#concept-documentfragment-host
+ const host =
+ 'host' in fragment ? (fragment as any as ShadowRoot).host : null;
+ if (host !== null) {
+ // The ShadowRoot's host element marks the position where the
+ // fragment's content would appear.
+ host.scrollIntoView(alignToTop);
+ } else if (__DEV__) {
+ console.warn(
+ 'You are attempting to scroll a FragmentInstance that is only ' +
+ 'mounted inside a detached DocumentFragment. No scroll was ' +
+ 'performed.',
+ );
+ }
+ return;
+ } else {
+ // Narrowed down to Element by nodeType check above, but Flow doesn't know that.
+ const element = target as any as Element;
+ element.scrollIntoView(alignToTop);
+ }
}
let i = resolvedAlignToTop ? children.length - 1 : 0;
diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
index fbc3bdf60c2..628ebc85bf7 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
@@ -22,6 +22,7 @@ let simulateIntersection;
let setClientRects;
let mockRangeClientRects;
let assertConsoleErrorDev;
+let assertConsoleWarnDev;
function Wrapper({children}) {
return children;
@@ -44,6 +45,7 @@ describe('FragmentRefs', () => {
mockRangeClientRects = IntersectionMocks.mockRangeClientRects;
assertConsoleErrorDev =
require('internal-test-utils').assertConsoleErrorDev;
+ assertConsoleWarnDev = require('internal-test-utils').assertConsoleWarnDev;
container = document.createElement('div');
document.body.innerHTML = '';
@@ -2588,6 +2590,45 @@ describe('FragmentRefs', () => {
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},
+ );
+ });
});
});