[DOM] Find host siblings for nested empty Fragments (#37162)
Fixes a bug where `compareDocumentPosition` fiber traversal would stop searching after an empty Fragment fiber. Also affects `scrollIntoView`
Jack Pope committed
Aug 11, 2026 at 18:13 UTC
db4ee659e9671848c283eb5fbe08daa1e81caebc
3 files changed
+96
-15
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+81
-2
@@ -1924,6 +1924,51 @@ describe('FragmentRefs', () => {
1924
);
1925
});
1926
1927
+ // @gate enableFragmentRefs
1928
+ it('handles empty fragments nested inside non-host wrappers', async () => {
1929
+ const fragmentRef = React.createRef();
1930
+ const beforeRef = React.createRef();
1931
+ const afterRef = React.createRef();
1932
+ const root = ReactDOMClient.createRoot(container);
1933
+
1934
+ function Test() {
1935
+ return (
1936
+ <div>
1937
+ <div id="before" ref={beforeRef} />
1938
+ <Wrapper>
1939
+ <React.Fragment ref={fragmentRef} />
1940
+ </Wrapper>
1941
+ <div id="after" ref={afterRef} />
1942
+ </div>
1943
+ );
1944
+ }
1945
+
1946
+ await act(() => root.render(<Test />));
1947
+
1948
+ expectPosition(
1949
+ fragmentRef.current.compareDocumentPosition(beforeRef.current),
1950
+ {
1951
+ preceding: true,
1952
+ following: false,
1953
+ contains: false,
1954
+ containedBy: false,
1955
+ disconnected: false,
1956
+ implementationSpecific: true,
1957
+ },
1958
+ );
1959
+ expectPosition(
1960
+ fragmentRef.current.compareDocumentPosition(afterRef.current),
1961
+ {
1962
+ preceding: false,
1963
+ following: true,
1964
+ contains: false,
1965
+ containedBy: false,
1966
+ disconnected: false,
1967
+ implementationSpecific: true,
1968
+ },
1969
+ );
1970
+ });
1971
+
1972
// @gate enableFragmentRefs
1973
it('handles nested children', async () => {
1974
const fragmentRef = React.createRef();
@@ -2368,8 +2413,8 @@ describe('FragmentRefs', () => {
2413
expectPosition(
2414
fragmentRef.current.compareDocumentPosition(childBRef.current),
2415
{
2371
- preceding: true,
2372
- following: false,
2416
+ preceding: false,
2417
+ following: true,
2418
contains: false,
2419
containedBy: false,
2420
disconnected: false,
@@ -2640,6 +2685,40 @@ describe('FragmentRefs', () => {
2685
expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
2686
});
2687
2688
+ // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
2689
+ it('finds host siblings when the empty fragment is nested in a non-host wrapper', async () => {
2690
+ const fragmentRef = React.createRef();
2691
+ const beforeRef = React.createRef();
2692
+ const afterRef = React.createRef();
2693
+ const root = ReactDOMClient.createRoot(container);
2694
+ await act(() => {
2695
+ root.render(
2696
+ <div>
2697
+ <div ref={beforeRef} id="before" />
2698
+ <Wrapper>
2699
+ <Fragment ref={fragmentRef} />
2700
+ </Wrapper>
2701
+ <div ref={afterRef} id="after" />
2702
+ </div>,
2703
+ );
2704
+ });
2705
+
2706
+ beforeRef.current.scrollIntoView = jest.fn();
2707
+ afterRef.current.scrollIntoView = jest.fn();
2708
+
2709
+ // Default / alignToTop=true should use the following host sibling,
2710
+ // even though the empty fragment's fiber.sibling is null.
2711
+ fragmentRef.current.scrollIntoView();
2712
+ expect(beforeRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
2713
+ expect(afterRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
2714
+
2715
+ afterRef.current.scrollIntoView.mockClear();
2716
+
2717
+ fragmentRef.current.scrollIntoView(false);
2718
+ expect(beforeRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
2719
+ expect(afterRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
2720
+ });
2721
+
2722
// @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
2723
it('calls scrollIntoView on the prev sibling if alignToTop is false', async () => {
2724
const fragmentRef = React.createRef();
packages/react-reconciler/src/ReactFiberTreeReflection.js
+10
-9
@@ -472,34 +472,35 @@ export function getFragmentInstanceOrTextInstanceSiblings(
472
result,
473
fiber,
474
parentHostFiber.child,
475
+ {foundSelf: false},
476
);
477
return result;
478
}
479
480
/**
481
* Only collects HostText with enableFragmentRefsTextNodes enabled. Otherwise, only collects HostComponent.
482
+ * Returns true once the following host sibling has been found.
483
*/
484
function findFragmentInstanceOrTextInstanceSiblings(
485
result: [Fiber | null, Fiber | null],
486
self: Fiber,
487
child: null | Fiber,
486
- foundSelf: boolean = false,
488
+ state: {foundSelf: boolean},
489
): boolean {
490
while (child !== null) {
491
if (child === self) {
490
- foundSelf = true;
491
- if (child.sibling) {
492
- child = child.sibling;
493
- } else {
494
- return true;
495
- }
492
+ // Shared across recursive calls so ancestors can keep scanning for
493
+ // following host siblings after a nested empty fragment.
494
+ state.foundSelf = true;
495
+ child = child.sibling;
496
+ continue;
497
}
498
if (
499
child.tag === HostComponent ||
500
child.tag === HostSingleton ||
501
(enableFragmentRefsTextNodes && child.tag === HostText)
502
) {
502
- if (foundSelf) {
503
+ if (state.foundSelf) {
504
result[1] = child;
505
return true;
506
} else {
@@ -516,7 +517,7 @@ function findFragmentInstanceOrTextInstanceSiblings(
517
result,
518
self,
519
child.child,
519
- foundSelf,
520
+ state,
521
)
522
) {
523
return true;
packages/shared/ReactDOMFragmentRefShared.js
+5
-4
@@ -11,7 +11,7 @@
11
12
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
13
14
-import {getNextSiblingInstanceOrTextInstanceFiber} from 'react-reconciler/src/ReactFiberTreeReflection';
14
+import {getFragmentInstanceOrTextInstanceSiblings} from 'react-reconciler/src/ReactFiberTreeReflection';
15
16
export function compareDocumentPositionForEmptyFragment<TPublicInstance>(
17
fragmentFiber: Fiber,
@@ -31,9 +31,10 @@ export function compareDocumentPositionForEmptyFragment<TPublicInstance>(
31
} else {
32
if (parentResult & Node.DOCUMENT_POSITION_CONTAINED_BY) {
33
// otherNode is one of the fragment's siblings. Use the next
34
- // sibling to determine if its preceding or following.
35
- const nextSiblingFiber =
36
- getNextSiblingInstanceOrTextInstanceFiber(fragmentFiber);
34
+ // host sibling (via the parent tree, not fiber.sibling) to
35
+ // determine if its preceding or following.
36
+ const [, nextSiblingFiber] =
37
+ getFragmentInstanceOrTextInstanceSiblings(fragmentFiber);
38
if (nextSiblingFiber === null) {
39
result = Node.DOCUMENT_POSITION_PRECEDING;
40
} else {