@samitouri / QOS-React-2 / commits / edd05f181b

Add fragment handles to children of FragmentInstances (#34935)

This PR adds a `unstable_reactFragments?: Set<FragmentInstance>` property to DOM nodes that belong to a Fragment with a ref (top level host components). This allows you to access a FragmentInstance from a DOM node. This is flagged behind `enableFragmentRefsInstanceHandles`. The primary use case to unblock is reusing IntersectionObserver instances. A fairly common practice is to cache and reuse IntersectionObservers that share the same config, with a map of node->callbacks to run for each entry in the IO callback. Currently this is not possible with Fragment Ref `observeUsing` because the key in the cache would have to be the `FragmentInstance` and you can't find it without a handle from the node. This works now by accessing `entry.target.fragments`. This also opens up possibilities to use `FragmentInstance` operations in other places, such as events. We can do `event.target.unstable_reactFragments`, then access `fragmentInstance.getClientRects` for example. In a future PR, we can assign an event's `currentTarget` as the Fragment Ref for a more direct handle when the event has been dispatched by the Fragment itself. The first commit here implemented a handle only on observed elements. This is awkward because there isn't a good way to document or expose this temporary property. `element.fragments` is closer to what we would expect from a DOM API if a standard was implemented here. And by assigning it to all top-level nodes of a Fragment, it can be used beyond the cached IntersectionObserver callback. One tradeoff here is adding extra work during the creation of FragmentInstances as well as keeping track of adding/removing nodes. Previously we only track the Fiber on creation but here we add a traversal which could apply to a large set of top-level host children. The `element.unstable_reactFragments` Set can also be randomly ordered.

Jack Pope committed Nov 3, 2025 at 17:51 UTC edd05f181b72272fb87b77bd84d2c048a4613955
10 files changed +281 -7
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+51 -4
@@ -126,6 +126,7 @@ import {
126 enableHydrationChangeEvent,
127 enableFragmentRefsScrollIntoView,
128 enableProfilerTimer,
129 + enableFragmentRefsInstanceHandles,
130 } from 'shared/ReactFeatureFlags';
131 import {
132 HostComponent,
@@ -214,6 +215,10 @@ export type Container =
215 export type Instance = Element;
216 export type TextInstance = Text;
217
218 +type InstanceWithFragmentHandles = Instance & {
219 + unstable_reactFragments?: Set<FragmentInstanceType>,
220 +};
221 +
222 declare class ActivityInterface extends Comment {}
223 declare class SuspenseInterface extends Comment {
224 _reactRetry: void | (() => void);
@@ -3390,10 +3395,44 @@ if (enableFragmentRefsScrollIntoView) {
3395 };
3396 }
3397
3398 +function addFragmentHandleToFiber(
3399 + child: Fiber,
3400 + fragmentInstance: FragmentInstanceType,
3401 +): boolean {
3402 + if (enableFragmentRefsInstanceHandles) {
3403 + const instance =
3404 + getInstanceFromHostFiber<InstanceWithFragmentHandles>(child);
3405 + if (instance != null) {
3406 + addFragmentHandleToInstance(instance, fragmentInstance);
3407 + }
3408 + }
3409 + return false;
3410 +}
3411 +
3412 +function addFragmentHandleToInstance(
3413 + instance: InstanceWithFragmentHandles,
3414 + fragmentInstance: FragmentInstanceType,
3415 +): void {
3416 + if (enableFragmentRefsInstanceHandles) {
3417 + if (instance.unstable_reactFragments == null) {
3418 + instance.unstable_reactFragments = new Set();
3419 + }
3420 + instance.unstable_reactFragments.add(fragmentInstance);
3421 + }
3422 +}
3423 +
3424 export function createFragmentInstance(
3425 fragmentFiber: Fiber,
3426 ): FragmentInstanceType {
3396 - return new (FragmentInstance: any)(fragmentFiber);
3427 + const fragmentInstance = new (FragmentInstance: any)(fragmentFiber);
3428 + if (enableFragmentRefsInstanceHandles) {
3429 + traverseFragmentInstance(
3430 + fragmentFiber,
3431 + addFragmentHandleToFiber,
3432 + fragmentInstance,
3433 + );
3434 + }
3435 + return fragmentInstance;
3436 }
3437
3438 export function updateFragmentInstanceFiber(
@@ -3404,7 +3443,7 @@ export function updateFragmentInstanceFiber(
3443 }
3444
3445 export function commitNewChildToFragmentInstance(
3407 - childInstance: Instance,
3446 + childInstance: InstanceWithFragmentHandles,
3447 fragmentInstance: FragmentInstanceType,
3448 ): void {
3449 const eventListeners = fragmentInstance._eventListeners;
@@ -3419,17 +3458,25 @@ export function commitNewChildToFragmentInstance(
3458 observer.observe(childInstance);
3459 });
3460 }
3461 + if (enableFragmentRefsInstanceHandles) {
3462 + addFragmentHandleToInstance(childInstance, fragmentInstance);
3463 + }
3464 }
3465
3466 export function deleteChildFromFragmentInstance(
3425 - childElement: Instance,
3467 + childInstance: InstanceWithFragmentHandles,
3468 fragmentInstance: FragmentInstanceType,
3469 ): void {
3470 const eventListeners = fragmentInstance._eventListeners;
3471 if (eventListeners !== null) {
3472 for (let i = 0; i < eventListeners.length; i++) {
3473 const {type, listener, optionsOrUseCapture} = eventListeners[i];
3432 - childElement.removeEventListener(type, listener, optionsOrUseCapture);
3474 + childInstance.removeEventListener(type, listener, optionsOrUseCapture);
3475 + }
3476 + }
3477 + if (enableFragmentRefsInstanceHandles) {
3478 + if (childInstance.unstable_reactFragments != null) {
3479 + childInstance.unstable_reactFragments.delete(fragmentInstance);
3480 }
3481 }
3482 }
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+167
@@ -110,6 +110,53 @@ describe('FragmentRefs', () => {
110 await act(() => root.render(<Test />));
111 });
112
113 + // @gate enableFragmentRefs && enableFragmentRefsInstanceHandles
114 + it('attaches fragment handles to nodes', async () => {
115 + const fragmentParentRef = React.createRef();
116 + const fragmentRef = React.createRef();
117 +
118 + function Test({show}) {
119 + return (
120 + <Fragment ref={fragmentParentRef}>
121 + <Fragment ref={fragmentRef}>
122 + <div id="childA">A</div>
123 + <div id="childB">B</div>
124 + </Fragment>
125 + <div id="childC">C</div>
126 + {show && <div id="childD">D</div>}
127 + </Fragment>
128 + );
129 + }
130 +
131 + const root = ReactDOMClient.createRoot(container);
132 + await act(() => root.render(<Test show={false} />));
133 +
134 + const childA = document.querySelector('#childA');
135 + const childB = document.querySelector('#childB');
136 + const childC = document.querySelector('#childC');
137 +
138 + expect(childA.unstable_reactFragments.has(fragmentRef.current)).toBe(true);
139 + expect(childB.unstable_reactFragments.has(fragmentRef.current)).toBe(true);
140 + expect(childC.unstable_reactFragments.has(fragmentRef.current)).toBe(false);
141 + expect(childA.unstable_reactFragments.has(fragmentParentRef.current)).toBe(
142 + true,
143 + );
144 + expect(childB.unstable_reactFragments.has(fragmentParentRef.current)).toBe(
145 + true,
146 + );
147 + expect(childC.unstable_reactFragments.has(fragmentParentRef.current)).toBe(
148 + true,
149 + );
150 +
151 + await act(() => root.render(<Test show={true} />));
152 +
153 + const childD = document.querySelector('#childD');
154 + expect(childD.unstable_reactFragments.has(fragmentRef.current)).toBe(false);
155 + expect(childD.unstable_reactFragments.has(fragmentParentRef.current)).toBe(
156 + true,
157 + );
158 + });
159 +
160 describe('focus methods', () => {
161 describe('focus()', () => {
162 // @gate enableFragmentRefs
@@ -1045,6 +1092,126 @@ describe('FragmentRefs', () => {
1092 {withoutStack: true},
1093 );
1094 });
1095 +
1096 + // @gate enableFragmentRefs && enableFragmentRefsInstanceHandles
1097 + it('attaches handles to observed elements to allow caching of observers', async () => {
1098 + const targetToCallbackMap = new WeakMap();
1099 + let cachedObserver = null;
1100 + function createObserverIfNeeded(fragmentInstance, onIntersection) {
1101 + const callbacks = targetToCallbackMap.get(fragmentInstance);
1102 + targetToCallbackMap.set(
1103 + fragmentInstance,
1104 + callbacks ? [...callbacks, onIntersection] : [onIntersection],
1105 + );
1106 + if (cachedObserver !== null) {
1107 + return cachedObserver;
1108 + }
1109 + const observer = new IntersectionObserver(entries => {
1110 + entries.forEach(entry => {
1111 + const fragmentInstances = entry.target.unstable_reactFragments;
1112 + if (fragmentInstances) {
1113 + Array.from(fragmentInstances).forEach(fInstance => {
1114 + const cbs = targetToCallbackMap.get(fInstance) || [];
1115 + cbs.forEach(callback => {
1116 + callback(entry);
1117 + });
1118 + });
1119 + }
1120 +
1121 + targetToCallbackMap.get(entry.target)?.forEach(callback => {
1122 + callback(entry);
1123 + });
1124 + });
1125 + });
1126 + cachedObserver = observer;
1127 + return observer;
1128 + }
1129 +
1130 + function IntersectionObserverFragment({onIntersection, children}) {
1131 + const fragmentRef = React.useRef(null);
1132 + React.useLayoutEffect(() => {
1133 + const observer = createObserverIfNeeded(
1134 + fragmentRef.current,
1135 + onIntersection,
1136 + );
1137 + fragmentRef.current.observeUsing(observer);
1138 + const lastRefValue = fragmentRef.current;
1139 + return () => {
1140 + lastRefValue.unobserveUsing(observer);
1141 + };
1142 + }, []);
1143 + return <React.Fragment ref={fragmentRef}>{children}</React.Fragment>;
1144 + }
1145 +
1146 + let logs = [];
1147 + function logIntersection(id) {
1148 + logs.push(`observe: ${id}`);
1149 + }
1150 +
1151 + function ChildWithManualIO({id}) {
1152 + const divRef = React.useRef(null);
1153 + React.useLayoutEffect(() => {
1154 + const observer = createObserverIfNeeded(divRef.current, entry => {
1155 + logIntersection(id);
1156 + });
1157 + observer.observe(divRef.current);
1158 + return () => {
1159 + observer.unobserve(divRef.current);
1160 + };
1161 + }, []);
1162 + return (
1163 + <div id={id} ref={divRef}>
1164 + {id}
1165 + </div>
1166 + );
1167 + }
1168 +
1169 + function Test() {
1170 + return (
1171 + <>
1172 + <IntersectionObserverFragment
1173 + onIntersection={() => logIntersection('grandparent')}>
1174 + <IntersectionObserverFragment
1175 + onIntersection={() => logIntersection('parentA')}>
1176 + <div id="childA">A</div>
1177 + </IntersectionObserverFragment>
1178 + </IntersectionObserverFragment>
1179 + <IntersectionObserverFragment
1180 + onIntersection={() => logIntersection('parentB')}>
1181 + <div id="childB">B</div>
1182 + <ChildWithManualIO id="childC" />
1183 + </IntersectionObserverFragment>
1184 + </>
1185 + );
1186 + }
1187 +
1188 + const root = ReactDOMClient.createRoot(container);
1189 + await act(() => root.render(<Test />));
1190 +
1191 + simulateIntersection([
1192 + container.querySelector('#childA'),
1193 + {y: 0, x: 0, width: 1, height: 1},
1194 + 1,
1195 + ]);
1196 + expect(logs).toEqual(['observe: grandparent', 'observe: parentA']);
1197 +
1198 + logs = [];
1199 +
1200 + simulateIntersection([
1201 + container.querySelector('#childB'),
1202 + {y: 0, x: 0, width: 1, height: 1},
1203 + 1,
1204 + ]);
1205 + expect(logs).toEqual(['observe: parentB']);
1206 +
1207 + logs = [];
1208 + simulateIntersection([
1209 + container.querySelector('#childC'),
1210 + {y: 0, x: 0, width: 1, height: 1},
1211 + 1,
1212 + ]);
1213 + expect(logs).toEqual(['observe: parentB', 'observe: childC']);
1214 + });
1215 });
1216
1217 describe('getClientRects', () => {
packages/react-native-renderer/src/ReactFiberConfigFabric.js
+55 -3
@@ -40,6 +40,7 @@ import {
40 type PublicTextInstance,
41 type PublicRootInstance,
42 } from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
43 +import {enableFragmentRefsInstanceHandles} from 'shared/ReactFeatureFlags';
44
45 const {
46 createNode,
@@ -119,6 +120,9 @@ export type TextInstance = {
120 };
121 export type HydratableInstance = Instance | TextInstance;
122 export type PublicInstance = ReactNativePublicInstance;
123 +type PublicInstanceWithFragmentHandles = PublicInstance & {
124 + unstable_reactFragments?: Set<FragmentInstanceType>,
125 +};
126 export type Container = {
127 containerTag: number,
128 publicInstance: PublicRootInstance | null,
@@ -794,10 +798,45 @@ function collectClientRects(child: Fiber, rects: Array<DOMRect>): boolean {
798 return false;
799 }
800
801 +function addFragmentHandleToFiber(
802 + child: Fiber,
803 + fragmentInstance: FragmentInstanceType,
804 +): boolean {
805 + if (enableFragmentRefsInstanceHandles) {
806 + const instance = ((getPublicInstanceFromHostFiber(
807 + child,
808 + ): any): PublicInstanceWithFragmentHandles);
809 + if (instance != null) {
810 + addFragmentHandleToInstance(instance, fragmentInstance);
811 + }
812 + }
813 + return false;
814 +}
815 +
816 +function addFragmentHandleToInstance(
817 + instance: PublicInstanceWithFragmentHandles,
818 + fragmentInstance: FragmentInstanceType,
819 +): void {
820 + if (enableFragmentRefsInstanceHandles) {
821 + if (instance.unstable_reactFragments == null) {
822 + instance.unstable_reactFragments = new Set();
823 + }
824 + instance.unstable_reactFragments.add(fragmentInstance);
825 + }
826 +}
827 +
828 export function createFragmentInstance(
829 fragmentFiber: Fiber,
830 ): FragmentInstanceType {
800 - return new (FragmentInstance: any)(fragmentFiber);
831 + const fragmentInstance = new (FragmentInstance: any)(fragmentFiber);
832 + if (enableFragmentRefsInstanceHandles) {
833 + traverseFragmentInstance(
834 + fragmentFiber,
835 + addFragmentHandleToFiber,
836 + fragmentInstance,
837 + );
838 + }
839 + return fragmentInstance;
840 }
841
842 export function updateFragmentInstanceFiber(
@@ -821,13 +860,26 @@ export function commitNewChildToFragmentInstance(
860 observer.observe(publicInstance);
861 });
862 }
863 + if (enableFragmentRefsInstanceHandles) {
864 + addFragmentHandleToInstance(
865 + ((publicInstance: any): PublicInstanceWithFragmentHandles),
866 + fragmentInstance,
867 + );
868 + }
869 }
870
871 export function deleteChildFromFragmentInstance(
827 - child: Instance,
872 + childInstance: Instance,
873 fragmentInstance: FragmentInstanceType,
874 ): void {
830 - // Noop
875 + const publicInstance = ((getPublicInstance(
876 + childInstance,
877 + ): any): PublicInstanceWithFragmentHandles);
878 + if (enableFragmentRefsInstanceHandles) {
879 + if (publicInstance.unstable_reactFragments != null) {
880 + publicInstance.unstable_reactFragments.delete(fragmentInstance);
881 + }
882 + }
883 }
884
885 export const NotPendingTransition: TransitionStatus = null;
packages/shared/ReactFeatureFlags.js
+1
@@ -147,6 +147,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
147
148 export const enableFragmentRefs: boolean = true;
149 export const enableFragmentRefsScrollIntoView: boolean = true;
150 +export const enableFragmentRefsInstanceHandles: boolean = false;
151
152 // -----------------------------------------------------------------------------
153 // Ready for next major.
packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
+1
@@ -25,4 +25,5 @@ export const passChildrenWhenCloningPersistedNodes = __VARIANT__;
25 export const renameElementSymbol = __VARIANT__;
26 export const enableFragmentRefs = __VARIANT__;
27 export const enableFragmentRefsScrollIntoView = __VARIANT__;
28 +export const enableFragmentRefsInstanceHandles = __VARIANT__;
29 export const enableComponentPerformanceTrack = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -27,6 +27,7 @@ export const {
27 renameElementSymbol,
28 enableFragmentRefs,
29 enableFragmentRefsScrollIntoView,
30 + enableFragmentRefsInstanceHandles,
31 } = dynamicFlags;
32
33 // The rest of the flags are static for better dead code elimination.
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -74,6 +74,7 @@ export const ownerStackLimit = 1e4;
74
75 export const enableFragmentRefs: boolean = true;
76 export const enableFragmentRefsScrollIntoView: boolean = false;
77 +export const enableFragmentRefsInstanceHandles: boolean = false;
78
79 // Profiling Only
80 export const enableProfilerTimer: boolean = __PROFILE__;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -75,6 +75,7 @@ export const ownerStackLimit = 1e4;
75
76 export const enableFragmentRefs: boolean = true;
77 export const enableFragmentRefsScrollIntoView: boolean = true;
78 +export const enableFragmentRefsInstanceHandles: boolean = false;
79
80 // TODO: This must be in sync with the main ReactFeatureFlags file because
81 // the Test Renderer's value must be the same as the one used by the
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -82,6 +82,7 @@ export const enableDefaultTransitionIndicator: boolean = true;
82
83 export const enableFragmentRefs: boolean = false;
84 export const enableFragmentRefsScrollIntoView: boolean = false;
85 +export const enableFragmentRefsInstanceHandles: boolean = false;
86 export const ownerStackLimit = 1e4;
87
88 // Flow magic to verify the exports of this file match the original version.
packages/shared/forks/ReactFeatureFlags.www.js
+2
@@ -111,5 +111,7 @@ export const enableDefaultTransitionIndicator: boolean = true;
111
112 export const ownerStackLimit = 1e4;
113
114 +export const enableFragmentRefsInstanceHandles: boolean = true;
115 +
116 // Flow magic to verify the exports of this file match the original version.
117 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);