Add Fragment Refs to Fabric with intersection observer support (#33056)
Adds Fragment Ref support to RN through the Fabric config, starting with `observeUsing`/`unobserveUsing`. This is mostly a copy from the implementation on DOM, and some of it can likely be shared in the future but keeping it separate for now and we can refactor as we add more features. Added a basic test with Fabric, but testing specific methods requires so much mocking that it doesn't seem valuable here. I built Fabric and ran on the Catalyst app internally to test with intersection observers end to end.
Jack Pope committed
Apr 30, 2025 at 10:47 UTC
408d055a3b89794088130ed39bf42ca540766275
6 files changed
+159
-12
packages/react-native-renderer/src/ReactFiberConfigFabric.js
+68
-6
@@ -24,6 +24,7 @@ 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';
28
29
// Modules provided by RN:
30
import {
@@ -622,30 +623,91 @@ export function waitForCommitToBeReady(): null {
623
return null;
624
}
625
625
-export type FragmentInstanceType = null;
626
+export type FragmentInstanceType = {
627
+ _fragmentFiber: Fiber,
628
+ _observers: null | Set<IntersectionObserver>,
629
+ observeUsing: (observer: IntersectionObserver) => void,
630
+ unobserveUsing: (observer: IntersectionObserver) => void,
631
+};
632
+
633
+function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
634
+ this._fragmentFiber = fragmentFiber;
635
+ this._observers = null;
636
+}
637
+
638
+// $FlowFixMe[prop-missing]
639
+FragmentInstance.prototype.observeUsing = function (
640
+ this: FragmentInstanceType,
641
+ observer: IntersectionObserver,
642
+): void {
643
+ if (this._observers === null) {
644
+ this._observers = new Set();
645
+ }
646
+ this._observers.add(observer);
647
+ traverseFragmentInstance(this._fragmentFiber, observeChild, observer);
648
+};
649
+function observeChild(instance: Instance, observer: IntersectionObserver) {
650
+ const publicInstance = getPublicInstance(instance);
651
+ if (publicInstance == null) {
652
+ throw new Error('Expected to find a host node. This is a bug in React.');
653
+ }
654
+ // $FlowFixMe[incompatible-call] Element types are behind a flag in RN
655
+ observer.observe(publicInstance);
656
+ return false;
657
+}
658
+// $FlowFixMe[prop-missing]
659
+FragmentInstance.prototype.unobserveUsing = function (
660
+ this: FragmentInstanceType,
661
+ observer: IntersectionObserver,
662
+): void {
663
+ if (this._observers === null || !this._observers.has(observer)) {
664
+ if (__DEV__) {
665
+ console.error(
666
+ 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' +
667
+ 'instance. First attach the observer with observeUsing()',
668
+ );
669
+ }
670
+ } else {
671
+ this._observers.delete(observer);
672
+ traverseFragmentInstance(this._fragmentFiber, unobserveChild, observer);
673
+ }
674
+};
675
+function unobserveChild(instance: Instance, observer: IntersectionObserver) {
676
+ const publicInstance = getPublicInstance(instance);
677
+ if (publicInstance == null) {
678
+ throw new Error('Expected to find a host node. This is a bug in React.');
679
+ }
680
+ // $FlowFixMe[incompatible-call] Element types are behind a flag in RN
681
+ observer.unobserve(publicInstance);
682
+ return false;
683
+}
684
685
export function createFragmentInstance(
686
fragmentFiber: Fiber,
687
): FragmentInstanceType {
630
- return null;
688
+ return new (FragmentInstance: any)(fragmentFiber);
689
}
690
691
export function updateFragmentInstanceFiber(
692
fragmentFiber: Fiber,
693
instance: FragmentInstanceType,
694
): void {
637
- // Noop
695
+ instance._fragmentFiber = fragmentFiber;
696
}
697
698
export function commitNewChildToFragmentInstance(
641
- child: PublicInstance,
699
+ child: Instance,
700
fragmentInstance: FragmentInstanceType,
701
): void {
644
- // Noop
702
+ if (fragmentInstance._observers !== null) {
703
+ fragmentInstance._observers.forEach(observer => {
704
+ observeChild(child, observer);
705
+ });
706
+ }
707
}
708
709
export function deleteChildFromFragmentInstance(
648
- child: PublicInstance,
710
+ child: Instance,
711
fragmentInstance: FragmentInstanceType,
712
): void {
713
// Noop
packages/react-native-renderer/src/__tests__/ReactFabricFragmentRefs-test.internal.js
new
+83
@@ -0,0 +1,83 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @emails react-core
8
+ * @jest-environment node
9
+ */
10
+
11
+'use strict';
12
+
13
+let React;
14
+let ReactFabric;
15
+let createReactNativeComponentClass;
16
+let act;
17
+let View;
18
+let Text;
19
+
20
+describe('Fabric FragmentRefs', () => {
21
+ beforeEach(() => {
22
+ jest.resetModules();
23
+
24
+ require('react-native/Libraries/ReactPrivate/InitializeNativeFabricUIManager');
25
+
26
+ React = require('react');
27
+ ReactFabric = require('react-native-renderer/fabric');
28
+ createReactNativeComponentClass =
29
+ require('react-native/Libraries/ReactPrivate/ReactNativePrivateInterface')
30
+ .ReactNativeViewConfigRegistry.register;
31
+ ({act} = require('internal-test-utils'));
32
+ View = createReactNativeComponentClass('RCTView', () => ({
33
+ validAttributes: {nativeID: true},
34
+ uiViewClassName: 'RCTView',
35
+ }));
36
+ Text = createReactNativeComponentClass('RCTText', () => ({
37
+ validAttributes: {nativeID: true},
38
+ uiViewClassName: 'RCTText',
39
+ }));
40
+ });
41
+
42
+ // @gate enableFragmentRefs
43
+ it('attaches a ref to Fragment', async () => {
44
+ const fragmentRef = React.createRef();
45
+
46
+ await act(() =>
47
+ ReactFabric.render(
48
+ <View>
49
+ <React.Fragment ref={fragmentRef}>
50
+ <View>
51
+ <Text>Hi</Text>
52
+ </View>
53
+ </React.Fragment>
54
+ </View>,
55
+ 11,
56
+ null,
57
+ true,
58
+ ),
59
+ );
60
+
61
+ expect(fragmentRef.current).not.toBe(null);
62
+ });
63
+
64
+ // @gate enableFragmentRefs
65
+ it('accepts a ref callback', async () => {
66
+ let fragmentRef;
67
+
68
+ await act(() => {
69
+ ReactFabric.render(
70
+ <React.Fragment ref={ref => (fragmentRef = ref)}>
71
+ <View nativeID="child">
72
+ <Text>Hi</Text>
73
+ </View>
74
+ </React.Fragment>,
75
+ 11,
76
+ null,
77
+ true,
78
+ );
79
+ });
80
+
81
+ expect(fragmentRef && fragmentRef._fragmentFiber).toBeTruthy();
82
+ });
83
+});
packages/react-reconciler/src/ReactFiberTreeReflection.js
+4
-4
@@ -345,9 +345,9 @@ export function doesFiberContain(
345
return false;
346
}
347
348
-export function traverseFragmentInstance<A, B, C>(
348
+export function traverseFragmentInstance<I, A, B, C>(
349
fragmentFiber: Fiber,
350
- fn: (Instance, A, B, C) => boolean,
350
+ fn: (I, A, B, C) => boolean,
351
a: A,
352
b: B,
353
c: C,
@@ -355,9 +355,9 @@ export function traverseFragmentInstance<A, B, C>(
355
traverseFragmentInstanceChildren(fragmentFiber.child, fn, a, b, c);
356
}
357
358
-function traverseFragmentInstanceChildren<A, B, C>(
358
+function traverseFragmentInstanceChildren<I, A, B, C>(
359
child: Fiber | null,
360
- fn: (Instance, A, B, C) => boolean,
360
+ fn: (I, A, B, C) => boolean,
361
a: A,
362
b: B,
363
c: C,
packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
+1
@@ -28,3 +28,4 @@ export const enableSiblingPrerendering = __VARIANT__;
28
export const enableFastAddPropertiesInDiffing = __VARIANT__;
29
export const enableLazyPublicInstanceInFabric = __VARIANT__;
30
export const renameElementSymbol = __VARIANT__;
31
+export const enableFragmentRefs = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
-1
@@ -30,6 +30,7 @@ export const {
30
enableFastAddPropertiesInDiffing,
31
enableLazyPublicInstanceInFabric,
32
renameElementSymbol,
33
+ enableFragmentRefs,
34
} = dynamicFlags;
35
36
// The rest of the flags are static for better dead code elimination.
@@ -84,7 +85,6 @@ export const enableGestureTransition = false;
85
export const enableScrollEndPolyfill = true;
86
export const enableSuspenseyImages = false;
87
export const enableSrcObject = false;
87
-export const enableFragmentRefs = false;
88
export const ownerStackLimit = 1e4;
89
90
// Flow magic to verify the exports of this file match the original version.
scripts/error-codes/codes.json
+2
-1
@@ -543,5 +543,6 @@
543
"555": "Cannot requestFormReset() inside a startGestureTransition. There should be no side-effects associated with starting a Gesture until its Action is invoked. Move side-effects to the Action instead.",
544
"556": "Expected prepareToHydrateHostActivityInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.",
545
"557": "Expected to have a hydrated activity instance. This error is likely caused by a bug in React. Please file an issue.",
546
- "558": "Client rendering an Activity suspended it again. This is a bug in React."
546
+ "558": "Client rendering an Activity suspended it again. This is a bug in React.",
547
+ "559": "Expected to find a host node. This is a bug in React."
548
}