Skip to content

Commit e6516f2

Browse files
RSNarafacebook-github-bot
authored andcommitted
IntersectionObserver: Migrate js infra to shadow node family (#51148)
Summary: Pull Request resolved: #51148 Intersection observer should not be holding on to shadow nodes. This diff migrates the javascript infra to instead use families. Changelog: [Internal] Reviewed By: yungsters Differential Revision: D74262804 fbshipit-source-id: cc090be54f7312ce32b853ddf86567bb43e676b8
1 parent a4be563 commit e6516f2

8 files changed

Lines changed: 157 additions & 27 deletions

File tree

packages/react-native/ReactCommon/react/nativemodule/intersectionobserver/NativeIntersectionObserver.cpp

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,35 +21,76 @@ NativeIntersectionObserverModuleProvider(
2121

2222
namespace facebook::react {
2323

24+
namespace {
25+
26+
jsi::Object tokenFromShadowNodeFamily(
27+
jsi::Runtime& runtime,
28+
ShadowNodeFamily::Shared shadowNodeFamily) {
29+
jsi::Object obj(runtime);
30+
// Need to const_cast since JSI only allows non-const pointees
31+
obj.setNativeState(
32+
runtime,
33+
std::const_pointer_cast<ShadowNodeFamily>(std::move(shadowNodeFamily)));
34+
return obj;
35+
}
36+
37+
ShadowNodeFamily::Shared shadowNodeFamilyFromToken(
38+
jsi::Runtime& runtime,
39+
jsi::Object token) {
40+
return token.getNativeState<ShadowNodeFamily>(runtime);
41+
}
42+
43+
} // namespace
44+
2445
NativeIntersectionObserver::NativeIntersectionObserver(
2546
std::shared_ptr<CallInvoker> jsInvoker)
2647
: NativeIntersectionObserverCxxSpec(std::move(jsInvoker)) {}
2748

2849
void NativeIntersectionObserver::observe(
2950
jsi::Runtime& runtime,
3051
NativeIntersectionObserverObserveOptions options) {
52+
observeV2(runtime, std::move(options));
53+
}
54+
55+
void NativeIntersectionObserver::unobserve(
56+
jsi::Runtime& runtime,
57+
IntersectionObserverObserverId intersectionObserverId,
58+
jsi::Object targetShadowNode) {
59+
auto shadowNode = shadowNodeFromValue(runtime, std::move(targetShadowNode));
60+
auto token =
61+
tokenFromShadowNodeFamily(runtime, shadowNode->getFamilyShared());
62+
unobserveV2(runtime, intersectionObserverId, std::move(token));
63+
}
64+
65+
jsi::Object NativeIntersectionObserver::observeV2(
66+
jsi::Runtime& runtime,
67+
NativeIntersectionObserverObserveOptions options) {
3168
auto intersectionObserverId = options.intersectionObserverId;
3269
auto shadowNode =
3370
shadowNodeFromValue(runtime, std::move(options.targetShadowNode));
71+
auto shadowNodeFamily = shadowNode->getFamilyShared();
3472
auto thresholds = options.thresholds;
3573
auto rootThresholds = options.rootThresholds;
3674
auto& uiManager = getUIManagerFromRuntime(runtime);
3775

3876
intersectionObserverManager_.observe(
3977
intersectionObserverId,
40-
shadowNode->getFamilyShared(),
78+
shadowNodeFamily,
4179
thresholds,
4280
rootThresholds,
4381
uiManager);
82+
83+
return tokenFromShadowNodeFamily(runtime, shadowNodeFamily);
4484
}
4585

46-
void NativeIntersectionObserver::unobserve(
86+
void NativeIntersectionObserver::unobserveV2(
4787
jsi::Runtime& runtime,
4888
IntersectionObserverObserverId intersectionObserverId,
49-
jsi::Object targetShadowNode) {
50-
auto shadowNode = shadowNodeFromValue(runtime, std::move(targetShadowNode));
89+
jsi::Object targetToken) {
90+
auto shadowNodeFamily =
91+
shadowNodeFamilyFromToken(runtime, std::move(targetToken));
5192
intersectionObserverManager_.unobserve(
52-
intersectionObserverId, shadowNode->getFamilyShared());
93+
intersectionObserverId, shadowNodeFamily);
5394
}
5495

5596
void NativeIntersectionObserver::connect(

packages/react-native/ReactCommon/react/nativemodule/intersectionobserver/NativeIntersectionObserver.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,28 @@ class NativeIntersectionObserver
6161
public:
6262
NativeIntersectionObserver(std::shared_ptr<CallInvoker> jsInvoker);
6363

64+
// TODO(T223605846): Remove legacy observe method
65+
[[deprecated("Please use observeV2")]]
6466
void observe(
6567
jsi::Runtime& runtime,
6668
NativeIntersectionObserverObserveOptions options);
6769

70+
// TODO(T223605846): Remove legacy unobserve method
71+
[[deprecated("Please use unobserveV2")]]
6872
void unobserve(
6973
jsi::Runtime& runtime,
7074
IntersectionObserverObserverId intersectionObserverId,
7175
jsi::Object targetShadowNode);
7276

77+
jsi::Object observeV2(
78+
jsi::Runtime& runtime,
79+
NativeIntersectionObserverObserveOptions options);
80+
81+
void unobserveV2(
82+
jsi::Runtime& runtime,
83+
IntersectionObserverObserverId intersectionObserverId,
84+
jsi::Object targetToken);
85+
7386
void connect(
7487
jsi::Runtime& runtime,
7588
AsyncCallback<> notifyIntersectionObserversCallback);

packages/react-native/ReactCommon/react/renderer/core/ShadowNodeFamily.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ struct ShadowNodeFamilyFragment {
3939
* Represents all things that shadow nodes from the same family have in common.
4040
* To be used inside `ShadowNode` class *only*.
4141
*/
42-
class ShadowNodeFamily final {
42+
class ShadowNodeFamily final : public jsi::NativeState {
4343
public:
4444
using Shared = std::shared_ptr<const ShadowNodeFamily>;
4545
using Weak = std::weak_ptr<const ShadowNodeFamily>;

packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,16 @@ const definitions: FeatureFlagDefinitions = {
663663
},
664664
ossReleaseStage: 'none',
665665
},
666+
utilizeTokensInIntersectionObserver: {
667+
defaultValue: true,
668+
metadata: {
669+
dateAdded: '2025-05-06',
670+
description: 'Use tokens in IntersectionObserver vs ShadowNode.',
671+
expectedReleaseValue: true,
672+
purpose: 'experimentation',
673+
},
674+
ossReleaseStage: 'none',
675+
},
666676
},
667677
};
668678

packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<6667fc8e4fdd2db0b54c908155b110cf>>
7+
* @generated SignedSource<<17fa5e03fe52ed129cf731bba6e9869c>>
88
* @flow strict
99
*/
1010

@@ -39,6 +39,7 @@ export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{
3939
shouldUseAnimatedObjectForTransform: Getter<boolean>,
4040
shouldUseRemoveClippedSubviewsAsDefaultOnIOS: Getter<boolean>,
4141
shouldUseSetNativePropsInFabric: Getter<boolean>,
42+
utilizeTokensInIntersectionObserver: Getter<boolean>,
4243
}>;
4344

4445
export type ReactNativeFeatureFlagsJsOnlyOverrides = OverridesFor<ReactNativeFeatureFlagsJsOnly>;
@@ -155,6 +156,11 @@ export const shouldUseRemoveClippedSubviewsAsDefaultOnIOS: Getter<boolean> = cre
155156
*/
156157
export const shouldUseSetNativePropsInFabric: Getter<boolean> = createJavaScriptFlagGetter('shouldUseSetNativePropsInFabric', true);
157158

159+
/**
160+
* Use tokens in IntersectionObserver vs ShadowNode.
161+
*/
162+
export const utilizeTokensInIntersectionObserver: Getter<boolean> = createJavaScriptFlagGetter('utilizeTokensInIntersectionObserver', true);
163+
158164
/**
159165
* Common flag for testing. Do NOT modify.
160166
*/

packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* @flow strict-local
88
* @format
99
* @oncall react_native
10+
* @fantom_flags utilizeTokensInIntersectionObserver:true
1011
*/
1112

1213
import 'react-native/Libraries/Core/InitializeCore';
@@ -844,8 +845,7 @@ describe('IntersectionObserver', () => {
844845
});
845846
});
846847

847-
// TODO (T223234714): Fix memory leak and enable this test.
848-
it.skip('should not retain initial children of observed targets', () => {
848+
it('should not retain initial children of observed targets', () => {
849849
const root = Fantom.createRoot();
850850
observer = new IntersectionObserver(() => {});
851851

packages/react-native/src/private/webapis/intersectionobserver/internals/IntersectionObserverManager.js

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ import type IntersectionObserver, {
2323
IntersectionObserverCallback,
2424
} from '../IntersectionObserver';
2525
import type IntersectionObserverEntry from '../IntersectionObserverEntry';
26+
import type {NativeIntersectionObserverToken} from '../specs/NativeIntersectionObserver';
2627

2728
import * as Systrace from '../../../../../Libraries/Performance/Systrace';
2829
import warnOnce from '../../../../../Libraries/Utilities/warnOnce';
30+
import * as ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags';
2931
import {
3032
getInstanceHandle,
3133
getNativeNodeReference,
@@ -74,6 +76,26 @@ const targetToShadowNodeMap: WeakMap<
7476
ReturnType<typeof getNativeNodeReference>,
7577
> = new WeakMap();
7678

79+
const targetToTokenMap: WeakMap<
80+
ReactNativeElement,
81+
NativeIntersectionObserverToken,
82+
> = new WeakMap();
83+
84+
let modernNativeIntersectionObserver =
85+
NativeIntersectionObserver == null
86+
? null
87+
: NativeIntersectionObserver.observeV2 == null ||
88+
NativeIntersectionObserver.unobserveV2 == null
89+
? null
90+
: {
91+
observe: NativeIntersectionObserver.observeV2,
92+
unobserve: NativeIntersectionObserver.unobserveV2,
93+
};
94+
95+
if (!ReactNativeFeatureFlags.utilizeTokensInIntersectionObserver()) {
96+
modernNativeIntersectionObserver = null;
97+
}
98+
7799
/**
78100
* Registers the given intersection observer and returns a unique ID for it,
79101
* which is required to start observing targets.
@@ -153,20 +175,32 @@ export function observe({
153175
// access it even after the instance handle has been unmounted.
154176
setTargetForInstanceHandle(instanceHandle, target);
155177

156-
// Same for the mapping between the target and its shadow node.
157-
targetToShadowNodeMap.set(target, targetNativeNodeReference);
178+
if (modernNativeIntersectionObserver == null) {
179+
// Same for the mapping between the target and its shadow node.
180+
targetToShadowNodeMap.set(target, targetNativeNodeReference);
181+
}
158182

159183
if (!isConnected) {
160184
NativeIntersectionObserver.connect(notifyIntersectionObservers);
161185
isConnected = true;
162186
}
163187

164-
NativeIntersectionObserver.observe({
165-
intersectionObserverId,
166-
targetShadowNode: targetNativeNodeReference,
167-
thresholds: registeredObserver.observer.thresholds,
168-
rootThresholds: registeredObserver.observer.rnRootThresholds,
169-
});
188+
if (modernNativeIntersectionObserver == null) {
189+
NativeIntersectionObserver.observe({
190+
intersectionObserverId,
191+
targetShadowNode: targetNativeNodeReference,
192+
thresholds: registeredObserver.observer.thresholds,
193+
rootThresholds: registeredObserver.observer.rnRootThresholds,
194+
});
195+
} else {
196+
const token = modernNativeIntersectionObserver.observe({
197+
intersectionObserverId,
198+
targetShadowNode: targetNativeNodeReference,
199+
thresholds: registeredObserver.observer.thresholds,
200+
rootThresholds: registeredObserver.observer.rnRootThresholds,
201+
});
202+
targetToTokenMap.set(target, token);
203+
}
170204

171205
return true;
172206
}
@@ -190,18 +224,33 @@ export function unobserve(
190224
return;
191225
}
192226

193-
const targetNativeNodeReference = targetToShadowNodeMap.get(target);
194-
if (targetNativeNodeReference == null) {
195-
console.error(
196-
'IntersectionObserverManager: could not find registration data for target',
227+
if (modernNativeIntersectionObserver == null) {
228+
const targetNativeNodeReference = targetToShadowNodeMap.get(target);
229+
if (targetNativeNodeReference == null) {
230+
console.error(
231+
'IntersectionObserverManager: could not find registration data for target',
232+
);
233+
return;
234+
}
235+
236+
NativeIntersectionObserver.unobserve(
237+
intersectionObserverId,
238+
targetNativeNodeReference,
197239
);
198-
return;
199-
}
240+
} else {
241+
const targetToken = targetToTokenMap.get(target);
242+
if (targetToken == null) {
243+
console.error(
244+
'IntersectionObserverManager: could not find registration data for target',
245+
);
246+
return;
247+
}
200248

201-
NativeIntersectionObserver.unobserve(
202-
intersectionObserverId,
203-
targetNativeNodeReference,
204-
);
249+
modernNativeIntersectionObserver.unobserve(
250+
intersectionObserverId,
251+
targetToken,
252+
);
253+
}
205254
}
206255

207256
/**

packages/react-native/src/private/webapis/intersectionobserver/specs/NativeIntersectionObserver.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,20 @@ export type NativeIntersectionObserverObserveOptions = {
3030
rootThresholds?: ?$ReadOnlyArray<number>,
3131
};
3232

33+
export type NativeIntersectionObserverToken = mixed;
34+
3335
export interface Spec extends TurboModule {
36+
// TODO(T223605846): Remove legacy observe method
3437
+observe: (options: NativeIntersectionObserverObserveOptions) => void;
38+
// TODO(T223605846): Remove legacy unobserve method
3539
+unobserve: (intersectionObserverId: number, targetShadowNode: mixed) => void;
40+
+observeV2?: (
41+
options: NativeIntersectionObserverObserveOptions,
42+
) => NativeIntersectionObserverToken;
43+
+unobserveV2?: (
44+
intersectionObserverId: number,
45+
token: NativeIntersectionObserverToken,
46+
) => void;
3647
+connect: (notifyIntersectionObserversCallback: () => void) => void;
3748
+disconnect: () => void;
3849
+takeRecords: () => $ReadOnlyArray<NativeIntersectionObserverEntry>;

0 commit comments

Comments
 (0)