@samitouri / QOS-React-2 / commits / db4ee659e9

[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 {