Skip to content

Commit 9b20b33

Browse files
committed
Collect orphaned AnimatedPropsRegistry entry on last animated view unmount
An in-flight worklet frame can re-add an unmounting view's props after handleNodeRemovals drained the tag. With no animated views left the props GC interval has stopped and no further commit drains it, so the entry leaks. Clear the orphaned registry one UI frame after the last view unmounts, and switch the GC view counter to viewsMap.size (the old counter drifted negative for nested AnimatedComponents sharing a viewTag).
1 parent cc95531 commit 9b20b33

8 files changed

Lines changed: 68 additions & 6 deletions

File tree

packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,10 @@ void AnimatedPropsRegistry::removeTag(const Tag tag) {
8787
timestampMap_.erase(tag);
8888
}
8989

90+
void AnimatedPropsRegistry::removeAll() {
91+
react_native_assert(UpdatesRegistryManager::isLockedByCurrentThread());
92+
updatesRegistry_.clear();
93+
timestampMap_.clear();
94+
}
95+
9096
} // namespace reanimated

packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ class AnimatedPropsRegistry : public UpdatesRegistry {
1818
/// Also removes updates older than `cleanupTimestamp` from the registry.
1919
jsi::Value getUpdatesOlderThanTimestamp(jsi::Runtime &rt, double timestamp, double cleanupTimestamp);
2020

21+
/// Drops every entry. Called by the JS-side GC once the last animated view
22+
/// unmounts, when every remaining entry is an orphan no other sweep collects.
23+
void removeAll();
24+
2125
private:
2226
std::unordered_map<Tag, double> timestampMap_;
2327

packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,11 @@ jsi::Value ReanimatedModuleProxy::getSettledUpdates(jsi::Runtime &rt) {
663663
rt, currentTimestamp - 1000 /* 1 second */, currentTimestamp - 2000 /* 2 seconds */);
664664
}
665665

666+
void ReanimatedModuleProxy::removeOrphanedProps(jsi::Runtime &rt) {
667+
auto lock = updatesRegistryManager_->lock();
668+
animatedPropsRegistry_->removeAll();
669+
}
670+
666671
bool ReanimatedModuleProxy::handleEvent(
667672
const std::string &eventName,
668673
const int emitterReactTag,
@@ -1579,6 +1584,14 @@ jsi::Object ReanimatedModuleProxy::toOptimizedObject(jsi::Runtime &rt) {
15791584
return strongThis->getSettledUpdates(rt);
15801585
});
15811586

1587+
addMethod<0>(rt, obj, "removeOrphanedProps", [weakThis = weak_from_this()](jsi::Runtime &rt, const jsi::Value &) {
1588+
auto strongThis = weakThis.lock();
1589+
if (!strongThis) {
1590+
return;
1591+
}
1592+
strongThis->removeOrphanedProps(rt);
1593+
});
1594+
15821595
addMethod<2>(
15831596
rt,
15841597
obj,

packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ class ReanimatedModuleProxy : public std::enable_shared_from_this<ReanimatedModu
149149

150150
jsi::Value getSettledUpdates(jsi::Runtime &rt);
151151

152+
void removeOrphanedProps(jsi::Runtime &rt);
153+
152154
void dispatchCommand(
153155
jsi::Runtime &rt,
154156
const jsi::Value &shadowNodeValue,

packages/react-native-reanimated/src/PropsRegistryGarbageCollector.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict';
22

3+
import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets';
4+
35
import {
46
unprocessColor,
57
unprocessColorsInProps,
@@ -11,7 +13,6 @@ import { ReanimatedModule } from './ReanimatedModule';
1113
const FLUSH_INTERVAL_MS = 500;
1214

1315
export const PropsRegistryGarbageCollector = {
14-
viewsCount: 0,
1516
viewsMap: new Map<number, IAnimatedComponentInternal>(),
1617
intervalId: null as NodeJS.Timeout | null,
1718

@@ -25,17 +26,20 @@ export const PropsRegistryGarbageCollector = {
2526
return;
2627
}
2728
this.viewsMap.set(viewTag, component);
28-
this.viewsCount++;
29-
if (this.viewsCount === 1) {
29+
if (this.viewsMap.size === 1) {
3030
this.registerInterval();
3131
}
3232
},
3333

3434
unregisterView(viewTag: number) {
35-
this.viewsMap.delete(viewTag);
36-
this.viewsCount--;
37-
if (this.viewsCount === 0) {
35+
// `delete` returns false when the tag wasn't tracked (the nested-component
36+
// case registerView skipped above); bail to keep the count symmetric.
37+
if (!this.viewsMap.delete(viewTag)) {
38+
return;
39+
}
40+
if (this.viewsMap.size === 0) {
3841
this.unregisterInterval();
42+
scheduleOrphanedPropsCleanup();
3943
}
4044
},
4145

@@ -66,6 +70,25 @@ export const PropsRegistryGarbageCollector = {
6670
},
6771
};
6872

73+
// The last animated view just unmounted, so the GC interval has stopped and no
74+
// commit will drain the registry. An in-flight animation frame can still re-add
75+
// the view's props; wait one UI frame for it to run, then clear the registry.
76+
function scheduleOrphanedPropsCleanup() {
77+
scheduleOnUI(() => {
78+
'worklet';
79+
requestAnimationFrame(() => {
80+
'worklet';
81+
scheduleOnRN(removeOrphanedProps);
82+
});
83+
});
84+
}
85+
86+
function removeOrphanedProps() {
87+
if (PropsRegistryGarbageCollector.viewsMap.size === 0) {
88+
ReanimatedModule.removeOrphanedProps();
89+
}
90+
}
91+
6992
function unprocessProps(props: StyleProps) {
7093
unprocessColorsInProps(props);
7194
unprocessBoxShadow(props);

packages/react-native-reanimated/src/ReanimatedModule/NativeReanimated.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ See https://docs.swmansion.com/react-native-reanimated/docs/guides/troubleshooti
244244
return this.#reanimatedModuleProxy.getSettledUpdates();
245245
}
246246

247+
removeOrphanedProps() {
248+
this.#reanimatedModuleProxy.removeOrphanedProps();
249+
}
250+
247251
registerPseudoStyles(
248252
shadowNodeWrapper: ShadowNodeWrapper,
249253
config: CSSPseudoStyleConfig
@@ -297,6 +301,8 @@ class DummyReanimatedModuleProxy implements ReanimatedModuleProxy {
297301
return [];
298302
}
299303

304+
removeOrphanedProps(): void {}
305+
300306
registerPseudoStyles(): void {}
301307
unregisterPseudoStyles(): void {}
302308
}

packages/react-native-reanimated/src/ReanimatedModule/js-reanimated/JSReanimated.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,12 @@ class JSReanimated implements IReanimatedModule {
349349
);
350350
}
351351

352+
removeOrphanedProps(): void {
353+
throw new Error(
354+
'[Reanimated] `removeOrphanedProps` is not available in JSReanimated.'
355+
);
356+
}
357+
352358
registerPseudoStyles(): void {
353359
throw new Error(
354360
'[Reanimated] `registerPseudoStyles` is not available in JSReanimated.'

packages/react-native-reanimated/src/ReanimatedModule/reanimatedModuleProxy.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export interface ReanimatedModuleProxy {
9494

9595
getSettledUpdates(): SettledUpdate[];
9696

97+
removeOrphanedProps(): void;
98+
9799
registerPseudoStyles(
98100
shadowNodeWrapper: ShadowNodeWrapper,
99101
config: CSSPseudoStyleConfig

0 commit comments

Comments
 (0)