Skip to content

Commit edd05f1

Browse files
authored
Add fragment handles to children of FragmentInstances (facebook#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.
1 parent 67f7d47 commit edd05f1

File tree

10 files changed

+281
-7
lines changed

10 files changed

+281
-7
lines changed

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ import {
126126
enableHydrationChangeEvent,
127127
enableFragmentRefsScrollIntoView,
128128
enableProfilerTimer,
129+
enableFragmentRefsInstanceHandles,
129130
} from 'shared/ReactFeatureFlags';
130131
import {
131132
HostComponent,
@@ -214,6 +215,10 @@ export type Container =
214215
export type Instance = Element;
215216
export type TextInstance = Text;
216217

218+
type InstanceWithFragmentHandles = Instance & {
219+
unstable_reactFragments?: Set<FragmentInstanceType>,
220+
};
221+
217222
declare class ActivityInterface extends Comment {}
218223
declare class SuspenseInterface extends Comment {
219224
_reactRetry: void | (() => void);
@@ -3390,10 +3395,44 @@ if (enableFragmentRefsScrollIntoView) {
33903395
};
33913396
}
33923397

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+
33933424
export function createFragmentInstance(
33943425
fragmentFiber: Fiber,
33953426
): 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;
33973436
}
33983437

33993438
export function updateFragmentInstanceFiber(
@@ -3404,7 +3443,7 @@ export function updateFragmentInstanceFiber(
34043443
}
34053444

34063445
export function commitNewChildToFragmentInstance(
3407-
childInstance: Instance,
3446+
childInstance: InstanceWithFragmentHandles,
34083447
fragmentInstance: FragmentInstanceType,
34093448
): void {
34103449
const eventListeners = fragmentInstance._eventListeners;
@@ -3419,17 +3458,25 @@ export function commitNewChildToFragmentInstance(
34193458
observer.observe(childInstance);
34203459
});
34213460
}
3461+
if (enableFragmentRefsInstanceHandles) {
3462+
addFragmentHandleToInstance(childInstance, fragmentInstance);
3463+
}
34223464
}
34233465

34243466
export function deleteChildFromFragmentInstance(
3425-
childElement: Instance,
3467+
childInstance: InstanceWithFragmentHandles,
34263468
fragmentInstance: FragmentInstanceType,
34273469
): void {
34283470
const eventListeners = fragmentInstance._eventListeners;
34293471
if (eventListeners !== null) {
34303472
for (let i = 0; i < eventListeners.length; i++) {
34313473
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);
34333480
}
34343481
}
34353482
}

packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,53 @@ describe('FragmentRefs', () => {
110110
await act(() => root.render(<Test />));
111111
});
112112

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+
113160
describe('focus methods', () => {
114161
describe('focus()', () => {
115162
// @gate enableFragmentRefs
@@ -1045,6 +1092,126 @@ describe('FragmentRefs', () => {
10451092
{withoutStack: true},
10461093
);
10471094
});
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+
});
10481215
});
10491216

10501217
describe('getClientRects', () => {

packages/react-native-renderer/src/ReactFiberConfigFabric.js

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
type PublicTextInstance,
4141
type PublicRootInstance,
4242
} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
43+
import {enableFragmentRefsInstanceHandles} from 'shared/ReactFeatureFlags';
4344

4445
const {
4546
createNode,
@@ -119,6 +120,9 @@ export type TextInstance = {
119120
};
120121
export type HydratableInstance = Instance | TextInstance;
121122
export type PublicInstance = ReactNativePublicInstance;
123+
type PublicInstanceWithFragmentHandles = PublicInstance & {
124+
unstable_reactFragments?: Set<FragmentInstanceType>,
125+
};
122126
export type Container = {
123127
containerTag: number,
124128
publicInstance: PublicRootInstance | null,
@@ -794,10 +798,45 @@ function collectClientRects(child: Fiber, rects: Array<DOMRect>): boolean {
794798
return false;
795799
}
796800

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+
797828
export function createFragmentInstance(
798829
fragmentFiber: Fiber,
799830
): 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;
801840
}
802841

803842
export function updateFragmentInstanceFiber(
@@ -821,13 +860,26 @@ export function commitNewChildToFragmentInstance(
821860
observer.observe(publicInstance);
822861
});
823862
}
863+
if (enableFragmentRefsInstanceHandles) {
864+
addFragmentHandleToInstance(
865+
((publicInstance: any): PublicInstanceWithFragmentHandles),
866+
fragmentInstance,
867+
);
868+
}
824869
}
825870

826871
export function deleteChildFromFragmentInstance(
827-
child: Instance,
872+
childInstance: Instance,
828873
fragmentInstance: FragmentInstanceType,
829874
): 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+
}
831883
}
832884

833885
export const NotPendingTransition: TransitionStatus = null;

packages/shared/ReactFeatureFlags.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
147147

148148
export const enableFragmentRefs: boolean = true;
149149
export const enableFragmentRefsScrollIntoView: boolean = true;
150+
export const enableFragmentRefsInstanceHandles: boolean = false;
150151

151152
// -----------------------------------------------------------------------------
152153
// Ready for next major.

packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ export const passChildrenWhenCloningPersistedNodes = __VARIANT__;
2525
export const renameElementSymbol = __VARIANT__;
2626
export const enableFragmentRefs = __VARIANT__;
2727
export const enableFragmentRefsScrollIntoView = __VARIANT__;
28+
export const enableFragmentRefsInstanceHandles = __VARIANT__;
2829
export const enableComponentPerformanceTrack = __VARIANT__;

packages/shared/forks/ReactFeatureFlags.native-fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const {
2727
renameElementSymbol,
2828
enableFragmentRefs,
2929
enableFragmentRefsScrollIntoView,
30+
enableFragmentRefsInstanceHandles,
3031
} = dynamicFlags;
3132

3233
// The rest of the flags are static for better dead code elimination.

0 commit comments

Comments
 (0)