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

This implements `getRootNode(options)` on fragment instances as the equivalent of calling `getRootNode` on the fragment's parent host node.

The parent host instance will also be used to proxy dispatchEvent in an upcoming PR.

Changed files:
filename: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
patch: @@ -53,7 +53,10 @@ import {
   markNodeAsHoistable,
   isOwnedInstance,
 } from './ReactDOMComponentTree';
-import {traverseFragmentInstance} from 'react-reconciler/src/ReactFiberTreeReflection';
+import {
+  traverseFragmentInstance,
+  getFragmentParentHostInstance,
+} from 'react-reconciler/src/ReactFiberTreeReflection';
 
 export {detachDeletedInstance};
 import {hasRole} from './DOMAccessibilityRoles';
@@ -2239,6 +2242,9 @@ export type FragmentInstanceType = {
   observeUsing(observer: IntersectionObserver | ResizeObserver): void,
   unobserveUsing(observer: IntersectionObserver | ResizeObserver): void,
   getClientRects(): Array<DOMRect>,
+  getRootNode(getRootNodeOptions?: {
+    composed: boolean,
+  }): Document | ShadowRoot | FragmentInstanceType,
 };
 
 function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
@@ -2338,7 +2344,7 @@ FragmentInstance.prototype.focus = function (
 FragmentInstance.prototype.focusLast = function (
   this: FragmentInstanceType,
   focusOptions?: FocusOptions,
-) {
+): void {
   const children: Array<Instance> = [];
   traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
   for (let i = children.length - 1; i >= 0; i--) {
@@ -2429,6 +2435,20 @@ function collectClientRects(child: Instance, rects: Array<DOMRect>): boolean {
   rects.push.apply(rects, child.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) {
+    return this;
+  }
+  const rootNode =
+    // $FlowFixMe[incompatible-cast] Flow expects Node
+    (parentHostInstance.getRootNode(getRootNodeOptions): Document | ShadowRoot);
+  return rootNode;
+};
 
 function normalizeListenerOptions(
   opts: ?EventListenerOptionsOrUseCapture,

filename: packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
patch: @@ -846,7 +846,7 @@ describe('FragmentRefs', () => {
 
   describe('getClientRects', () => {
     // @gate enableFragmentRefs
-    it('returns the bounding client recs of all children', async () => {
+    it('returns the bounding client rects of all children', async () => {
       const fragmentRef = React.createRef();
       const childARef = React.createRef();
       const childBRef = React.createRef();
@@ -884,4 +884,86 @@ describe('FragmentRefs', () => {
       expect(clientRects[2].left).toBe(9);
     });
   });
+
+  describe('getRootNode', () => {
+    // @gate enableFragmentRefs
+    it('returns the root node of the parent', async () => {
+      const fragmentRef = React.createRef();
+      const root = ReactDOMClient.createRoot(container);
+
+      function Test() {
+        return (
+          <div>
+            <React.Fragment ref={fragmentRef}>
+              <div />
+            </React.Fragment>
+          </div>
+        );
+      }
+
+      await act(() => root.render(<Test />));
+      expect(fragmentRef.current.getRootNode()).toBe(document);
+    });
+
+    // The desired behavior here is to return the topmost disconnected element when
+    // fragment + parent are unmounted. Currently we have a pass during unmount that
+    // recursively cleans up return pointers of the whole tree. We can change this
+    // with a future refactor. See: https://github.com/facebook/react/pull/32682#discussion_r2008313082
+    // @gate enableFragmentRefs
+    it('returns the topmost disconnected element if the fragment and parent are unmounted', async () => {
+      const containerRef = React.createRef();
+      const parentRef = React.createRef();
+      const fragmentRef = React.createRef();
+      const root = ReactDOMClient.createRoot(container);
+
+      function Test({mounted}) {
+        return (
+          <div ref={containerRef} id="container">
+            {mounted && (
+              <div ref={parentRef} id="parent">
+                <React.Fragment ref={fragmentRef}>
+                  <div />
+                </React.Fragment>
+              </div>
+            )}
+          </div>
+        );
+      }
+
+      await act(() => root.render(<Test mounted={true} />));
+      expect(fragmentRef.current.getRootNode()).toBe(document);
+      const fragmentHandle = fragmentRef.current;
+      await act(() => root.render(<Test mounted={false} />));
+      // TODO: The commented out assertion is the desired behavior. For now, we return
+      // the fragment instance itself. This is currently the same behavior if you unmount
+      // the fragment but not the parent. See context above.
+      // expect(fragmentHandle.getRootNode().id).toBe(parentRefHandle.id);
+      expect(fragmentHandle.getRootNode()).toBe(fragmentHandle);
+    });
+
+    // @gate enableFragmentRefs
+    it('returns self when only the fragment was unmounted', async () => {
+      const fragmentRef = React.createRef();
+      const parentRef = React.createRef();
+      const root = ReactDOMClient.createRoot(container);
+
+      function Test({mounted}) {
+        return (
+          <div ref={parentRef} id="parent">
+            {mounted && (
+              <React.Fragment ref={fragmentRef}>
+                <div />
+              </React.Fragment>
+            )}
+          </div>
+        );
+      }
+
+      await act(() => root.render(<Test mounted={true} />));
+      expect(fragmentRef.current.getRootNode()).toBe(document);
+      const fragmentHandle = fragmentRef.current;
+      await act(() => root.render(<Test mounted={false} />));
+      expect(fragmentHandle.getRootNode()).toBe(fragmentHandle);
+    });
+  });
 });

filename: packages/react-noop-renderer/src/createReactNoop.js
patch: @@ -512,10 +512,14 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
       throw new Error('Not yet implemented.');
     },
 
-    createFragmentInstance(parentInstance) {
+    createFragmentInstance(fragmentFiber) {
       return null;
     },
 
+    updateFragmentInstanceFiber(fragmentFiber, fragmentInstance) {
+      // Noop
+    },
+
     commitNewChildToFragmentInstance(child, fragmentInstance) {
       // Noop
     },

filename: packages/react-reconciler/src/ReactFiberTreeReflection.js
patch: @@ -352,3 +352,18 @@ function traverseFragmentInstanceChildren<A, B, C>(
     child = child.sibling;
   }
 }
+
+export function getFragmentParentHostInstance(fiber: Fiber): null | Instance {
+  let parent = fiber.return;
+  while (parent !== null) {
+    if (parent.tag === HostRoot) {
+      return parent.stateNode.containerInfo;
+    }
+    if (parent.tag === HostComponent) {
+      return parent.stateNode;
+    }
+    parent = parent.return;
+  }
+
+  return null;
+}