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
+}