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}, + ); + }); }); });