@samitouri / QOS-React / commits / 04bf10e6a9

Add getRootNode to fragment instances (#32682)

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.

Jack Pope committed Mar 24, 2025 at 10:19 UTC 04bf10e6a9526ea2600005a714c957c47dd8551d
4 files changed +125 -4
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+22 -2
@@ -53,7 +53,10 @@ import {
53 markNodeAsHoistable,
54 isOwnedInstance,
55 } from './ReactDOMComponentTree';
56 -import {traverseFragmentInstance} from 'react-reconciler/src/ReactFiberTreeReflection';
56 +import {
57 + traverseFragmentInstance,
58 + getFragmentParentHostInstance,
59 +} from 'react-reconciler/src/ReactFiberTreeReflection';
60
61 export {detachDeletedInstance};
62 import {hasRole} from './DOMAccessibilityRoles';
@@ -2239,6 +2242,9 @@ export type FragmentInstanceType = {
2242 observeUsing(observer: IntersectionObserver | ResizeObserver): void,
2243 unobserveUsing(observer: IntersectionObserver | ResizeObserver): void,
2244 getClientRects(): Array<DOMRect>,
2245 + getRootNode(getRootNodeOptions?: {
2246 + composed: boolean,
2247 + }): Document | ShadowRoot | FragmentInstanceType,
2248 };
2249
2250 function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
@@ -2338,7 +2344,7 @@ FragmentInstance.prototype.focus = function (
2344 FragmentInstance.prototype.focusLast = function (
2345 this: FragmentInstanceType,
2346 focusOptions?: FocusOptions,
2341 -) {
2347 +): void {
2348 const children: Array<Instance> = [];
2349 traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
2350 for (let i = children.length - 1; i >= 0; i--) {
@@ -2429,6 +2435,20 @@ function collectClientRects(child: Instance, rects: Array<DOMRect>): boolean {
2435 rects.push.apply(rects, child.getClientRects());
2436 return false;
2437 }
2438 +// $FlowFixMe[prop-missing]
2439 +FragmentInstance.prototype.getRootNode = function (
2440 + this: FragmentInstanceType,
2441 + getRootNodeOptions?: {composed: boolean},
2442 +): Document | ShadowRoot | FragmentInstanceType {
2443 + const parentHostInstance = getFragmentParentHostInstance(this._fragmentFiber);
2444 + if (parentHostInstance === null) {
2445 + return this;
2446 + }
2447 + const rootNode =
2448 + // $FlowFixMe[incompatible-cast] Flow expects Node
2449 + (parentHostInstance.getRootNode(getRootNodeOptions): Document | ShadowRoot);
2450 + return rootNode;
2451 +};
2452
2453 function normalizeListenerOptions(
2454 opts: ?EventListenerOptionsOrUseCapture,
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+83 -1
@@ -846,7 +846,7 @@ describe('FragmentRefs', () => {
846
847 describe('getClientRects', () => {
848 // @gate enableFragmentRefs
849 - it('returns the bounding client recs of all children', async () => {
849 + it('returns the bounding client rects of all children', async () => {
850 const fragmentRef = React.createRef();
851 const childARef = React.createRef();
852 const childBRef = React.createRef();
@@ -884,4 +884,86 @@ describe('FragmentRefs', () => {
884 expect(clientRects[2].left).toBe(9);
885 });
886 });
887 +
888 + describe('getRootNode', () => {
889 + // @gate enableFragmentRefs
890 + it('returns the root node of the parent', async () => {
891 + const fragmentRef = React.createRef();
892 + const root = ReactDOMClient.createRoot(container);
893 +
894 + function Test() {
895 + return (
896 + <div>
897 + <React.Fragment ref={fragmentRef}>
898 + <div />
899 + </React.Fragment>
900 + </div>
901 + );
902 + }
903 +
904 + await act(() => root.render(<Test />));
905 + expect(fragmentRef.current.getRootNode()).toBe(document);
906 + });
907 +
908 + // The desired behavior here is to return the topmost disconnected element when
909 + // fragment + parent are unmounted. Currently we have a pass during unmount that
910 + // recursively cleans up return pointers of the whole tree. We can change this
911 + // with a future refactor. See: https://github.com/facebook/react/pull/32682#discussion_r2008313082
912 + // @gate enableFragmentRefs
913 + it('returns the topmost disconnected element if the fragment and parent are unmounted', async () => {
914 + const containerRef = React.createRef();
915 + const parentRef = React.createRef();
916 + const fragmentRef = React.createRef();
917 + const root = ReactDOMClient.createRoot(container);
918 +
919 + function Test({mounted}) {
920 + return (
921 + <div ref={containerRef} id="container">
922 + {mounted && (
923 + <div ref={parentRef} id="parent">
924 + <React.Fragment ref={fragmentRef}>
925 + <div />
926 + </React.Fragment>
927 + </div>
928 + )}
929 + </div>
930 + );
931 + }
932 +
933 + await act(() => root.render(<Test mounted={true} />));
934 + expect(fragmentRef.current.getRootNode()).toBe(document);
935 + const fragmentHandle = fragmentRef.current;
936 + await act(() => root.render(<Test mounted={false} />));
937 + // TODO: The commented out assertion is the desired behavior. For now, we return
938 + // the fragment instance itself. This is currently the same behavior if you unmount
939 + // the fragment but not the parent. See context above.
940 + // expect(fragmentHandle.getRootNode().id).toBe(parentRefHandle.id);
941 + expect(fragmentHandle.getRootNode()).toBe(fragmentHandle);
942 + });
943 +
944 + // @gate enableFragmentRefs
945 + it('returns self when only the fragment was unmounted', async () => {
946 + const fragmentRef = React.createRef();
947 + const parentRef = React.createRef();
948 + const root = ReactDOMClient.createRoot(container);
949 +
950 + function Test({mounted}) {
951 + return (
952 + <div ref={parentRef} id="parent">
953 + {mounted && (
954 + <React.Fragment ref={fragmentRef}>
955 + <div />
956 + </React.Fragment>
957 + )}
958 + </div>
959 + );
960 + }
961 +
962 + await act(() => root.render(<Test mounted={true} />));
963 + expect(fragmentRef.current.getRootNode()).toBe(document);
964 + const fragmentHandle = fragmentRef.current;
965 + await act(() => root.render(<Test mounted={false} />));
966 + expect(fragmentHandle.getRootNode()).toBe(fragmentHandle);
967 + });
968 + });
969 });
packages/react-noop-renderer/src/createReactNoop.js
+5 -1
@@ -512,10 +512,14 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
512 throw new Error('Not yet implemented.');
513 },
514
515 - createFragmentInstance(parentInstance) {
515 + createFragmentInstance(fragmentFiber) {
516 return null;
517 },
518
519 + updateFragmentInstanceFiber(fragmentFiber, fragmentInstance) {
520 + // Noop
521 + },
522 +
523 commitNewChildToFragmentInstance(child, fragmentInstance) {
524 // Noop
525 },
packages/react-reconciler/src/ReactFiberTreeReflection.js
+15
@@ -352,3 +352,18 @@ function traverseFragmentInstanceChildren<A, B, C>(
352 child = child.sibling;
353 }
354 }
355 +
356 +export function getFragmentParentHostInstance(fiber: Fiber): null | Instance {
357 + let parent = fiber.return;
358 + while (parent !== null) {
359 + if (parent.tag === HostRoot) {
360 + return parent.stateNode.containerInfo;
361 + }
362 + if (parent.tag === HostComponent) {
363 + return parent.stateNode;
364 + }
365 + parent = parent.return;
366 + }
367 +
368 + return null;
369 +}