Repository: facebook/react
Pull request: Add compareDocumentPosition to fragment instances

This adds `compareDocumentPosition(otherNode)` to fragment instances.

The semantics implemented are meant to match typical element positioning, with some fragment specifics. See the unit tests for all expectations.

- An element preceding a fragment is `Node.DOCUMENT_POSITION_PRECEDING`
- An element after a fragment is `Node.DOCUMENT_POSITION_FOLLOWING`
- An element containing the fragment is `Node.DOCUMENT_POSITION_PRECEDING` and `Node.DOCUMENT_POSITION_CONTAINING`
- An element within the fragment is `Node.DOCUMENT_POSITION_CONTAINED_BY`
- An element compared against an empty fragment attempt to find a position based on the React tree and also include `Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC`

Since we assume a fragment instances target children are DOM siblings and we want to compare the full fragment as a pseudo container, we can compare against the first target child outside of handling the special cases (empty fragments and contained elements).


Changed files:
filename: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
patch: @@ -37,6 +37,11 @@ import {runWithFiberInDEV} from 'react-reconciler/src/ReactCurrentFiber';
 import hasOwnProperty from 'shared/hasOwnProperty';
 import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
 import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
+import {
+  isFiberContainedBy,
+  isFiberFollowing,
+  isFiberPreceding,
+} from 'react-reconciler/src/ReactFiberTreeReflection';
 
 export {
   setCurrentUpdatePriority,
@@ -60,7 +65,9 @@ import {
 } from './ReactDOMComponentTree';
 import {
   traverseFragmentInstance,
-  getFragmentParentHostInstance,
+  getFragmentParentHostFiber,
+  getNextSiblingHostFiber,
+  getInstanceFromHostFiber,
 } from 'react-reconciler/src/ReactFiberTreeReflection';
 
 export {detachDeletedInstance};
@@ -2599,6 +2606,7 @@ export type FragmentInstanceType = {
   getRootNode(getRootNodeOptions?: {
     composed: boolean,
   }): Document | ShadowRoot | FragmentInstanceType,
+  compareDocumentPosition(otherNode: Instance): number,
 };
 
 function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
@@ -2636,12 +2644,13 @@ FragmentInstance.prototype.addEventListener = function (
   this._eventListeners = listeners;
 };
 function addEventListenerToChild(
-  child: Instance,
+  child: Fiber,
   type: string,
   listener: EventListener,
   optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
 ): boolean {
-  child.addEventListener(type, listener, optionsOrUseCapture);
+  const instance = getInstanceFromHostFiber<Instance>(child);
+  instance.addEventListener(type, listener, optionsOrUseCapture);
   return false;
 }
 // $FlowFixMe[prop-missing]
@@ -2675,12 +2684,13 @@ FragmentInstance.prototype.removeEventListener = function (
   }
 };
 function removeEventListenerFromChild(
-  child: Instance,
+  child: Fiber,
   type: string,
   listener: EventListener,
   optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
 ): boolean {
-  child.removeEventListener(type, listener, optionsOrUseCapture);
+  const instance = getInstanceFromHostFiber<Instance>(child);
+  instance.removeEventListener(type, listener, optionsOrUseCapture);
   return false;
 }
 // $FlowFixMe[prop-missing]
@@ -2690,28 +2700,32 @@ FragmentInstance.prototype.focus = function (
 ): void {
   traverseFragmentInstance(
     this._fragmentFiber,
-    setFocusIfFocusable,
+    setFocusOnFiberIfFocusable,
     focusOptions,
   );
 };
+function setFocusOnFiberIfFocusable(
+  fiber: Fiber,
+  focusOptions?: FocusOptions,
+): boolean {
+  const instance = getInstanceFromHostFiber<Instance>(fiber);
+  return setFocusIfFocusable(instance, focusOptions);
+}
 // $FlowFixMe[prop-missing]
 FragmentInstance.prototype.focusLast = function (
   this: FragmentInstanceType,
   focusOptions?: FocusOptions,
 ): void {
-  const children: Array<Instance> = [];
+  const children: Array<Fiber> = [];
   traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
   for (let i = children.length - 1; i >= 0; i--) {
     const child = children[i];
-    if (setFocusIfFocusable(child, focusOptions)) {
+    if (setFocusOnFiberIfFocusable(child, focusOptions)) {
       break;
     }
   }
 };
-function collectChildren(
-  child: Instance,
-  collection: Array<Instance>,
-): boolean {
+function collectChildren(child: Fiber, collection: Array<Fiber>): boolean {
   collection.push(child);
   return false;
 }
@@ -2724,12 +2738,13 @@ FragmentInstance.prototype.blur = function (this: FragmentInstanceType): void {
     blurActiveElementWithinFragment,
   );
 };
-function blurActiveElementWithinFragment(child: Instance): boolean {
+function blurActiveElementWithinFragment(child: Fiber): boolean {
   // TODO: We can get the activeElement from the parent outside of the loop when we have a reference.
-  const ownerDocument = child.ownerDocument;
-  if (child === ownerDocument.activeElement) {
+  const instance = getInstanceFromHostFiber<Instance>(child);
+  const ownerDocument = instance.ownerDocument;
+  if (instance === ownerDocument.activeElement) {
     // $FlowFixMe[prop-missing]
-    child.blur();
+    instance.blur();
     return true;
   }
   return false;
@@ -2746,10 +2761,11 @@ FragmentInstance.prototype.observeUsing = function (
   traverseFragmentInstance(this._fragmentFiber, observeChild, observer);
 };
 function observeChild(
-  child: Instance,
+  child: Fiber,
   observer: IntersectionObserver | ResizeObserver,
 ) {
-  observer.observe(child);
+  const instance = getInstanceFromHostFiber<Instance>(child);
+  observer.observe(instance);
   return false;
 }
 // $FlowFixMe[prop-missing]
@@ -2770,10 +2786,11 @@ FragmentInstance.prototype.unobserveUsing = function (
   }
 };
 function unobserveChild(
-  child: Instance,
+  child: Fiber,
   observer: IntersectionObserver | ResizeObserver,
 ) {
-  observer.unobserve(child);
+  const instance = getInstanceFromHostFiber<Instance>(child);
+  observer.unobserve(instance);
   return false;
 }
 // $FlowFixMe[prop-missing]
@@ -2784,25 +2801,155 @@ FragmentInstance.prototype.getClientRects = function (
   traverseFragmentInstance(this._fragmentFiber, collectClientRects, rects);
   return rects;
 };
-function collectClientRects(child: Instance, rects: Array<DOMRect>): boolean {
+function collectClientRects(child: Fiber, rects: Array<DOMRect>): boolean {
+  const instance = getInstanceFromHostFiber<Instance>(child);
   // $FlowFixMe[method-unbinding]
-  rects.push.apply(rects, child.getClientRects());
+  rects.push.apply(rects, instance.getClientRects());
   return false;
 }
 // $FlowFixMe[prop-missing]
 FragmentInstance.prototype.getRootNode = function (
   this: FragmentInstanceType,
   getRootNodeOptions?: {composed: boolean},
 ): Document | ShadowRoot | FragmentInstanceType {
-  const parentHostInstance = getFragmentParentHostInstance(this._fragmentFiber);
-  if (parentHostInstance === null) {
+  const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
+  if (parentHostFiber === null) {
     return this;
   }
+  const parentHostInstance =
+    getInstanceFromHostFiber<Instance>(parentHostFiber);
   const rootNode =
     // $FlowFixMe[incompatible-cast] Flow expects Node
     (parentHostInstance.getRootNode(getRootNodeOptions): Document | ShadowRoot);
   return rootNode;
 };
+// $FlowFixMe[prop-missing]
+FragmentInstance.prototype.compareDocumentPosition = function (
+  this: FragmentInstanceType,
+  otherNode: Instance,
+): number {
+  const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
+  if (parentHostFiber === null) {
+    return Node.DOCUMENT_POSITION_DISCONNECTED;
+  }
+  const children: Array<Fiber> = [];
+  traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
+
+  let result = Node.DOCUMENT_POSITION_DISCONNECTED;
+  if (children.length === 0) {
+    // If the fragment has no children, we can use the parent and
+    // siblings to determine a position.
+    const parentHostInstance =
+      getInstanceFromHostFiber<Instance>(parentHostFiber);
+    const parentResult = parentHostInstance.compareDocumentPosition(otherNode);
+    result = parentResult;
+    if (parentHostInstance === otherNode) {
+      result = Node.DOCUMENT_POSITION_CONTAINS;
+    } else {
+      if (parentResult & Node.DOCUMENT_POSITION_CONTAINED_BY) {
+        // otherNode is one of the fragment's siblings. Use the next
+        // sibling to determine if its preceding or following.
+        const nextSiblingFiber = getNextSiblingHostFiber(this._fragmentFiber);
+        if (nextSiblingFiber === null) {
+          result = Node.DOCUMENT_POSITION_PRECEDING;
+        } else {
+          const nextSiblingInstance =
+            getInstanceFromHostFiber<Instance>(nextSiblingFiber);
+          const nextSiblingResult =
+            nextSiblingInstance.compareDocumentPosition(otherNode);
+          if (
+            nextSiblingResult === 0 ||
+            nextSiblingResult & Node.DOCUMENT_POSITION_FOLLOWING
+          ) {
+            result = Node.DOCUMENT_POSITION_FOLLOWING;
+          } else {
+            result = Node.DOCUMENT_POSITION_PRECEDING;
+          }
+        }
+      }
+    }
+
+    result |= Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC;
+    return result;
+  }
+
+  const firstElement = getInstanceFromHostFiber<Instance>(children[0]);
+  const lastElement = getInstanceFromHostFiber<Instance>(
+    children[children.length - 1],
+  );
+  const firstResult = firstElement.compareDocumentPosition(otherNode);
+  const lastResult = lastElement.compareDocumentPosition(otherNode);
+  if (
+    (firstResult & Node.DOCUMENT_POSITION_FOLLOWING &&
+      lastResult & Node.DOCUMENT_POSITION_PRECEDING) ||
+    otherNode === firstElement ||
+    otherNode === lastElement
+  ) {
+    result = Node.DOCUMENT_POSITION_CONTAINED_BY;
+  } else {
+    result = firstResult;
+  }
+
+  if (
+    result & Node.DOCUMENT_POSITION_DISCONNECTED ||
+    result & Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC
+  ) {
+    return result;
+  }
+
+  // Now that we have the result from the DOM API, we double check it matches
+  // the state of the React tree. If it doesn't, we have a case of portaled or
+  // otherwise injected elements and we return DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC.
+  const documentPositionMatchesFiberPosition =
+    validateDocumentPositionWithFiberTree(
+      result,
+      this._fragmentFiber,
+      children[0],
+      children[children.length - 1],
+      otherNode,
+    );
+  if (documentPositionMatchesFiberPosition) {
+    return result;
+  }
+  return Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC;
+};
+
+function validateDocumentPositionWithFiberTree(
+  documentPosition: number,
+  fragmentFiber: Fiber,
+  precedingBoundaryFiber: Fiber,
+  followingBoundaryFiber: Fiber,
+  otherNode: Instance,
+): boolean {
+  const otherFiber = getClosestInstanceFromNode(otherNode);
+  if (documentPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) {
+    return !!otherFiber && isFiberContainedBy(fragmentFiber, otherFiber);
+  }
+  if (documentPosition & Node.DOCUMENT_POSITION_CONTAINS) {
+    if (otherFiber === null) {
+      // otherFiber could be null if its the document or body element
+      const ownerDocument = otherNode.ownerDocument;
+      return otherNode === ownerDocument || otherNode === ownerDocument.body;
+    }
+    return isFiberContainedBy(otherFiber, fragmentFiber);
+  }
+  if (documentPosition & Node.DOCUMENT_POSITION_PRECEDING) {
+    return (
+      !!otherFiber &&
+      (otherFiber === precedingBoundaryFiber ||
+        isFiberPreceding(precedingBoundaryFiber, otherFiber))
+    );
+  }
+  if (documentPosition & Node.DOCUMENT_POSITION_FOLLOWING) {
+    return (
+      !!otherFiber &&
+      (otherFiber === followingBoundaryFiber ||
+        isFiberFollowing(followingBoundaryFiber, otherFiber))
+    );
+  }
+
+  return false;
+}
 
 function normalizeListenerOptions(
   opts: ?EventListenerOptionsOrUseCapture,

filename: packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
patch: @@ -36,6 +36,7 @@ import {
   HostText,
   ScopeComponent,
 } from 'react-reconciler/src/ReactWorkTags';
+import {getLowestCommonAncestor} from 'react-reconciler/src/ReactFiberTreeReflection';
 
 import getEventTarget from './getEventTarget';
 import {
@@ -891,46 +892,6 @@ function getParent(inst: Fiber | null): Fiber | null {
   return null;
 }
 
-/**
- * Return the lowest common ancestor of A and B, or null if they are in
- * different trees.
- */
-function getLowestCommonAncestor(instA: Fiber, instB: Fiber): Fiber | null {
-  let nodeA: null | Fiber = instA;
-  let nodeB: null | Fiber = instB;
-  let depthA = 0;
-  for (let tempA: null | Fiber = nodeA; tempA; tempA = getParent(tempA)) {
-    depthA++;
-  }
-  let depthB = 0;
-  for (let tempB: null | Fiber = nodeB; tempB; tempB = getParent(tempB)) {
-    depthB++;
-  }
-
-  // If A is deeper, crawl up.
-  while (depthA - depthB > 0) {
-    nodeA = getParent(nodeA);
-    depthA--;
-  }
-
-  // If B is deeper, crawl up.
-  while (depthB - depthA > 0) {
-    nodeB = getParent(nodeB);
-    depthB--;
-  }
-
-  // Walk in lockstep until we find a match.
-  let depth = depthA;
-  while (depth--) {
-    if (nodeA === nodeB || (nodeB !== null && nodeA === nodeB.alternate)) {
-      return nodeA;
-    }
-    nodeA = getParent(nodeA);
-    nodeB = getParent(nodeB);
-  }
-  return null;
-}
-
 function accumulateEnterLeaveListenersForEvent(
   dispatchQueue: DispatchQueue,
   event: KnownReactSyntheticEvent,
@@ -992,7 +953,8 @@ export function accumulateEnterLeaveTwoPhaseListeners(
   from: Fiber | null,
   to: Fiber | null,
 ): void {
-  const common = from && to ? getLowestCommonAncestor(from, to) : null;
+  const common =
+    from && to ? getLowestCommonAncestor(from, to, getParent) : null;
 
   if (from !== null) {
     accumulateEnterLeaveListenersForEvent(

filename: packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
patch: @@ -11,6 +11,8 @@
 
 let React;
 let ReactDOMClient;
+let ReactDOM;
+let createPortal;
 let act;
 let container;
 let Fragment;
@@ -31,6 +33,8 @@ describe('FragmentRefs', () => {
     Fragment = React.Fragment;
     Activity = React.unstable_Activity;
     ReactDOMClient = require('react-dom/client');
+    ReactDOM = require('react-dom');
+    createPortal = ReactDOM.createPortal;
     act = require('internal-test-utils').act;
     const IntersectionMocks = require('./utils/IntersectionMocks');
     mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver;
@@ -40,6 +44,7 @@ describe('FragmentRefs', () => {
       require('internal-test-utils').assertConsoleErrorDev;
 
     container = document.createElement('div');
+    document.body.innerHTML = '';
     document.body.appendChild(container);
   });
 
@@ -611,6 +616,39 @@ describe('FragmentRefs', () => {
       expect(logs).toEqual([]);
     });
 
+    // @gate enableFragmentRefs
+    it('applies event listeners to portaled children', async () => {
+      const fragmentRef = React.createRef();
+      const childARef = React.createRef();
+      const childBRef = React.createRef();
+      const root = ReactDOMClient.createRoot(container);
+
+      function Test() {
+        return (
+          <Fragment ref={fragmentRef}>
+            <div id="child-a" ref={childARef} />
+            {createPortal(<div id="child-b" ref={childBRef} />, document.body)}
+          </Fragment>
+        );
+      }
+
+      await act(() => {
+        root.render(<Test />);
+      });
+
+      const logs = [];
+      fragmentRef.current.addEventListener('click', e => {
+        logs.push(e.target.id);
+      });
+
+      childARef.current.click();
+      expect(logs).toEqual(['child-a']);
+
+      logs.length = 0;
+      childBRef.current.click();
+      expect(logs).toEqual(['child-b']);
+    });
+
     describe('with activity', () => {
       // @gate enableFragmentRefs && enableActivity
       it('does not apply event listeners to hidden trees', async () => {
@@ -966,4 +1004,568 @@ describe('FragmentRefs', () => {
       expect(fragmentHandle.getRootNode()).toBe(fragmentHandle);
     });
   });
+
+  describe('compareDocumentPosition', () => {
+    function expectPosition(position, spec) {
+      const positionResult = {
+        following: (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
+        preceding: (position & Node.DOCUMENT_POSITION_PRECEDING) !== 0,
+        contains: (position & Node.DOCUMENT_POSITION_CONTAINS) !== 0,
+        containedBy: (position & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0,
+        disconnected: (position & Node.DOCUMENT_POSITION_DISCONNECTED) !== 0,
+        implementationSpecific:
+          (position & Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC) !== 0,
+      };
+      expect(positionResult).toEqual(spec);
+    }
+    // @gate enableFragmentRefs
+    it('returns the relationship between the fragment instance and a given node', async () => {
+      const fragmentRef = React.createRef();
+      const beforeRef = React.createRef();
+      const afterRef = React.createRef();
+      const middleChildRef = React.createRef();
+      const firstChildRef = React.createRef();
+      const lastChildRef = React.createRef();
+      const containerRef = React.createRef();
+      const disconnectedElement = document.createElement('div');
+      const root = ReactDOMClient.createRoot(container);
+
+      function Test() {
+        return (
+          <div ref={containerRef}>
+            <div ref={beforeRef} />
+            <React.Fragment ref={fragmentRef}>
+              <div ref={firstChildRef} />
+              <div ref={middleChildRef} />
+              <div ref={lastChildRef} />
+            </React.Fragment>
+            <div ref={afterRef} />
+          </div>
+        );
+      }
+
+      await act(() => root.render(<Test />));
+
+      // document.body is preceding and contains the fragment
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(document.body),
+        {
+          preceding: true,
+          following: false,
+          contains: true,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+
+      // beforeRef is preceding the fragment
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(beforeRef.current),
+        {
+          preceding: true,
+          following: false,
+          contains: false,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+
+      // afterRef is following the fragment
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(afterRef.current),
+        {
+          preceding: false,
+          following: true,
+          contains: false,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+
+      // firstChildRef is contained by the fragment
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(firstChildRef.current),
+        {
+          preceding: false,
+          following: false,
+          contains: false,
+          containedBy: true,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+
+      // middleChildRef is contained by the fragment
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(middleChildRef.current),
+        {
+          preceding: false,
+          following: false,
+          contains: false,
+          containedBy: true,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+
+      // lastChildRef is contained by the fragment
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(lastChildRef.current),
+        {
+          preceding: false,
+          following: false,
+          contains: false,
+          containedBy: true,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+
+      // containerRef preceds and contains the fragment
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(containerRef.current),
+        {
+          preceding: true,
+          following: false,
+          contains: true,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(disconnectedElement),
+        {
+          preceding: false,
+          following: true,
+          contains: false,
+          containedBy: false,
+          disconnected: true,
+          implementationSpecific: true,
+        },
+      );
+    });
+
+    // @gate enableFragmentRefs
+    it('handles fragment instances with one child', async () => {
+      const fragmentRef = React.createRef();
+      const beforeRef = React.createRef();
+      const afterRef = React.createRef();
+      const containerRef = React.createRef();
+      const onlyChildRef = React.createRef();
+      const disconnectedElement = document.createElement('div');
+      const root = ReactDOMClient.createRoot(container);
+
+      function Test() {
+        return (
+          <div id="container" ref={containerRef}>
+            <div>
+              <div ref={beforeRef} id="before" />
+              <React.Fragment ref={fragmentRef}>
+                <div ref={onlyChildRef} id="within" />
+              </React.Fragment>
+              <div id="after" ref={afterRef} />
+            </div>
+          </div>
+        );
+      }
+
+      await act(() => root.render(<Test />));
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(beforeRef.current),
+        {
+          preceding: true,
+          following: false,
+          contains: false,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(afterRef.current),
+        {
+          preceding: false,
+          following: true,
+          contains: false,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(onlyChildRef.current),
+        {
+          preceding: false,
+          following: false,
+          contains: false,
+          containedBy: true,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(containerRef.current),
+        {
+          preceding: true,
+          following: false,
+          contains: true,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(disconnectedElement),
+        {
+          preceding: false,
+          following: true,
+          contains: false,
+          containedBy: false,
+          disconnected: true,
+          implementationSpecific: true,
+        },
+      );
+    });
+
+    // @gate enableFragmentRefs
+    it('handles empty fragment instances', async () => {
+      const fragmentRef = React.createRef();
+      const beforeParentRef = React.createRef();
+      const beforeRef = React.createRef();
+      const afterRef = React.createRef();
+      const afterParentRef = React.createRef();
+      const containerRef = React.createRef();
+      const root = ReactDOMClient.createRoot(container);
+
+      function Test() {
+        return (
+          <>
+            <div id="before-container" ref={beforeParentRef} />
+            <div id="container" ref={containerRef}>
+              <div id="before" ref={beforeRef} />
+              <React.Fragment ref={fragmentRef} />
+              <div id="after" ref={afterRef} />
+            </div>
+            <div id="after-container" ref={afterParentRef} />
+          </>
+        );
+      }
+
+      await act(() => root.render(<Test />));
+
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(document.body),
+        {
+          preceding: true,
+          following: false,
+          contains: true,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: true,
+        },
+      );
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(beforeRef.current),
+        {
+          preceding: true,
+          following: false,
+          contains: false,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: true,
+        },
+      );
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(beforeParentRef.current),
+        {
+          preceding: true,
+          following: false,
+          contains: false,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: true,
+        },
+      );
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(afterRef.current),
+        {
+          preceding: false,
+          following: true,
+          contains: false,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: true,
+        },
+      );
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(afterParentRef.current),
+        {
+          preceding: false,
+          following: true,
+          contains: false,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: true,
+        },
+      );
+      expectPosition(
+        fragmentRef.current.compareDocumentPosition(containerRef.current),
+        {
+          preceding: false,
+          following: false,
+          contains: true,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: true,
+        },
+      );
+    });
+
+    // @gate enableFragmentRefs
+    it('returns disconnected for comparison with an unmounted fragment instance', async () => {
+      const fragmentRef = React.createRef();
+      const containerRef = React.createRef();
+      const root = ReactDOMClient.createRoot(container);
+
+      function Test({mount}) {
+        return (
+          <div ref={containerRef}>
+            {mount && (
+              <Fragment ref={fragmentRef}>
+                <div />
+              </Fragment>
+            )}
+          </div>
+        );
+      }
+
+      await act(() => root.render(<Test mount={true} />));
+
+      const fragmentHandle = fragmentRef.current;
+
+      expectPosition(
+        fragmentHandle.compareDocumentPosition(containerRef.current),
+        {
+          preceding: true,
+          following: false,
+          contains: true,
+          containedBy: false,
+          disconnected: false,
+          implementationSpecific: false,
+        },
+      );
+
+      await act(() => {
+        root.render(<Test mount={false} />);
+      });
+
+      expectPosition(
+        fragmentHandle.compareDocumentPosition(containerRef.current),
+        {
+          preceding: false,
+          following: false,
+          contains: false,
+          containedBy: false,
+          disconnected: true,
+          implementationSpecific: false,
+        },
+      );
+    });
+
+    describe('with portals', () => {
+      // @gate enableFragmentRefs
+      it('handles portaled elements', async () => {
+        const fragmentRef = React.createRef();
+        const portaledSiblingRef = React.createRef();
+        const portaledChildRef = React.createRef();
+
+        function Test() {
+          return (
+            <div>
+              {createPortal(<div ref={portaledSiblingRef} />, document.body)}
+              <Fragment ref={fragmentRef}>
+                {createPortal(<div ref={portaledChildRef} />, document.body)}
+                <div />
+              </Fragment>
+            </div>
+          );
+        }
+
+        const root = ReactDOMClient.createRoot(container);
+        await act(() => root.render(<Test />));
+
+        // The sibling is preceding in both the DOM and the React tree
+        expectPosition(
+          fragmentRef.current.compareDocumentPosition(
+            portaledSiblingRef.current,
+          ),
+          {
+            preceding: true,
+            following: false,
+            contains: false,
+            containedBy: false,
+            disconnected: false,
+            implementationSpecific: false,
+          },
+        );
+
+        // The child is contained by in the React tree but not in the DOM
+        expectPosition(
+          fragmentRef.current.compareDocumentPosition(portaledChildRef.current),
+          {
+            preceding: false,
+            following: false,
+            contains: false,
+            containedBy: false,
+            disconnected: false,
+            implementationSpecific: true,
+          },
+        );
+      });
+
+      // @gate enableFragmentRefs
+      it('handles multiple portals to the same element', async () => {
+        const root = ReactDOMClient.createRoot(container);
+        const fragmentRef = React.createRef();
+        const childARef = React.createRef();
+        const childBRef = React.createRef();
+        const childCRef = React.createRef();
+
+        function Test() {
+          const [c, setC] = React.useState(false);
+          React.useEffect(() => {
+            setC(true);
+          });
+
+          return (
+            <>
+              {createPortal(
+                <Fragment ref={fragmentRef}>
+                  <div id="A" ref={childARef} />
+                  {c ? <div id="C" ref={childCRef} /> : null}
+                </Fragment>,
+                document.body,
+              )}
+              {createPortal(<p id="B" ref={childBRef} />, document.body)}
+            </>
+          );
+        }
+
+        await act(() => root.render(<Test />));
+
+        // Due to effect, order is A->B->C
+        expect(document.body.innerHTML).toBe(
+          '<div></div>' +
+            '<div id="A"></div>' +
+            '<p id="B"></p>' +
+            '<div id="C"></div>',
+        );
+
+        expectPosition(
+          fragmentRef.current.compareDocumentPosition(document.body),
+          {
+            preceding: true,
+            following: false,
+            contains: true,
+            containedBy: false,
+            disconnected: false,
+            implementationSpecific: false,
+          },
+        );
+
+        expectPosition(
+          fragmentRef.current.compareDocumentPosition(childARef.current),
+          {
+            preceding: false,
+            following: false,
+            contains: false,
+            containedBy: true,
+            disconnected: false,
+            implementationSpecific: false,
+          },
+        );
+        expectPosition(
+          fragmentRef.current.compareDocumentPosition(childBRef.current),
+          {
+            preceding: false,
+            following: false,
+            contains: false,
+            containedBy: false,
+            disconnected: false,
+            implementationSpecific: true,
+          },
+        );
+        expectPosition(
+          fragmentRef.current.compareDocumentPosition(childCRef.current),
+          {
+            preceding: false,
+            following: false,
+            contains: false,
+            containedBy: true,
+            disconnected: false,
+            implementationSpecific: false,
+          },
+        );
+      });
+
+      // @gate enableFragmentRefs
+      it('handles empty fragments', async () => {
+        const fragmentRef = React.createRef();
+        const childARef = React.createRef();
+        const childBRef = React.createRef();
+
+        function Test() {
+          return (
+            <>
+              <div id="A" ref={childARef} />
+              {createPortal(<Fragment ref={fragmentRef} />, document.body)}
+              <div id="B" ref={childBRef} />
+            </>
+          );
+        }
+
+        const root = ReactDOMClient.createRoot(container);
+        await act(() => root.render(<Test />));
+
+        expectPosition(
+          fragmentRef.current.compareDocumentPosition(document.body),
+          {
+            preceding: true,
+            following: false,
+            contains: true,
+            containedBy: false,
+            disconnected: false,
+            implementationSpecific: true,
+          },
+        );
+        expectPosition(
+          fragmentRef.current.compareDocumentPosition(childARef.current),
+          {
+            preceding: true,
+            following: false,
+            contains: false,
+            containedBy: false,
+            disconnected: false,
+            implementationSpecific: true,
+          },
+        );
+        expectPosition(
+          fragmentRef.current.compareDocumentPosition(childBRef.current),
+          {
+            preceding: true,
+            following: false,
+            contains: false,
+            containedBy: false,
+            disconnected: false,
+            implementationSpecific: true,
+          },
+        );
+      });
+    });
+  });
 });

filename: packages/react-native-renderer/src/ReactFiberConfigFabric.js
patch: @@ -24,7 +24,10 @@ import {
 } from 'react-reconciler/src/ReactEventPriorities';
 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
 import {HostText} from 'react-reconciler/src/ReactWorkTags';
-import {traverseFragmentInstance} from 'react-reconciler/src/ReactFiberTreeReflection';
+import {
+  getInstanceFromHostFiber,
+  traverseFragmentInstance,
+} from 'react-reconciler/src/ReactFiberTreeReflection';
 
 // Modules provided by RN:
 import {
@@ -640,7 +643,8 @@ FragmentInstance.prototype.observeUsing = function (
   this._observers.add(observer);
   traverseFragmentInstance(this._fragmentFiber, observeChild, observer);
 };
-function observeChild(instance: Instance, observer: IntersectionObserver) {
+function observeChild(child: Fiber, observer: IntersectionObserver) {
+  const instance = getInstanceFromHostFiber<Instance>(child);
   const publicInstance = getPublicInstance(instance);
   if (publicInstance == null) {
     throw new Error('Expected to find a host node. This is a bug in React.');
@@ -666,7 +670,8 @@ FragmentInstance.prototype.unobserveUsing = function (
     traverseFragmentInstance(this._fragmentFiber, unobserveChild, observer);
   }
 };
-function unobserveChild(instance: Instance, observer: IntersectionObserver) {
+function unobserveChild(child: Fiber, observer: IntersectionObserver) {
+  const instance = getInstanceFromHostFiber<Instance>(child);
   const publicInstance = getPublicInstance(instance);
   if (publicInstance == null) {
     throw new Error('Expected to find a host node. This is a bug in React.');
@@ -690,7 +695,7 @@ export function updateFragmentInstanceFiber(
 }
 
 export function commitNewChildToFragmentInstance(
-  child: Instance,
+  child: Fiber,
   fragmentInstance: FragmentInstanceType,
 ): void {
   if (fragmentInstance._observers !== null) {

filename: packages/react-reconciler/src/ReactFiberTreeReflection.js
patch: @@ -12,7 +12,6 @@ import type {
   Container,
   ActivityInstance,
   SuspenseInstance,
-  Instance,
 } from './ReactFiberConfig';
 import type {ActivityState} from './ReactFiberActivityComponent';
 import type {SuspenseState} from './ReactFiberSuspenseComponent';
@@ -345,51 +344,265 @@ export function doesFiberContain(
   return false;
 }
 
-export function traverseFragmentInstance<I, A, B, C>(
+export function traverseFragmentInstance<A, B, C>(
   fragmentFiber: Fiber,
-  fn: (I, A, B, C) => boolean,
+  fn: (Fiber, A, B, C) => boolean,
   a: A,
   b: B,
   c: C,
 ): void {
-  traverseFragmentInstanceChildren(fragmentFiber.child, fn, a, b, c);
+  traverseVisibleHostChildren(fragmentFiber.child, false, fn, a, b, c);
 }
 
-function traverseFragmentInstanceChildren<I, A, B, C>(
+function traverseVisibleHostChildren<A, B, C>(
   child: Fiber | null,
-  fn: (I, A, B, C) => boolean,
+  searchWithinHosts: boolean,
+  fn: (Fiber, A, B, C) => boolean,
   a: A,
   b: B,
   c: C,
-): void {
+): boolean {
   while (child !== null) {
     if (child.tag === HostComponent) {
-      if (fn(child.stateNode, a, b, c)) {
-        return;
+      if (fn(child, a, b, c)) {
+        return true;
+      }
+      if (searchWithinHosts) {
+        if (
+          traverseVisibleHostChildren(
+            child.child,
+            searchWithinHosts,
+            fn,
+            a,
+            b,
+            c,
+          )
+        ) {
+          return true;
+        }
       }
     } else if (
       child.tag === OffscreenComponent &&
       child.memoizedState !== null
     ) {
       // Skip hidden subtrees
     } else {
-      traverseFragmentInstanceChildren(child.child, fn, a, b, c);
+      if (
+        traverseVisibleHostChildren(child.child, searchWithinHosts, fn, a, b, c)
+      ) {
+        return true;
+      }
     }
     child = child.sibling;
   }
+  return false;
 }
 
-export function getFragmentParentHostInstance(fiber: Fiber): null | Instance {
+export function getFragmentParentHostFiber(fiber: Fiber): null | Fiber {
   let parent = fiber.return;
   while (parent !== null) {
-    if (parent.tag === HostRoot) {
-      return parent.stateNode.containerInfo;
+    if (parent.tag === HostRoot || parent.tag === HostComponent) {
+      return parent;
     }
-    if (parent.tag === HostComponent) {
-      return parent.stateNode;
+    parent = parent.return;
+  }
+
+  return null;
+}
+
+export function getInstanceFromHostFiber<I>(fiber: Fiber): I {
+  switch (fiber.tag) {
+    case HostComponent:
+      return fiber.stateNode;
+    case HostRoot:
+      return fiber.stateNode.containerInfo;
+    default:
+      throw new Error('Expected to find a host node. This is a bug in React.');
+  }
+}
+
+let searchTarget = null;
+let searchBoundary = null;
+function pushSearchTarget(target: null | Fiber): void {
+  searchTarget = target;
+}
+function popSearchTarget(): null | Fiber {
+  return searchTarget;
+}
+function pushSearchBoundary(value: null | Fiber): void {
+  searchBoundary = value;
+}
+function popSearchBoundary(): null | Fiber {
+  return searchBoundary;
+}
+
+export function getNextSiblingHostFiber(fiber: Fiber): null | Fiber {
+  traverseVisibleHostChildren(fiber.sibling, false, findNextSibling);
+  const sibling = popSearchTarget();
+  pushSearchTarget(null);
+  return sibling;
+}
+
+function findNextSibling(child: Fiber): boolean {
+  pushSearchTarget(child);
+  return true;
+}
+
+export function isFiberContainedBy(
+  maybeChild: Fiber,
+  maybeParent: Fiber,
+): boolean {
+  let parent = maybeParent.return;
+  if (parent === maybeChild || parent === maybeChild.alternate) {
+    return true;
+  }
+  while (parent !== null && parent !== maybeChild) {
+    if (
+      (parent.tag === HostComponent || parent.tag === HostRoot) &&
+      (parent.return === maybeChild || parent.return === maybeChild.alternate)
+    ) {
+      return true;
     }
     parent = parent.return;
   }
+  return false;
+}
+
+export function isFiberPreceding(fiber: Fiber, otherFiber: Fiber): boolean {
+  const commonAncestor = getLowestCommonAncestor(
+    fiber,
+    otherFiber,
+    getParentForFragmentAncestors,
+  );
+  if (commonAncestor === null) {
+    return false;
+  }
+  traverseVisibleHostChildren(
+    commonAncestor,
+    true,
+    isFiberPrecedingCheck,
+    otherFiber,
+    fiber,
+  );
+  const target = popSearchTarget();
+  pushSearchTarget(null);
+  return target !== null;
+}
+
+function isFiberPrecedingCheck(
+  child: Fiber,
+  target: Fiber,
+  boundary: Fiber,
+): boolean {
+  if (child === boundary) {
+    return true;
+  }
+  if (child === target) {
+    pushSearchTarget(child);
+    return true;
+  }
+  return false;
+}
+
+export function isFiberFollowing(fiber: Fiber, otherFiber: Fiber): boolean {
+  const commonAncestor = getLowestCommonAncestor(
+    fiber,
+    otherFiber,
+    getParentForFragmentAncestors,
+  );
+  if (commonAncestor === null) {
+    return false;
+  }
+  traverseVisibleHostChildren(
+    commonAncestor,
+    true,
+    isFiberFollowingCheck,
+    otherFiber,
+    fiber,
+  );
+  const target = popSearchTarget();
+  pushSearchTarget(null);
+  pushSearchBoundary(null);
+  return target !== null;
+}
+
+function isFiberFollowingCheck(
+  child: Fiber,
+  target: Fiber,
+  boundary: Fiber,
+): boolean {
+  if (child === boundary) {
+    pushSearchBoundary(child);
+    return false;
+  }
+  if (child === target) {
+    // The target is only following if we already found the boundary.
+    if (popSearchBoundary() !== null) {
+      pushSearchTarget(child);
+    }
+    return true;
+  }
+  return false;
+}
 
+function getParentForFragmentAncestors(inst: Fiber | null): Fiber | null {
+  if (inst === null) {
+    return null;
+  }
+  do {
+    inst = inst === null ? null : inst.return;
+  } while (
+    inst &&
+    inst.tag !== HostComponent &&
+    inst.tag !== HostSingleton &&
+    inst.tag !== HostRoot
+  );
+  if (inst) {
+    return inst;
+  }
+  return null;
+}
+
+/**
+ * Return the lowest common ancestor of A and B, or null if they are in
+ * different trees.
+ */
+export function getLowestCommonAncestor(
+  instA: Fiber,
+  instB: Fiber,
+  getParent: (inst: Fiber | null) => Fiber | null,
+): Fiber | null {
+  let nodeA: null | Fiber = instA;
+  let nodeB: null | Fiber = instB;
+  let depthA = 0;
+  for (let tempA: null | Fiber = nodeA; tempA; tempA = getParent(tempA)) {
+    depthA++;
+  }
+  let depthB = 0;
+  for (let tempB: null | Fiber = nodeB; tempB; tempB = getParent(tempB)) {
+    depthB++;
+  }
+
+  // If A is deeper, crawl up.
+  while (depthA - depthB > 0) {
+    nodeA = getParent(nodeA);
+    depthA--;
+  }
+
+  // If B is deeper, crawl up.
+  while (depthB - depthA > 0) {
+    nodeB = getParent(nodeB);
+    depthB--;
+  }
+
+  // Walk in lockstep until we find a match.
+  let depth = depthA;
+  while (depth--) {
+    if (nodeA === nodeB || (nodeB !== null && nodeA === nodeB.alternate)) {
+      return nodeA;
+    }
+    nodeA = getParent(nodeA);
+    nodeB = getParent(nodeB);
+  }
   return null;
 }