@samitouri / QOS-React-1 / commits / e5a8de81e5

Add compareDocumentPosition to fragment instances (#32722)

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 will result in `Node.DOCUMENT_POSITION_DISCONNECTED` and `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).

Jack Pope committed May 6, 2025 at 13:01 UTC e5a8de81e57181692d33ce916dfd6aa23638ec92
5 files changed +1013 -84
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+171 -24
@@ -37,6 +37,11 @@ import {runWithFiberInDEV} from 'react-reconciler/src/ReactCurrentFiber';
37 import hasOwnProperty from 'shared/hasOwnProperty';
38 import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
39 import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
40 +import {
41 + isFiberContainedBy,
42 + isFiberFollowing,
43 + isFiberPreceding,
44 +} from 'react-reconciler/src/ReactFiberTreeReflection';
45
46 export {
47 setCurrentUpdatePriority,
@@ -60,7 +65,9 @@ import {
65 } from './ReactDOMComponentTree';
66 import {
67 traverseFragmentInstance,
63 - getFragmentParentHostInstance,
68 + getFragmentParentHostFiber,
69 + getNextSiblingHostFiber,
70 + getInstanceFromHostFiber,
71 } from 'react-reconciler/src/ReactFiberTreeReflection';
72
73 export {detachDeletedInstance};
@@ -2599,6 +2606,7 @@ export type FragmentInstanceType = {
2606 getRootNode(getRootNodeOptions?: {
2607 composed: boolean,
2608 }): Document | ShadowRoot | FragmentInstanceType,
2609 + compareDocumentPosition(otherNode: Instance): number,
2610 };
2611
2612 function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
@@ -2636,12 +2644,13 @@ FragmentInstance.prototype.addEventListener = function (
2644 this._eventListeners = listeners;
2645 };
2646 function addEventListenerToChild(
2639 - child: Instance,
2647 + child: Fiber,
2648 type: string,
2649 listener: EventListener,
2650 optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2651 ): boolean {
2644 - child.addEventListener(type, listener, optionsOrUseCapture);
2652 + const instance = getInstanceFromHostFiber<Instance>(child);
2653 + instance.addEventListener(type, listener, optionsOrUseCapture);
2654 return false;
2655 }
2656 // $FlowFixMe[prop-missing]
@@ -2675,12 +2684,13 @@ FragmentInstance.prototype.removeEventListener = function (
2684 }
2685 };
2686 function removeEventListenerFromChild(
2678 - child: Instance,
2687 + child: Fiber,
2688 type: string,
2689 listener: EventListener,
2690 optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2691 ): boolean {
2683 - child.removeEventListener(type, listener, optionsOrUseCapture);
2692 + const instance = getInstanceFromHostFiber<Instance>(child);
2693 + instance.removeEventListener(type, listener, optionsOrUseCapture);
2694 return false;
2695 }
2696 // $FlowFixMe[prop-missing]
@@ -2690,28 +2700,32 @@ FragmentInstance.prototype.focus = function (
2700 ): void {
2701 traverseFragmentInstance(
2702 this._fragmentFiber,
2693 - setFocusIfFocusable,
2703 + setFocusOnFiberIfFocusable,
2704 focusOptions,
2705 );
2706 };
2707 +function setFocusOnFiberIfFocusable(
2708 + fiber: Fiber,
2709 + focusOptions?: FocusOptions,
2710 +): boolean {
2711 + const instance = getInstanceFromHostFiber<Instance>(fiber);
2712 + return setFocusIfFocusable(instance, focusOptions);
2713 +}
2714 // $FlowFixMe[prop-missing]
2715 FragmentInstance.prototype.focusLast = function (
2716 this: FragmentInstanceType,
2717 focusOptions?: FocusOptions,
2718 ): void {
2702 - const children: Array<Instance> = [];
2719 + const children: Array<Fiber> = [];
2720 traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
2721 for (let i = children.length - 1; i >= 0; i--) {
2722 const child = children[i];
2706 - if (setFocusIfFocusable(child, focusOptions)) {
2723 + if (setFocusOnFiberIfFocusable(child, focusOptions)) {
2724 break;
2725 }
2726 }
2727 };
2711 -function collectChildren(
2712 - child: Instance,
2713 - collection: Array<Instance>,
2714 -): boolean {
2728 +function collectChildren(child: Fiber, collection: Array<Fiber>): boolean {
2729 collection.push(child);
2730 return false;
2731 }
@@ -2724,12 +2738,13 @@ FragmentInstance.prototype.blur = function (this: FragmentInstanceType): void {
2738 blurActiveElementWithinFragment,
2739 );
2740 };
2727 -function blurActiveElementWithinFragment(child: Instance): boolean {
2741 +function blurActiveElementWithinFragment(child: Fiber): boolean {
2742 // TODO: We can get the activeElement from the parent outside of the loop when we have a reference.
2729 - const ownerDocument = child.ownerDocument;
2730 - if (child === ownerDocument.activeElement) {
2743 + const instance = getInstanceFromHostFiber<Instance>(child);
2744 + const ownerDocument = instance.ownerDocument;
2745 + if (instance === ownerDocument.activeElement) {
2746 // $FlowFixMe[prop-missing]
2732 - child.blur();
2747 + instance.blur();
2748 return true;
2749 }
2750 return false;
@@ -2746,10 +2761,11 @@ FragmentInstance.prototype.observeUsing = function (
2761 traverseFragmentInstance(this._fragmentFiber, observeChild, observer);
2762 };
2763 function observeChild(
2749 - child: Instance,
2764 + child: Fiber,
2765 observer: IntersectionObserver | ResizeObserver,
2766 ) {
2752 - observer.observe(child);
2767 + const instance = getInstanceFromHostFiber<Instance>(child);
2768 + observer.observe(instance);
2769 return false;
2770 }
2771 // $FlowFixMe[prop-missing]
@@ -2770,10 +2786,11 @@ FragmentInstance.prototype.unobserveUsing = function (
2786 }
2787 };
2788 function unobserveChild(
2773 - child: Instance,
2789 + child: Fiber,
2790 observer: IntersectionObserver | ResizeObserver,
2791 ) {
2776 - observer.unobserve(child);
2792 + const instance = getInstanceFromHostFiber<Instance>(child);
2793 + observer.unobserve(instance);
2794 return false;
2795 }
2796 // $FlowFixMe[prop-missing]
@@ -2784,9 +2801,10 @@ FragmentInstance.prototype.getClientRects = function (
2801 traverseFragmentInstance(this._fragmentFiber, collectClientRects, rects);
2802 return rects;
2803 };
2787 -function collectClientRects(child: Instance, rects: Array<DOMRect>): boolean {
2804 +function collectClientRects(child: Fiber, rects: Array<DOMRect>): boolean {
2805 + const instance = getInstanceFromHostFiber<Instance>(child);
2806 // $FlowFixMe[method-unbinding]
2789 - rects.push.apply(rects, child.getClientRects());
2807 + rects.push.apply(rects, instance.getClientRects());
2808 return false;
2809 }
2810 // $FlowFixMe[prop-missing]
@@ -2794,15 +2812,144 @@ FragmentInstance.prototype.getRootNode = function (
2812 this: FragmentInstanceType,
2813 getRootNodeOptions?: {composed: boolean},
2814 ): Document | ShadowRoot | FragmentInstanceType {
2797 - const parentHostInstance = getFragmentParentHostInstance(this._fragmentFiber);
2798 - if (parentHostInstance === null) {
2815 + const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
2816 + if (parentHostFiber === null) {
2817 return this;
2818 }
2819 + const parentHostInstance =
2820 + getInstanceFromHostFiber<Instance>(parentHostFiber);
2821 const rootNode =
2822 // $FlowFixMe[incompatible-cast] Flow expects Node
2823 (parentHostInstance.getRootNode(getRootNodeOptions): Document | ShadowRoot);
2824 return rootNode;
2825 };
2826 +// $FlowFixMe[prop-missing]
2827 +FragmentInstance.prototype.compareDocumentPosition = function (
2828 + this: FragmentInstanceType,
2829 + otherNode: Instance,
2830 +): number {
2831 + const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
2832 + if (parentHostFiber === null) {
2833 + return Node.DOCUMENT_POSITION_DISCONNECTED;
2834 + }
2835 + const children: Array<Fiber> = [];
2836 + traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
2837 +
2838 + let result = Node.DOCUMENT_POSITION_DISCONNECTED;
2839 + if (children.length === 0) {
2840 + // If the fragment has no children, we can use the parent and
2841 + // siblings to determine a position.
2842 + const parentHostInstance =
2843 + getInstanceFromHostFiber<Instance>(parentHostFiber);
2844 + const parentResult = parentHostInstance.compareDocumentPosition(otherNode);
2845 + result = parentResult;
2846 + if (parentHostInstance === otherNode) {
2847 + result = Node.DOCUMENT_POSITION_CONTAINS;
2848 + } else {
2849 + if (parentResult & Node.DOCUMENT_POSITION_CONTAINED_BY) {
2850 + // otherNode is one of the fragment's siblings. Use the next
2851 + // sibling to determine if its preceding or following.
2852 + const nextSiblingFiber = getNextSiblingHostFiber(this._fragmentFiber);
2853 + if (nextSiblingFiber === null) {
2854 + result = Node.DOCUMENT_POSITION_PRECEDING;
2855 + } else {
2856 + const nextSiblingInstance =
2857 + getInstanceFromHostFiber<Instance>(nextSiblingFiber);
2858 + const nextSiblingResult =
2859 + nextSiblingInstance.compareDocumentPosition(otherNode);
2860 + if (
2861 + nextSiblingResult === 0 ||
2862 + nextSiblingResult & Node.DOCUMENT_POSITION_FOLLOWING
2863 + ) {
2864 + result = Node.DOCUMENT_POSITION_FOLLOWING;
2865 + } else {
2866 + result = Node.DOCUMENT_POSITION_PRECEDING;
2867 + }
2868 + }
2869 + }
2870 + }
2871 +
2872 + result |= Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC;
2873 + return result;
2874 + }
2875 +
2876 + const firstElement = getInstanceFromHostFiber<Instance>(children[0]);
2877 + const lastElement = getInstanceFromHostFiber<Instance>(
2878 + children[children.length - 1],
2879 + );
2880 + const firstResult = firstElement.compareDocumentPosition(otherNode);
2881 + const lastResult = lastElement.compareDocumentPosition(otherNode);
2882 + if (
2883 + (firstResult & Node.DOCUMENT_POSITION_FOLLOWING &&
2884 + lastResult & Node.DOCUMENT_POSITION_PRECEDING) ||
2885 + otherNode === firstElement ||
2886 + otherNode === lastElement
2887 + ) {
2888 + result = Node.DOCUMENT_POSITION_CONTAINED_BY;
2889 + } else {
2890 + result = firstResult;
2891 + }
2892 +
2893 + if (
2894 + result & Node.DOCUMENT_POSITION_DISCONNECTED ||
2895 + result & Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC
2896 + ) {
2897 + return result;
2898 + }
2899 +
2900 + // Now that we have the result from the DOM API, we double check it matches
2901 + // the state of the React tree. If it doesn't, we have a case of portaled or
2902 + // otherwise injected elements and we return DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC.
2903 + const documentPositionMatchesFiberPosition =
2904 + validateDocumentPositionWithFiberTree(
2905 + result,
2906 + this._fragmentFiber,
2907 + children[0],
2908 + children[children.length - 1],
2909 + otherNode,
2910 + );
2911 + if (documentPositionMatchesFiberPosition) {
2912 + return result;
2913 + }
2914 + return Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC;
2915 +};
2916 +
2917 +function validateDocumentPositionWithFiberTree(
2918 + documentPosition: number,
2919 + fragmentFiber: Fiber,
2920 + precedingBoundaryFiber: Fiber,
2921 + followingBoundaryFiber: Fiber,
2922 + otherNode: Instance,
2923 +): boolean {
2924 + const otherFiber = getClosestInstanceFromNode(otherNode);
2925 + if (documentPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) {
2926 + return !!otherFiber && isFiberContainedBy(fragmentFiber, otherFiber);
2927 + }
2928 + if (documentPosition & Node.DOCUMENT_POSITION_CONTAINS) {
2929 + if (otherFiber === null) {
2930 + // otherFiber could be null if its the document or body element
2931 + const ownerDocument = otherNode.ownerDocument;
2932 + return otherNode === ownerDocument || otherNode === ownerDocument.body;
2933 + }
2934 + return isFiberContainedBy(otherFiber, fragmentFiber);
2935 + }
2936 + if (documentPosition & Node.DOCUMENT_POSITION_PRECEDING) {
2937 + return (
2938 + !!otherFiber &&
2939 + (otherFiber === precedingBoundaryFiber ||
2940 + isFiberPreceding(precedingBoundaryFiber, otherFiber))
2941 + );
2942 + }
2943 + if (documentPosition & Node.DOCUMENT_POSITION_FOLLOWING) {
2944 + return (
2945 + !!otherFiber &&
2946 + (otherFiber === followingBoundaryFiber ||
2947 + isFiberFollowing(followingBoundaryFiber, otherFiber))
2948 + );
2949 + }
2950 +
2951 + return false;
2952 +}
2953
2954 function normalizeListenerOptions(
2955 opts: ?EventListenerOptionsOrUseCapture,
packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
+3 -41
@@ -36,6 +36,7 @@ import {
36 HostText,
37 ScopeComponent,
38 } from 'react-reconciler/src/ReactWorkTags';
39 +import {getLowestCommonAncestor} from 'react-reconciler/src/ReactFiberTreeReflection';
40
41 import getEventTarget from './getEventTarget';
42 import {
@@ -891,46 +892,6 @@ function getParent(inst: Fiber | null): Fiber | null {
892 return null;
893 }
894
894 -/**
895 - * Return the lowest common ancestor of A and B, or null if they are in
896 - * different trees.
897 - */
898 -function getLowestCommonAncestor(instA: Fiber, instB: Fiber): Fiber | null {
899 - let nodeA: null | Fiber = instA;
900 - let nodeB: null | Fiber = instB;
901 - let depthA = 0;
902 - for (let tempA: null | Fiber = nodeA; tempA; tempA = getParent(tempA)) {
903 - depthA++;
904 - }
905 - let depthB = 0;
906 - for (let tempB: null | Fiber = nodeB; tempB; tempB = getParent(tempB)) {
907 - depthB++;
908 - }
909 -
910 - // If A is deeper, crawl up.
911 - while (depthA - depthB > 0) {
912 - nodeA = getParent(nodeA);
913 - depthA--;
914 - }
915 -
916 - // If B is deeper, crawl up.
917 - while (depthB - depthA > 0) {
918 - nodeB = getParent(nodeB);
919 - depthB--;
920 - }
921 -
922 - // Walk in lockstep until we find a match.
923 - let depth = depthA;
924 - while (depth--) {
925 - if (nodeA === nodeB || (nodeB !== null && nodeA === nodeB.alternate)) {
926 - return nodeA;
927 - }
928 - nodeA = getParent(nodeA);
929 - nodeB = getParent(nodeB);
930 - }
931 - return null;
932 -}
933 -
895 function accumulateEnterLeaveListenersForEvent(
896 dispatchQueue: DispatchQueue,
897 event: KnownReactSyntheticEvent,
@@ -992,7 +953,8 @@ export function accumulateEnterLeaveTwoPhaseListeners(
953 from: Fiber | null,
954 to: Fiber | null,
955 ): void {
995 - const common = from && to ? getLowestCommonAncestor(from, to) : null;
956 + const common =
957 + from && to ? getLowestCommonAncestor(from, to, getParent) : null;
958
959 if (from !== null) {
960 accumulateEnterLeaveListenersForEvent(
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+602
@@ -11,6 +11,8 @@
11
12 let React;
13 let ReactDOMClient;
14 +let ReactDOM;
15 +let createPortal;
16 let act;
17 let container;
18 let Fragment;
@@ -31,6 +33,8 @@ describe('FragmentRefs', () => {
33 Fragment = React.Fragment;
34 Activity = React.unstable_Activity;
35 ReactDOMClient = require('react-dom/client');
36 + ReactDOM = require('react-dom');
37 + createPortal = ReactDOM.createPortal;
38 act = require('internal-test-utils').act;
39 const IntersectionMocks = require('./utils/IntersectionMocks');
40 mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver;
@@ -40,6 +44,7 @@ describe('FragmentRefs', () => {
44 require('internal-test-utils').assertConsoleErrorDev;
45
46 container = document.createElement('div');
47 + document.body.innerHTML = '';
48 document.body.appendChild(container);
49 });
50
@@ -611,6 +616,39 @@ describe('FragmentRefs', () => {
616 expect(logs).toEqual([]);
617 });
618
619 + // @gate enableFragmentRefs
620 + it('applies event listeners to portaled children', async () => {
621 + const fragmentRef = React.createRef();
622 + const childARef = React.createRef();
623 + const childBRef = React.createRef();
624 + const root = ReactDOMClient.createRoot(container);
625 +
626 + function Test() {
627 + return (
628 + <Fragment ref={fragmentRef}>
629 + <div id="child-a" ref={childARef} />
630 + {createPortal(<div id="child-b" ref={childBRef} />, document.body)}
631 + </Fragment>
632 + );
633 + }
634 +
635 + await act(() => {
636 + root.render(<Test />);
637 + });
638 +
639 + const logs = [];
640 + fragmentRef.current.addEventListener('click', e => {
641 + logs.push(e.target.id);
642 + });
643 +
644 + childARef.current.click();
645 + expect(logs).toEqual(['child-a']);
646 +
647 + logs.length = 0;
648 + childBRef.current.click();
649 + expect(logs).toEqual(['child-b']);
650 + });
651 +
652 describe('with activity', () => {
653 // @gate enableFragmentRefs && enableActivity
654 it('does not apply event listeners to hidden trees', async () => {
@@ -966,4 +1004,568 @@ describe('FragmentRefs', () => {
1004 expect(fragmentHandle.getRootNode()).toBe(fragmentHandle);
1005 });
1006 });
1007 +
1008 + describe('compareDocumentPosition', () => {
1009 + function expectPosition(position, spec) {
1010 + const positionResult = {
1011 + following: (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
1012 + preceding: (position & Node.DOCUMENT_POSITION_PRECEDING) !== 0,
1013 + contains: (position & Node.DOCUMENT_POSITION_CONTAINS) !== 0,
1014 + containedBy: (position & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0,
1015 + disconnected: (position & Node.DOCUMENT_POSITION_DISCONNECTED) !== 0,
1016 + implementationSpecific:
1017 + (position & Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC) !== 0,
1018 + };
1019 + expect(positionResult).toEqual(spec);
1020 + }
1021 + // @gate enableFragmentRefs
1022 + it('returns the relationship between the fragment instance and a given node', async () => {
1023 + const fragmentRef = React.createRef();
1024 + const beforeRef = React.createRef();
1025 + const afterRef = React.createRef();
1026 + const middleChildRef = React.createRef();
1027 + const firstChildRef = React.createRef();
1028 + const lastChildRef = React.createRef();
1029 + const containerRef = React.createRef();
1030 + const disconnectedElement = document.createElement('div');
1031 + const root = ReactDOMClient.createRoot(container);
1032 +
1033 + function Test() {
1034 + return (
1035 + <div ref={containerRef}>
1036 + <div ref={beforeRef} />
1037 + <React.Fragment ref={fragmentRef}>
1038 + <div ref={firstChildRef} />
1039 + <div ref={middleChildRef} />
1040 + <div ref={lastChildRef} />
1041 + </React.Fragment>
1042 + <div ref={afterRef} />
1043 + </div>
1044 + );
1045 + }
1046 +
1047 + await act(() => root.render(<Test />));
1048 +
1049 + // document.body is preceding and contains the fragment
1050 + expectPosition(
1051 + fragmentRef.current.compareDocumentPosition(document.body),
1052 + {
1053 + preceding: true,
1054 + following: false,
1055 + contains: true,
1056 + containedBy: false,
1057 + disconnected: false,
1058 + implementationSpecific: false,
1059 + },
1060 + );
1061 +
1062 + // beforeRef is preceding the fragment
1063 + expectPosition(
1064 + fragmentRef.current.compareDocumentPosition(beforeRef.current),
1065 + {
1066 + preceding: true,
1067 + following: false,
1068 + contains: false,
1069 + containedBy: false,
1070 + disconnected: false,
1071 + implementationSpecific: false,
1072 + },
1073 + );
1074 +
1075 + // afterRef is following the fragment
1076 + expectPosition(
1077 + fragmentRef.current.compareDocumentPosition(afterRef.current),
1078 + {
1079 + preceding: false,
1080 + following: true,
1081 + contains: false,
1082 + containedBy: false,
1083 + disconnected: false,
1084 + implementationSpecific: false,
1085 + },
1086 + );
1087 +
1088 + // firstChildRef is contained by the fragment
1089 + expectPosition(
1090 + fragmentRef.current.compareDocumentPosition(firstChildRef.current),
1091 + {
1092 + preceding: false,
1093 + following: false,
1094 + contains: false,
1095 + containedBy: true,
1096 + disconnected: false,
1097 + implementationSpecific: false,
1098 + },
1099 + );
1100 +
1101 + // middleChildRef is contained by the fragment
1102 + expectPosition(
1103 + fragmentRef.current.compareDocumentPosition(middleChildRef.current),
1104 + {
1105 + preceding: false,
1106 + following: false,
1107 + contains: false,
1108 + containedBy: true,
1109 + disconnected: false,
1110 + implementationSpecific: false,
1111 + },
1112 + );
1113 +
1114 + // lastChildRef is contained by the fragment
1115 + expectPosition(
1116 + fragmentRef.current.compareDocumentPosition(lastChildRef.current),
1117 + {
1118 + preceding: false,
1119 + following: false,
1120 + contains: false,
1121 + containedBy: true,
1122 + disconnected: false,
1123 + implementationSpecific: false,
1124 + },
1125 + );
1126 +
1127 + // containerRef preceds and contains the fragment
1128 + expectPosition(
1129 + fragmentRef.current.compareDocumentPosition(containerRef.current),
1130 + {
1131 + preceding: true,
1132 + following: false,
1133 + contains: true,
1134 + containedBy: false,
1135 + disconnected: false,
1136 + implementationSpecific: false,
1137 + },
1138 + );
1139 +
1140 + expectPosition(
1141 + fragmentRef.current.compareDocumentPosition(disconnectedElement),
1142 + {
1143 + preceding: false,
1144 + following: true,
1145 + contains: false,
1146 + containedBy: false,
1147 + disconnected: true,
1148 + implementationSpecific: true,
1149 + },
1150 + );
1151 + });
1152 +
1153 + // @gate enableFragmentRefs
1154 + it('handles fragment instances with one child', async () => {
1155 + const fragmentRef = React.createRef();
1156 + const beforeRef = React.createRef();
1157 + const afterRef = React.createRef();
1158 + const containerRef = React.createRef();
1159 + const onlyChildRef = React.createRef();
1160 + const disconnectedElement = document.createElement('div');
1161 + const root = ReactDOMClient.createRoot(container);
1162 +
1163 + function Test() {
1164 + return (
1165 + <div id="container" ref={containerRef}>
1166 + <div>
1167 + <div ref={beforeRef} id="before" />
1168 + <React.Fragment ref={fragmentRef}>
1169 + <div ref={onlyChildRef} id="within" />
1170 + </React.Fragment>
1171 + <div id="after" ref={afterRef} />
1172 + </div>
1173 + </div>
1174 + );
1175 + }
1176 +
1177 + await act(() => root.render(<Test />));
1178 + expectPosition(
1179 + fragmentRef.current.compareDocumentPosition(beforeRef.current),
1180 + {
1181 + preceding: true,
1182 + following: false,
1183 + contains: false,
1184 + containedBy: false,
1185 + disconnected: false,
1186 + implementationSpecific: false,
1187 + },
1188 + );
1189 + expectPosition(
1190 + fragmentRef.current.compareDocumentPosition(afterRef.current),
1191 + {
1192 + preceding: false,
1193 + following: true,
1194 + contains: false,
1195 + containedBy: false,
1196 + disconnected: false,
1197 + implementationSpecific: false,
1198 + },
1199 + );
1200 + expectPosition(
1201 + fragmentRef.current.compareDocumentPosition(onlyChildRef.current),
1202 + {
1203 + preceding: false,
1204 + following: false,
1205 + contains: false,
1206 + containedBy: true,
1207 + disconnected: false,
1208 + implementationSpecific: false,
1209 + },
1210 + );
1211 + expectPosition(
1212 + fragmentRef.current.compareDocumentPosition(containerRef.current),
1213 + {
1214 + preceding: true,
1215 + following: false,
1216 + contains: true,
1217 + containedBy: false,
1218 + disconnected: false,
1219 + implementationSpecific: false,
1220 + },
1221 + );
1222 + expectPosition(
1223 + fragmentRef.current.compareDocumentPosition(disconnectedElement),
1224 + {
1225 + preceding: false,
1226 + following: true,
1227 + contains: false,
1228 + containedBy: false,
1229 + disconnected: true,
1230 + implementationSpecific: true,
1231 + },
1232 + );
1233 + });
1234 +
1235 + // @gate enableFragmentRefs
1236 + it('handles empty fragment instances', async () => {
1237 + const fragmentRef = React.createRef();
1238 + const beforeParentRef = React.createRef();
1239 + const beforeRef = React.createRef();
1240 + const afterRef = React.createRef();
1241 + const afterParentRef = React.createRef();
1242 + const containerRef = React.createRef();
1243 + const root = ReactDOMClient.createRoot(container);
1244 +
1245 + function Test() {
1246 + return (
1247 + <>
1248 + <div id="before-container" ref={beforeParentRef} />
1249 + <div id="container" ref={containerRef}>
1250 + <div id="before" ref={beforeRef} />
1251 + <React.Fragment ref={fragmentRef} />
1252 + <div id="after" ref={afterRef} />
1253 + </div>
1254 + <div id="after-container" ref={afterParentRef} />
1255 + </>
1256 + );
1257 + }
1258 +
1259 + await act(() => root.render(<Test />));
1260 +
1261 + expectPosition(
1262 + fragmentRef.current.compareDocumentPosition(document.body),
1263 + {
1264 + preceding: true,
1265 + following: false,
1266 + contains: true,
1267 + containedBy: false,
1268 + disconnected: false,
1269 + implementationSpecific: true,
1270 + },
1271 + );
1272 + expectPosition(
1273 + fragmentRef.current.compareDocumentPosition(beforeRef.current),
1274 + {
1275 + preceding: true,
1276 + following: false,
1277 + contains: false,
1278 + containedBy: false,
1279 + disconnected: false,
1280 + implementationSpecific: true,
1281 + },
1282 + );
1283 + expectPosition(
1284 + fragmentRef.current.compareDocumentPosition(beforeParentRef.current),
1285 + {
1286 + preceding: true,
1287 + following: false,
1288 + contains: false,
1289 + containedBy: false,
1290 + disconnected: false,
1291 + implementationSpecific: true,
1292 + },
1293 + );
1294 + expectPosition(
1295 + fragmentRef.current.compareDocumentPosition(afterRef.current),
1296 + {
1297 + preceding: false,
1298 + following: true,
1299 + contains: false,
1300 + containedBy: false,
1301 + disconnected: false,
1302 + implementationSpecific: true,
1303 + },
1304 + );
1305 + expectPosition(
1306 + fragmentRef.current.compareDocumentPosition(afterParentRef.current),
1307 + {
1308 + preceding: false,
1309 + following: true,
1310 + contains: false,
1311 + containedBy: false,
1312 + disconnected: false,
1313 + implementationSpecific: true,
1314 + },
1315 + );
1316 + expectPosition(
1317 + fragmentRef.current.compareDocumentPosition(containerRef.current),
1318 + {
1319 + preceding: false,
1320 + following: false,
1321 + contains: true,
1322 + containedBy: false,
1323 + disconnected: false,
1324 + implementationSpecific: true,
1325 + },
1326 + );
1327 + });
1328 +
1329 + // @gate enableFragmentRefs
1330 + it('returns disconnected for comparison with an unmounted fragment instance', async () => {
1331 + const fragmentRef = React.createRef();
1332 + const containerRef = React.createRef();
1333 + const root = ReactDOMClient.createRoot(container);
1334 +
1335 + function Test({mount}) {
1336 + return (
1337 + <div ref={containerRef}>
1338 + {mount && (
1339 + <Fragment ref={fragmentRef}>
1340 + <div />
1341 + </Fragment>
1342 + )}
1343 + </div>
1344 + );
1345 + }
1346 +
1347 + await act(() => root.render(<Test mount={true} />));
1348 +
1349 + const fragmentHandle = fragmentRef.current;
1350 +
1351 + expectPosition(
1352 + fragmentHandle.compareDocumentPosition(containerRef.current),
1353 + {
1354 + preceding: true,
1355 + following: false,
1356 + contains: true,
1357 + containedBy: false,
1358 + disconnected: false,
1359 + implementationSpecific: false,
1360 + },
1361 + );
1362 +
1363 + await act(() => {
1364 + root.render(<Test mount={false} />);
1365 + });
1366 +
1367 + expectPosition(
1368 + fragmentHandle.compareDocumentPosition(containerRef.current),
1369 + {
1370 + preceding: false,
1371 + following: false,
1372 + contains: false,
1373 + containedBy: false,
1374 + disconnected: true,
1375 + implementationSpecific: false,
1376 + },
1377 + );
1378 + });
1379 +
1380 + describe('with portals', () => {
1381 + // @gate enableFragmentRefs
1382 + it('handles portaled elements', async () => {
1383 + const fragmentRef = React.createRef();
1384 + const portaledSiblingRef = React.createRef();
1385 + const portaledChildRef = React.createRef();
1386 +
1387 + function Test() {
1388 + return (
1389 + <div>
1390 + {createPortal(<div ref={portaledSiblingRef} />, document.body)}
1391 + <Fragment ref={fragmentRef}>
1392 + {createPortal(<div ref={portaledChildRef} />, document.body)}
1393 + <div />
1394 + </Fragment>
1395 + </div>
1396 + );
1397 + }
1398 +
1399 + const root = ReactDOMClient.createRoot(container);
1400 + await act(() => root.render(<Test />));
1401 +
1402 + // The sibling is preceding in both the DOM and the React tree
1403 + expectPosition(
1404 + fragmentRef.current.compareDocumentPosition(
1405 + portaledSiblingRef.current,
1406 + ),
1407 + {
1408 + preceding: true,
1409 + following: false,
1410 + contains: false,
1411 + containedBy: false,
1412 + disconnected: false,
1413 + implementationSpecific: false,
1414 + },
1415 + );
1416 +
1417 + // The child is contained by in the React tree but not in the DOM
1418 + expectPosition(
1419 + fragmentRef.current.compareDocumentPosition(portaledChildRef.current),
1420 + {
1421 + preceding: false,
1422 + following: false,
1423 + contains: false,
1424 + containedBy: false,
1425 + disconnected: false,
1426 + implementationSpecific: true,
1427 + },
1428 + );
1429 + });
1430 +
1431 + // @gate enableFragmentRefs
1432 + it('handles multiple portals to the same element', async () => {
1433 + const root = ReactDOMClient.createRoot(container);
1434 + const fragmentRef = React.createRef();
1435 + const childARef = React.createRef();
1436 + const childBRef = React.createRef();
1437 + const childCRef = React.createRef();
1438 +
1439 + function Test() {
1440 + const [c, setC] = React.useState(false);
1441 + React.useEffect(() => {
1442 + setC(true);
1443 + });
1444 +
1445 + return (
1446 + <>
1447 + {createPortal(
1448 + <Fragment ref={fragmentRef}>
1449 + <div id="A" ref={childARef} />
1450 + {c ? <div id="C" ref={childCRef} /> : null}
1451 + </Fragment>,
1452 + document.body,
1453 + )}
1454 + {createPortal(<p id="B" ref={childBRef} />, document.body)}
1455 + </>
1456 + );
1457 + }
1458 +
1459 + await act(() => root.render(<Test />));
1460 +
1461 + // Due to effect, order is A->B->C
1462 + expect(document.body.innerHTML).toBe(
1463 + '<div></div>' +
1464 + '<div id="A"></div>' +
1465 + '<p id="B"></p>' +
1466 + '<div id="C"></div>',
1467 + );
1468 +
1469 + expectPosition(
1470 + fragmentRef.current.compareDocumentPosition(document.body),
1471 + {
1472 + preceding: true,
1473 + following: false,
1474 + contains: true,
1475 + containedBy: false,
1476 + disconnected: false,
1477 + implementationSpecific: false,
1478 + },
1479 + );
1480 +
1481 + expectPosition(
1482 + fragmentRef.current.compareDocumentPosition(childARef.current),
1483 + {
1484 + preceding: false,
1485 + following: false,
1486 + contains: false,
1487 + containedBy: true,
1488 + disconnected: false,
1489 + implementationSpecific: false,
1490 + },
1491 + );
1492 + expectPosition(
1493 + fragmentRef.current.compareDocumentPosition(childBRef.current),
1494 + {
1495 + preceding: false,
1496 + following: false,
1497 + contains: false,
1498 + containedBy: false,
1499 + disconnected: false,
1500 + implementationSpecific: true,
1501 + },
1502 + );
1503 + expectPosition(
1504 + fragmentRef.current.compareDocumentPosition(childCRef.current),
1505 + {
1506 + preceding: false,
1507 + following: false,
1508 + contains: false,
1509 + containedBy: true,
1510 + disconnected: false,
1511 + implementationSpecific: false,
1512 + },
1513 + );
1514 + });
1515 +
1516 + // @gate enableFragmentRefs
1517 + it('handles empty fragments', async () => {
1518 + const fragmentRef = React.createRef();
1519 + const childARef = React.createRef();
1520 + const childBRef = React.createRef();
1521 +
1522 + function Test() {
1523 + return (
1524 + <>
1525 + <div id="A" ref={childARef} />
1526 + {createPortal(<Fragment ref={fragmentRef} />, document.body)}
1527 + <div id="B" ref={childBRef} />
1528 + </>
1529 + );
1530 + }
1531 +
1532 + const root = ReactDOMClient.createRoot(container);
1533 + await act(() => root.render(<Test />));
1534 +
1535 + expectPosition(
1536 + fragmentRef.current.compareDocumentPosition(document.body),
1537 + {
1538 + preceding: true,
1539 + following: false,
1540 + contains: true,
1541 + containedBy: false,
1542 + disconnected: false,
1543 + implementationSpecific: true,
1544 + },
1545 + );
1546 + expectPosition(
1547 + fragmentRef.current.compareDocumentPosition(childARef.current),
1548 + {
1549 + preceding: true,
1550 + following: false,
1551 + contains: false,
1552 + containedBy: false,
1553 + disconnected: false,
1554 + implementationSpecific: true,
1555 + },
1556 + );
1557 + expectPosition(
1558 + fragmentRef.current.compareDocumentPosition(childBRef.current),
1559 + {
1560 + preceding: true,
1561 + following: false,
1562 + contains: false,
1563 + containedBy: false,
1564 + disconnected: false,
1565 + implementationSpecific: true,
1566 + },
1567 + );
1568 + });
1569 + });
1570 + });
1571 });
packages/react-native-renderer/src/ReactFiberConfigFabric.js
+9 -4
@@ -24,7 +24,10 @@ import {
24 } from 'react-reconciler/src/ReactEventPriorities';
25 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
26 import {HostText} from 'react-reconciler/src/ReactWorkTags';
27 -import {traverseFragmentInstance} from 'react-reconciler/src/ReactFiberTreeReflection';
27 +import {
28 + getInstanceFromHostFiber,
29 + traverseFragmentInstance,
30 +} from 'react-reconciler/src/ReactFiberTreeReflection';
31
32 // Modules provided by RN:
33 import {
@@ -640,7 +643,8 @@ FragmentInstance.prototype.observeUsing = function (
643 this._observers.add(observer);
644 traverseFragmentInstance(this._fragmentFiber, observeChild, observer);
645 };
643 -function observeChild(instance: Instance, observer: IntersectionObserver) {
646 +function observeChild(child: Fiber, observer: IntersectionObserver) {
647 + const instance = getInstanceFromHostFiber<Instance>(child);
648 const publicInstance = getPublicInstance(instance);
649 if (publicInstance == null) {
650 throw new Error('Expected to find a host node. This is a bug in React.');
@@ -666,7 +670,8 @@ FragmentInstance.prototype.unobserveUsing = function (
670 traverseFragmentInstance(this._fragmentFiber, unobserveChild, observer);
671 }
672 };
669 -function unobserveChild(instance: Instance, observer: IntersectionObserver) {
673 +function unobserveChild(child: Fiber, observer: IntersectionObserver) {
674 + const instance = getInstanceFromHostFiber<Instance>(child);
675 const publicInstance = getPublicInstance(instance);
676 if (publicInstance == null) {
677 throw new Error('Expected to find a host node. This is a bug in React.');
@@ -690,7 +695,7 @@ export function updateFragmentInstanceFiber(
695 }
696
697 export function commitNewChildToFragmentInstance(
693 - child: Instance,
698 + child: Fiber,
699 fragmentInstance: FragmentInstanceType,
700 ): void {
701 if (fragmentInstance._observers !== null) {
packages/react-reconciler/src/ReactFiberTreeReflection.js
+228 -15
@@ -12,7 +12,6 @@ import type {
12 Container,
13 ActivityInstance,
14 SuspenseInstance,
15 - Instance,
15 } from './ReactFiberConfig';
16 import type {ActivityState} from './ReactFiberActivityComponent';
17 import type {SuspenseState} from './ReactFiberSuspenseComponent';
@@ -345,27 +344,42 @@ export function doesFiberContain(
344 return false;
345 }
346
348 -export function traverseFragmentInstance<I, A, B, C>(
347 +export function traverseFragmentInstance<A, B, C>(
348 fragmentFiber: Fiber,
350 - fn: (I, A, B, C) => boolean,
349 + fn: (Fiber, A, B, C) => boolean,
350 a: A,
351 b: B,
352 c: C,
353 ): void {
355 - traverseFragmentInstanceChildren(fragmentFiber.child, fn, a, b, c);
354 + traverseVisibleHostChildren(fragmentFiber.child, false, fn, a, b, c);
355 }
356
358 -function traverseFragmentInstanceChildren<I, A, B, C>(
357 +function traverseVisibleHostChildren<A, B, C>(
358 child: Fiber | null,
360 - fn: (I, A, B, C) => boolean,
359 + searchWithinHosts: boolean,
360 + fn: (Fiber, A, B, C) => boolean,
361 a: A,
362 b: B,
363 c: C,
364 -): void {
364 +): boolean {
365 while (child !== null) {
366 if (child.tag === HostComponent) {
367 - if (fn(child.stateNode, a, b, c)) {
368 - return;
367 + if (fn(child, a, b, c)) {
368 + return true;
369 + }
370 + if (searchWithinHosts) {
371 + if (
372 + traverseVisibleHostChildren(
373 + child.child,
374 + searchWithinHosts,
375 + fn,
376 + a,
377 + b,
378 + c,
379 + )
380 + ) {
381 + return true;
382 + }
383 }
384 } else if (
385 child.tag === OffscreenComponent &&
@@ -373,23 +387,222 @@ function traverseFragmentInstanceChildren<I, A, B, C>(
387 ) {
388 // Skip hidden subtrees
389 } else {
376 - traverseFragmentInstanceChildren(child.child, fn, a, b, c);
390 + if (
391 + traverseVisibleHostChildren(child.child, searchWithinHosts, fn, a, b, c)
392 + ) {
393 + return true;
394 + }
395 }
396 child = child.sibling;
397 }
398 + return false;
399 }
400
382 -export function getFragmentParentHostInstance(fiber: Fiber): null | Instance {
401 +export function getFragmentParentHostFiber(fiber: Fiber): null | Fiber {
402 let parent = fiber.return;
403 while (parent !== null) {
385 - if (parent.tag === HostRoot) {
386 - return parent.stateNode.containerInfo;
404 + if (parent.tag === HostRoot || parent.tag === HostComponent) {
405 + return parent;
406 }
388 - if (parent.tag === HostComponent) {
389 - return parent.stateNode;
407 + parent = parent.return;
408 + }
409 +
410 + return null;
411 +}
412 +
413 +export function getInstanceFromHostFiber<I>(fiber: Fiber): I {
414 + switch (fiber.tag) {
415 + case HostComponent:
416 + return fiber.stateNode;
417 + case HostRoot:
418 + return fiber.stateNode.containerInfo;
419 + default:
420 + throw new Error('Expected to find a host node. This is a bug in React.');
421 + }
422 +}
423 +
424 +let searchTarget = null;
425 +let searchBoundary = null;
426 +function pushSearchTarget(target: null | Fiber): void {
427 + searchTarget = target;
428 +}
429 +function popSearchTarget(): null | Fiber {
430 + return searchTarget;
431 +}
432 +function pushSearchBoundary(value: null | Fiber): void {
433 + searchBoundary = value;
434 +}
435 +function popSearchBoundary(): null | Fiber {
436 + return searchBoundary;
437 +}
438 +
439 +export function getNextSiblingHostFiber(fiber: Fiber): null | Fiber {
440 + traverseVisibleHostChildren(fiber.sibling, false, findNextSibling);
441 + const sibling = popSearchTarget();
442 + pushSearchTarget(null);
443 + return sibling;
444 +}
445 +
446 +function findNextSibling(child: Fiber): boolean {
447 + pushSearchTarget(child);
448 + return true;
449 +}
450 +
451 +export function isFiberContainedBy(
452 + maybeChild: Fiber,
453 + maybeParent: Fiber,
454 +): boolean {
455 + let parent = maybeParent.return;
456 + if (parent === maybeChild || parent === maybeChild.alternate) {
457 + return true;
458 + }
459 + while (parent !== null && parent !== maybeChild) {
460 + if (
461 + (parent.tag === HostComponent || parent.tag === HostRoot) &&
462 + (parent.return === maybeChild || parent.return === maybeChild.alternate)
463 + ) {
464 + return true;
465 }
466 parent = parent.return;
467 }
468 + return false;
469 +}
470 +
471 +export function isFiberPreceding(fiber: Fiber, otherFiber: Fiber): boolean {
472 + const commonAncestor = getLowestCommonAncestor(
473 + fiber,
474 + otherFiber,
475 + getParentForFragmentAncestors,
476 + );
477 + if (commonAncestor === null) {
478 + return false;
479 + }
480 + traverseVisibleHostChildren(
481 + commonAncestor,
482 + true,
483 + isFiberPrecedingCheck,
484 + otherFiber,
485 + fiber,
486 + );
487 + const target = popSearchTarget();
488 + pushSearchTarget(null);
489 + return target !== null;
490 +}
491 +
492 +function isFiberPrecedingCheck(
493 + child: Fiber,
494 + target: Fiber,
495 + boundary: Fiber,
496 +): boolean {
497 + if (child === boundary) {
498 + return true;
499 + }
500 + if (child === target) {
501 + pushSearchTarget(child);
502 + return true;
503 + }
504 + return false;
505 +}
506 +
507 +export function isFiberFollowing(fiber: Fiber, otherFiber: Fiber): boolean {
508 + const commonAncestor = getLowestCommonAncestor(
509 + fiber,
510 + otherFiber,
511 + getParentForFragmentAncestors,
512 + );
513 + if (commonAncestor === null) {
514 + return false;
515 + }
516 + traverseVisibleHostChildren(
517 + commonAncestor,
518 + true,
519 + isFiberFollowingCheck,
520 + otherFiber,
521 + fiber,
522 + );
523 + const target = popSearchTarget();
524 + pushSearchTarget(null);
525 + pushSearchBoundary(null);
526 + return target !== null;
527 +}
528 +
529 +function isFiberFollowingCheck(
530 + child: Fiber,
531 + target: Fiber,
532 + boundary: Fiber,
533 +): boolean {
534 + if (child === boundary) {
535 + pushSearchBoundary(child);
536 + return false;
537 + }
538 + if (child === target) {
539 + // The target is only following if we already found the boundary.
540 + if (popSearchBoundary() !== null) {
541 + pushSearchTarget(child);
542 + }
543 + return true;
544 + }
545 + return false;
546 +}
547
548 +function getParentForFragmentAncestors(inst: Fiber | null): Fiber | null {
549 + if (inst === null) {
550 + return null;
551 + }
552 + do {
553 + inst = inst === null ? null : inst.return;
554 + } while (
555 + inst &&
556 + inst.tag !== HostComponent &&
557 + inst.tag !== HostSingleton &&
558 + inst.tag !== HostRoot
559 + );
560 + if (inst) {
561 + return inst;
562 + }
563 + return null;
564 +}
565 +
566 +/**
567 + * Return the lowest common ancestor of A and B, or null if they are in
568 + * different trees.
569 + */
570 +export function getLowestCommonAncestor(
571 + instA: Fiber,
572 + instB: Fiber,
573 + getParent: (inst: Fiber | null) => Fiber | null,
574 +): Fiber | null {
575 + let nodeA: null | Fiber = instA;
576 + let nodeB: null | Fiber = instB;
577 + let depthA = 0;
578 + for (let tempA: null | Fiber = nodeA; tempA; tempA = getParent(tempA)) {
579 + depthA++;
580 + }
581 + let depthB = 0;
582 + for (let tempB: null | Fiber = nodeB; tempB; tempB = getParent(tempB)) {
583 + depthB++;
584 + }
585 +
586 + // If A is deeper, crawl up.
587 + while (depthA - depthB > 0) {
588 + nodeA = getParent(nodeA);
589 + depthA--;
590 + }
591 +
592 + // If B is deeper, crawl up.
593 + while (depthB - depthA > 0) {
594 + nodeB = getParent(nodeB);
595 + depthB--;
596 + }
597 +
598 + // Walk in lockstep until we find a match.
599 + let depth = depthA;
600 + while (depth--) {
601 + if (nodeA === nodeB || (nodeB !== null && nodeA === nodeB.alternate)) {
602 + return nodeA;
603 + }
604 + nodeA = getParent(nodeA);
605 + nodeB = getParent(nodeB);
606 + }
607 return null;
608 }