react-native-reanimated 4.5.2 → 4.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/Common/cpp/reanimated/Fabric/ShadowTreeCloner.cpp +1 -1
  2. package/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp +54 -24
  3. package/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h +13 -6
  4. package/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h +5 -5
  5. package/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp +84 -3
  6. package/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h +7 -4
  7. package/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp +159 -27
  8. package/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h +40 -18
  9. package/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp +21 -14
  10. package/android/build.gradle.kts +9 -0
  11. package/apple/reanimated/apple/REANodesManager.mm +5 -1
  12. package/lib/module/PropsRegistryGarbageCollector.js +3 -6
  13. package/lib/module/PropsRegistryGarbageCollector.js.map +1 -1
  14. package/lib/module/animation/styleAnimation.js +4 -3
  15. package/lib/module/animation/styleAnimation.js.map +1 -1
  16. package/lib/module/createAnimatedComponent/AnimatedComponent.js +1 -1
  17. package/lib/module/createAnimatedComponent/AnimatedComponent.js.map +1 -1
  18. package/lib/module/layoutReanimation/animationsManager.js +9 -6
  19. package/lib/module/layoutReanimation/animationsManager.js.map +1 -1
  20. package/lib/module/platform-specific/jsVersion.js +1 -1
  21. package/lib/typescript/PropsRegistryGarbageCollector.d.ts +0 -1
  22. package/lib/typescript/PropsRegistryGarbageCollector.d.ts.map +1 -1
  23. package/lib/typescript/animation/styleAnimation.d.ts +1 -1
  24. package/lib/typescript/animation/styleAnimation.d.ts.map +1 -1
  25. package/lib/typescript/createAnimatedComponent/AnimatedComponent.d.ts.map +1 -1
  26. package/lib/typescript/layoutReanimation/animationsManager.d.ts.map +1 -1
  27. package/lib/typescript/platform-specific/jsVersion.d.ts +1 -1
  28. package/package.json +2 -2
  29. package/scripts/reanimated_utils.rb +7 -0
  30. package/src/PropsRegistryGarbageCollector.ts +3 -6
  31. package/src/animation/styleAnimation.ts +5 -3
  32. package/src/createAnimatedComponent/AnimatedComponent.tsx +3 -1
  33. package/src/layoutReanimation/animationsManager.ts +9 -7
  34. package/src/platform-specific/jsVersion.ts +1 -1
@@ -25,7 +25,7 @@ mergeProps(const ShadowNode &shadowNode, const PropsMap &propsMap, const ShadowN
25
25
  if (propsVector.size() > 1) {
26
26
  folly::dynamic newPropsDynamic = folly::dynamic::object;
27
27
  for (const auto &props : propsVector) {
28
- newPropsDynamic = folly::dynamic::merge(props.operator folly::dynamic(), newPropsDynamic);
28
+ newPropsDynamic = folly::dynamic::merge(newPropsDynamic, props.operator folly::dynamic());
29
29
  }
30
30
  return shadowNode.getComponentDescriptor().cloneProps(propsParserContext, newProps, RawProps(newPropsDynamic));
31
31
  }
@@ -5,8 +5,10 @@
5
5
 
6
6
  #include <react/debug/react_native_assert.h>
7
7
 
8
+ #include <functional>
8
9
  #include <memory>
9
10
  #include <utility>
11
+ #include <vector>
10
12
 
11
13
  namespace reanimated {
12
14
 
@@ -35,26 +37,65 @@ void AnimatedPropsRegistry::update(jsi::Runtime &rt, const jsi::Value &operation
35
37
  addUpdatesToBatch(shadowNode->getFamilyShared(), jsi::dynamicFromValue(rt, updates));
36
38
  }
37
39
 
38
- if constexpr (StaticFeatureFlags::getFlag("FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS")) {
39
- timestampMap_[shadowNode->getTag()] = timestamp;
40
+ // When USE_ANIMATION_BACKEND is enabled, updates bypass `updatesRegistry_`,
41
+ // so entries added to `timestampMap_` would never be synced and thus never
42
+ // evicted, leaking until view unmount.
43
+ if constexpr (
44
+ StaticFeatureFlags::getFlag("FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS") &&
45
+ !StaticFeatureFlags::getFlag("USE_ANIMATION_BACKEND")) {
46
+ const auto tag = shadowNode->getTag();
47
+ timestampMap_[tag] = timestamp;
48
+ // If JS already has a `settledProps` snapshot for this tag, it is now
49
+ // stale — schedule a refresh on the next `collectSettledUpdates`.
50
+ if (syncedTags_.erase(tag) > 0) {
51
+ invalidatedTags_.insert(tag);
52
+ }
40
53
  }
41
54
  }
42
55
  }
43
56
 
44
- jsi::Value AnimatedPropsRegistry::getUpdatesOlderThanTimestamp(
45
- jsi::Runtime &rt,
46
- const double timestamp,
47
- const double cleanupTimestamp) {
57
+ jsi::Value AnimatedPropsRegistry::collectSettledUpdates(jsi::Runtime &rt, const double settledTimestamp) {
48
58
  react_native_assert(UpdatesRegistryManager::isLockedByCurrentThread());
49
- removeUpdatesOlderThanTimestamp(cleanupTimestamp);
50
59
 
51
60
  std::vector<std::pair<Tag, std::reference_wrapper<const folly::dynamic>>> updates;
52
61
 
53
- for (const auto &[viewTag, pair] : updatesRegistry_) {
54
- auto it = timestampMap_.find(viewTag);
55
- if (it != timestampMap_.end() && it->second < timestamp) {
56
- updates.emplace_back(viewTag, std::cref(pair.second));
62
+ for (auto it = updatesRegistry_.begin(); it != updatesRegistry_.end();) {
63
+ const auto viewTag = it->first;
64
+
65
+ if (syncedTags_.contains(viewTag)) {
66
+ // React already has the latest value for this tag (synced on a previous
67
+ // call, so the `settledProps` state is committed by now) — the registry
68
+ // entry is redundant. `syncedTags_` is intentionally retained to detect
69
+ // re-animation staleness. Note that `syncedTags_` and `invalidatedTags_`
70
+ // are disjoint — `update()` moves tags from the former to the latter.
71
+ timestampMap_.erase(viewTag);
72
+ it = updatesRegistry_.erase(it);
73
+ continue;
74
+ }
75
+
76
+ const auto timestampIt = timestampMap_.find(viewTag);
77
+ if (timestampIt == timestampMap_.end()) {
78
+ ++it;
79
+ continue;
80
+ }
81
+ const bool isSettled = timestampIt->second < settledTimestamp;
82
+ const auto invalidatedIt = invalidatedTags_.find(viewTag);
83
+ const bool isInvalidated = invalidatedIt != invalidatedTags_.end();
84
+ if (isSettled || isInvalidated) {
85
+ updates.emplace_back(viewTag, std::cref(it->second.second));
86
+ if (isSettled) {
87
+ // Only settled-path tags are tracked as "synced" so that an ongoing
88
+ // animation doesn't re-trigger an invalidation/sync on every GC tick.
89
+ syncedTags_.insert(viewTag);
90
+ }
91
+ if (isInvalidated) {
92
+ // Only erase serviced invalidations; if a tag was invalidated but the
93
+ // matching update batch hasn't been flushed into updatesRegistry_ yet,
94
+ // we leave the entry so the next sync picks it up.
95
+ invalidatedTags_.erase(invalidatedIt);
96
+ }
57
97
  }
98
+ ++it;
58
99
  }
59
100
 
60
101
  const jsi::Array array(rt, updates.size());
@@ -69,22 +110,11 @@ jsi::Value AnimatedPropsRegistry::getUpdatesOlderThanTimestamp(
69
110
  return jsi::Value(rt, array);
70
111
  }
71
112
 
72
- void AnimatedPropsRegistry::removeUpdatesOlderThanTimestamp(const double timestamp) {
73
- for (auto it = timestampMap_.begin(); it != timestampMap_.end();) {
74
- const auto viewTag = it->first;
75
- const auto viewTimestamp = it->second;
76
- if (viewTimestamp < timestamp) {
77
- it = timestampMap_.erase(it);
78
- updatesRegistry_.erase(viewTag);
79
- } else {
80
- it++;
81
- }
82
- }
83
- }
84
-
85
113
  void AnimatedPropsRegistry::removeTag(const Tag tag) {
86
114
  updatesRegistry_.erase(tag);
87
115
  timestampMap_.erase(tag);
116
+ syncedTags_.erase(tag);
117
+ invalidatedTags_.erase(tag);
88
118
  }
89
119
 
90
120
  } // namespace reanimated
@@ -4,10 +4,8 @@
4
4
 
5
5
  #include <react/renderer/uimanager/UIManager.h>
6
6
 
7
- #include <memory>
8
- #include <string>
9
7
  #include <unordered_map>
10
- #include <vector>
8
+ #include <unordered_set>
11
9
 
12
10
  namespace reanimated {
13
11
 
@@ -15,13 +13,22 @@ class AnimatedPropsRegistry : public UpdatesRegistry {
15
13
  public:
16
14
  void update(jsi::Runtime &rt, const jsi::Value &operations, double timestamp);
17
15
 
18
- /// Also removes updates older than `cleanupTimestamp` from the registry.
19
- jsi::Value getUpdatesOlderThanTimestamp(jsi::Runtime &rt, double timestamp, double cleanupTimestamp);
16
+ /// Returns updates that settled (received no update since `settledTimestamp`)
17
+ /// or whose synced `settledProps` snapshot was invalidated by a fresh update.
18
+ /// Also evicts entries that have already been synced to React — by the time
19
+ /// of the next call, the corresponding `settledProps` state is guaranteed to
20
+ /// be committed, so the registry entries are redundant.
21
+ jsi::Value collectSettledUpdates(jsi::Runtime &rt, double settledTimestamp);
20
22
 
21
23
  private:
22
24
  std::unordered_map<Tag, double> timestampMap_;
25
+ // Tags whose latest values have already been pushed to React `settledProps`.
26
+ // Intentionally retained after eviction to detect re-animation staleness.
27
+ std::unordered_set<Tag> syncedTags_;
28
+ // Tags that were synced to React but received a fresh worklet update since;
29
+ // their `settledProps` are stale and need to be refreshed on the next sync.
30
+ std::unordered_set<Tag> invalidatedTags_;
23
31
 
24
- void removeUpdatesOlderThanTimestamp(double timestamp);
25
32
  void removeTag(Tag tag) override;
26
33
  };
27
34
 
@@ -62,11 +62,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
62
62
  const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
63
63
  const std::shared_ptr<const ContextContainer> &contextContainer,
64
64
  jsi::Runtime &uiRuntime,
65
- const std::shared_ptr<UIScheduler> &uiScheduler
65
+ const std::shared_ptr<UIScheduler> &uiScheduler,
66
+ const std::shared_ptr<facebook::react::UIManager> &uiManager
66
67
  #ifdef ANDROID
67
68
  ,
68
69
  const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
69
- const std::shared_ptr<facebook::react::UIManager> &uiManager,
70
70
  const std::shared_ptr<facebook::react::CallInvoker> &jsInvoker
71
71
  #endif
72
72
  )
@@ -74,11 +74,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
74
74
  contextContainer_(contextContainer),
75
75
  componentDescriptorRegistry_(componentDescriptorRegistry),
76
76
  uiRuntime_(uiRuntime),
77
- uiScheduler_(uiScheduler)
77
+ uiScheduler_(uiScheduler),
78
+ uiManager_(uiManager)
78
79
  #ifdef ANDROID
79
80
  ,
80
81
  preserveMountedTags_(filterUnmountedTagsFunction),
81
- uiManager_(uiManager),
82
82
  jsInvoker_(jsInvoker)
83
83
  #endif
84
84
  {
@@ -98,10 +98,10 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
98
98
  SharedComponentDescriptorRegistry componentDescriptorRegistry_;
99
99
  jsi::Runtime &uiRuntime_;
100
100
  const std::shared_ptr<UIScheduler> uiScheduler_;
101
+ std::shared_ptr<facebook::react::UIManager> uiManager_;
101
102
  PreserveMountedTagsFunction preserveMountedTags_;
102
103
 
103
104
  #ifdef ANDROID
104
- std::shared_ptr<facebook::react::UIManager> uiManager_;
105
105
  std::shared_ptr<facebook::react::CallInvoker> jsInvoker_;
106
106
 
107
107
  void restoreOpacityInCaseOfFlakyEnteringAnimation(SurfaceId surfaceId) const;
@@ -30,6 +30,8 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Experimental::pullTrans
30
30
  const std::vector<std::shared_ptr<MutationNode>> roots;
31
31
  const bool isInTransition = static_cast<bool>(transitionState_);
32
32
 
33
+ reconcileContradictedRemovals(mutations, filteredMutations);
34
+
33
35
  if (isInTransition) {
34
36
  updateLightTree(propsParserContext, mutations, filteredMutations);
35
37
  handleProgressTransition(filteredMutations, mutations, propsParserContext, surfaceId);
@@ -107,6 +109,67 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Experimental::pullTrans
107
109
  return MountingTransaction{surfaceId, transactionNumber, std::move(filteredMutations), telemetry};
108
110
  }
109
111
 
112
+ // If React re-creates or re-inserts a tag whose exiting removal we are still
113
+ // withholding, it has contradicted that withheld removal. Flush it now instead
114
+ // of letting the stale node linger: updateLightTree would overwrite its
115
+ // lightNodes_ entry (the "LightNode already exists" assert is compiled out in
116
+ // release), orphaning the still-mounted exiting view, and the eventual
117
+ // endLayoutAnimation would then remove the wrong, live view and crash the
118
+ // mounting layer.
119
+ //
120
+ // This must run before updateLightTree (so the tag is re-registered cleanly)
121
+ // and before addOngoingAnimations (which would otherwise emit an Update for a
122
+ // tag we are about to Delete this frame).
123
+ void LayoutAnimationsProxy_Experimental::reconcileContradictedRemovals(
124
+ const ShadowViewMutationList &mutations,
125
+ ShadowViewMutationList &filteredMutations) const {
126
+ for (const auto &mutation : mutations) {
127
+ if (mutation.type != ShadowViewMutation::Type::Create && mutation.type != ShadowViewMutation::Type::Insert) {
128
+ continue;
129
+ }
130
+ const auto tag = mutation.newChildShadowView.tag;
131
+ std::shared_ptr<LightNode> node;
132
+ if (const auto it = lightNodes_.find(tag); it != lightNodes_.end() && it->second->state != UNDEFINED) {
133
+ node = it->second;
134
+ lightNodes_.erase(it);
135
+ if (node->state == DELETED) {
136
+ // already unmounted — only the stale map entry had to go
137
+ continue;
138
+ }
139
+ } else {
140
+ // A settled exiting view (state DEAD) has already left lightNodes_ but is
141
+ // still mounted, awaiting the deadNodes cleanup in handleRemovals. That
142
+ // cleanup runs at the end of the transaction — after this Create would
143
+ // have re-registered the tag in the mounting layer's view registry — so
144
+ // it must be flushed now instead.
145
+ const auto deadIt = std::find_if(
146
+ deadNodes.begin(), deadNodes.end(), [tag](const auto &deadNode) { return deadNode->current.tag == tag; });
147
+ if (deadIt == deadNodes.end()) {
148
+ continue;
149
+ }
150
+ node = *deadIt;
151
+ deadNodes.erase(deadIt);
152
+ if (node->state == DELETED) {
153
+ continue;
154
+ }
155
+ }
156
+ // Flush the withheld removal for this tag (and its withheld subtree) right
157
+ // now, mirroring the deadNodes cleanup in handleRemovals.
158
+ const auto parent = node->parent.lock();
159
+ react_native_assert(parent && "Parent node is nullptr");
160
+ if (!parent) {
161
+ continue;
162
+ }
163
+ const auto index = parent->removeChild(node);
164
+ react_native_assert(index != -1 && "Exiting node not found");
165
+ if (index == -1) {
166
+ continue;
167
+ }
168
+ endAnimationsRecursively(node, index, filteredMutations);
169
+ maybeDropAncestors(parent, filteredMutations);
170
+ }
171
+ }
172
+
110
173
  bool LayoutAnimationsProxy_Experimental::shouldOverridePullTransaction() const {
111
174
  // we need to listen to every possible mutation to keep the light tree updated
112
175
  return true;
@@ -305,11 +368,18 @@ std::optional<SurfaceId> LayoutAnimationsProxy_Experimental::endLayoutAnimation(
305
368
  return surfaceId;
306
369
  }
307
370
 
308
- auto node = lightNodes_[tag];
309
- react_native_assert(node && "LightNode not found");
371
+ const auto nodeIt = lightNodes_.find(tag);
372
+ // the withheld removal may have already been flushed (e.g. reconciled after
373
+ // React re-created the tag) — the assert alone is compiled out in release
374
+ // and operator[] would insert a null node here
375
+ if (nodeIt == lightNodes_.end() || !nodeIt->second) {
376
+ react_native_assert(false && "LightNode not found");
377
+ return surfaceId;
378
+ }
379
+ auto node = nodeIt->second;
310
380
 
311
381
  node->state = DEAD;
312
- lightNodes_.erase(tag);
382
+ lightNodes_.erase(nodeIt);
313
383
  deadNodes.insert(node);
314
384
 
315
385
  return surfaceId;
@@ -349,6 +419,7 @@ void LayoutAnimationsProxy_Experimental::handleRemovals(
349
419
  parent->children.push_back(node);
350
420
  if (node->state == UNDEFINED) {
351
421
  node->state = WAITING;
422
+ lightNodes_[node->current.tag] = node;
352
423
  }
353
424
  } else {
354
425
  maybeCancelAnimation(node->current.tag);
@@ -432,6 +503,10 @@ void LayoutAnimationsProxy_Experimental::endAnimationsRecursively(
432
503
  ShadowViewMutationList &mutations) const {
433
504
  maybeCancelAnimation(node->current.tag);
434
505
  node->state = DELETED;
506
+ // drop the tag mapping unless it was already re-registered for a new node
507
+ if (const auto it = lightNodes_.find(node->current.tag); it != lightNodes_.end() && it->second == node) {
508
+ lightNodes_.erase(it);
509
+ }
435
510
  // iterate from the end, so that children
436
511
  // with higher indices appear first in the mutations list
437
512
 
@@ -463,6 +538,9 @@ void LayoutAnimationsProxy_Experimental::maybeDropAncestors(
463
538
  react_native_assert(index != -1 && "Child node not found");
464
539
 
465
540
  node->state = DELETED;
541
+ if (const auto it = lightNodes_.find(node->current.tag); it != lightNodes_.end() && it->second == node) {
542
+ lightNodes_.erase(it);
543
+ }
466
544
  maybeCancelAnimation(node->current.tag);
467
545
  cleanupMutations.push_back(ShadowViewMutation::RemoveMutation(parent->current.tag, node->current, index));
468
546
  cleanupMutations.push_back(ShadowViewMutation::DeleteMutation(node->current));
@@ -515,6 +593,9 @@ bool LayoutAnimationsProxy_Experimental::startAnimationsRecursively(
515
593
  mutations.push_back(ShadowViewMutation::DeleteMutation(subNode->current));
516
594
  } else {
517
595
  subNode->state = WAITING;
596
+ // register withheld subtree members, so that reconcileContradictedRemovals
597
+ // can find them when React re-creates their tags
598
+ lightNodes_[subNode->current.tag] = subNode;
518
599
  }
519
600
  }
520
601
 
@@ -67,11 +67,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
67
67
  const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
68
68
  const std::shared_ptr<const ContextContainer> &contextContainer,
69
69
  jsi::Runtime &uiRuntime,
70
- const std::shared_ptr<UIScheduler> &uiScheduler
70
+ const std::shared_ptr<UIScheduler> &uiScheduler,
71
+ const std::shared_ptr<UIManager> &uiManager
71
72
  #ifdef ANDROID
72
73
  ,
73
74
  const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
74
- const std::shared_ptr<UIManager> &uiManager,
75
75
  const std::shared_ptr<CallInvoker> &jsInvoker
76
76
  #endif
77
77
  )
@@ -80,11 +80,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
80
80
  componentDescriptorRegistry,
81
81
  contextContainer,
82
82
  uiRuntime,
83
- uiScheduler
83
+ uiScheduler,
84
+ uiManager
84
85
  #ifdef ANDROID
85
86
  ,
86
87
  filterUnmountedTagsFunction,
87
- uiManager,
88
88
  jsInvoker
89
89
  #endif
90
90
  ),
@@ -109,6 +109,9 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
109
109
  const ShadowViewMutationList &mutations,
110
110
  ShadowViewMutationList &filteredMutations) const;
111
111
 
112
+ void reconcileContradictedRemovals(const ShadowViewMutationList &mutations, ShadowViewMutationList &filteredMutations)
113
+ const;
114
+
112
115
  void handleSharedTransitionsStart(
113
116
  const std::shared_ptr<LightNode> &afterTopScreen,
114
117
  const std::shared_ptr<LightNode> &beforeTopScreen,
@@ -1,12 +1,14 @@
1
1
  #include <reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h>
2
2
 
3
3
  #include <react/debug/react_native_assert.h>
4
+ #include <react/renderer/mounting/ShadowTree.h>
4
5
  #include <react/renderer/mounting/ShadowViewMutation.h>
5
6
 
6
7
  #include <memory>
7
8
  #include <ranges>
8
9
  #include <set>
9
10
  #include <string>
11
+ #include <thread>
10
12
  #include <unordered_map>
11
13
  #include <unordered_set>
12
14
  #include <utility>
@@ -33,11 +35,14 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Legacy::pullTransaction
33
35
  auto lock = std::unique_lock<std::recursive_mutex>(mutex);
34
36
  PropsParserContext propsParserContext{surfaceId, *contextContainer_};
35
37
  ShadowViewMutationList filteredMutations;
36
- auto &[deadNodes] = surfaceContext_[surfaceId];
38
+ auto &surfaceCtx = getSurfaceContext(surfaceId);
39
+ auto &deadNodes = surfaceCtx.deadNodes;
37
40
 
38
41
  std::vector<std::shared_ptr<MutationNode>> roots;
39
42
  std::unordered_map<Tag, Tag> movedViews;
40
43
 
44
+ reconcileContradictedRemovals(mutations, filteredMutations, surfaceId);
45
+
41
46
  addOngoingAnimations(surfaceId, filteredMutations);
42
47
 
43
48
  #ifdef ANDROID
@@ -58,17 +63,120 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Legacy::pullTransaction
58
63
 
59
64
  parseRemoveMutations(movedViews, mutations, roots);
60
65
 
61
- auto shouldAnimate = !surfacesToRemove_.contains(surfaceId);
62
- surfacesToRemove_.erase(surfaceId);
63
- handleRemovals(filteredMutations, roots, deadNodes, shouldAnimate);
66
+ // We recognize dropped surfaces by the presence of a Remove mutation for a root child. This can produce false
67
+ // positives. Ideal solution will be to introduce an appropriate API in RN
68
+ auto surfaceDropped = false;
69
+ const auto removesRootChildren = std::ranges::any_of(mutations, [surfaceId](const auto &mutation) {
70
+ return mutation.type == ShadowViewMutation::Remove && mutation.parentTag == surfaceId;
71
+ });
72
+ if (removesRootChildren) {
73
+ surfaceDropped = surfacesToRemove_.erase(surfaceId) != 0;
74
+ }
75
+ const bool flushDeadNodes = shouldFlushDeadNodes(surfaceDropped);
76
+ handleRemovals(filteredMutations, roots, deadNodes, surfaceDropped, flushDeadNodes);
77
+ #ifdef ANDROID
78
+ maybeScheduleCleanupPull(surfaceCtx, surfaceId, flushDeadNodes);
79
+ #endif // ANDROID
64
80
 
65
81
  handleUpdatesAndEnterings(filteredMutations, movedViews, mutations, propsParserContext, surfaceId);
66
82
 
67
83
  addOngoingAnimations(surfaceId, filteredMutations);
68
84
 
85
+ dropUpdatesForDeletedViews(filteredMutations);
86
+
69
87
  return MountingTransaction{surfaceId, transactionNumber, std::move(filteredMutations), telemetry};
70
88
  }
71
89
 
90
+ // The LayoutAnimationDriver can pair a final keyframe update with the withheld
91
+ // Remove/Delete it replays in the same transaction; we emit removals first, so
92
+ // the update would reach the mounting layer after its view was deleted.
93
+ void LayoutAnimationsProxy_Legacy::dropUpdatesForDeletedViews(ShadowViewMutationList &filteredMutations) const {
94
+ std::unordered_set<Tag> deletedTags;
95
+ for (const auto &mutation : filteredMutations) {
96
+ if (mutation.type == ShadowViewMutation::Delete) {
97
+ deletedTags.insert(mutation.oldChildShadowView.tag);
98
+ }
99
+ }
100
+ if (!deletedTags.empty()) {
101
+ std::erase_if(filteredMutations, [&deletedTags](const auto &mutation) {
102
+ return mutation.type == ShadowViewMutation::Update && deletedTags.contains(mutation.newChildShadowView.tag);
103
+ });
104
+ }
105
+ }
106
+
107
+ // If React re-creates or re-inserts a tag whose exiting removal we are still
108
+ // withholding, it has contradicted that withheld Remove/Delete. Flush it now
109
+ // instead of letting it fire later against a stale hierarchy (which would
110
+ // unmount the wrong, still-live view and crash the mounting layer).
111
+ //
112
+ // This must run before addOngoingAnimations (which would otherwise emit an
113
+ // Update for a tag we are about to Delete this frame) and before
114
+ // parseRemoveMutations, so the rest of the pipeline sees clean bookkeeping.
115
+ void LayoutAnimationsProxy_Legacy::reconcileContradictedRemovals(
116
+ ShadowViewMutationList &mutations,
117
+ ShadowViewMutationList &filteredMutations,
118
+ SurfaceId surfaceId) const {
119
+ auto &deadNodes = getSurfaceContext(surfaceId).deadNodes;
120
+ for (auto &mutation : mutations) {
121
+ if (mutation.type != ShadowViewMutation::Type::Create && mutation.type != ShadowViewMutation::Type::Insert) {
122
+ continue;
123
+ }
124
+ auto tag = mutation.newChildShadowView.tag;
125
+ auto it = nodeForTag_.find(tag);
126
+ // Only a MutationNode represents a withheld removal; a plain Node is just a
127
+ // live parent of some removed child and must be left untouched.
128
+ if (it == nodeForTag_.end() || !it->second->isMutationNode()) {
129
+ continue;
130
+ }
131
+ auto node = std::static_pointer_cast<MutationNode>(it->second);
132
+ // Flush the withheld Remove/Delete for this tag (and its withheld subtree)
133
+ // right now, mirroring the deadNodes cleanup in handleRemovals. This removes
134
+ // the stale view before React's Create/Insert re-registers the same tag.
135
+ endAnimationsRecursively(node, filteredMutations);
136
+ maybeDropAncestors(node->unflattenedParent, node, filteredMutations);
137
+ deadNodes.erase(node);
138
+ }
139
+ }
140
+
141
+ // On android mutations that alter the view hierarchy are only produced on the JS thread (the push model), so to not
142
+ // race with those, we apply the dead nodes cleanup only on the JS thread, unless there is a surface drop, in which case
143
+ // we can safely cleanup on the UI thread since the surface is gone and no more mutations will be produced for it.
144
+ bool LayoutAnimationsProxy_Legacy::shouldFlushDeadNodes([[maybe_unused]] const bool surfaceDropped) const {
145
+ #ifdef ANDROID
146
+ return surfaceDropped || std::this_thread::get_id() != uiThreadId_;
147
+ #else
148
+ return true;
149
+ #endif
150
+ }
151
+
152
+ #ifdef ANDROID
153
+ // We schedule a pullTransaction call to happen on the JS thread so it can safely remove dead nodes after exiting
154
+ // finished
155
+ void LayoutAnimationsProxy_Legacy::maybeScheduleCleanupPull(
156
+ SurfaceContext &surfaceCtx,
157
+ const SurfaceId surfaceId,
158
+ const bool flushedDeadNodes) const {
159
+ if (flushedDeadNodes) {
160
+ surfaceCtx.cleanupPullScheduled = false;
161
+ } else if (!surfaceCtx.deadNodes.empty() && !surfaceCtx.cleanupPullScheduled) {
162
+ surfaceCtx.cleanupPullScheduled = true;
163
+ scheduleDeferredCleanupPull(surfaceId);
164
+ }
165
+ }
166
+
167
+ void LayoutAnimationsProxy_Legacy::scheduleDeferredCleanupPull(SurfaceId surfaceId) const {
168
+ const std::weak_ptr<UIManager> weakUiManager = uiManager_;
169
+ jsInvoker_->invokeAsync([weakUiManager, surfaceId](jsi::Runtime &) {
170
+ auto uiManager = weakUiManager.lock();
171
+ if (!uiManager) {
172
+ return;
173
+ }
174
+ uiManager->getShadowTreeRegistry().visit(
175
+ surfaceId, [](ShadowTree const &shadowTree) { shadowTree.notifyDelegatesOfUpdates(); });
176
+ });
177
+ }
178
+ #endif
179
+
72
180
  std::optional<SurfaceId> LayoutAnimationsProxy_Legacy::progressLayoutAnimation(int tag, const jsi::Object &newStyle) {
73
181
  #ifdef LAYOUT_ANIMATIONS_LOGS
74
182
  LOG(INFO) << "progress layout animation for tag " << tag << std::endl;
@@ -127,10 +235,13 @@ std::optional<SurfaceId> LayoutAnimationsProxy_Legacy::endLayoutAnimation(int ta
127
235
  }
128
236
 
129
237
  auto node = nodeForTag_[tag];
130
- react_native_assert(node->isMutationNode() && "exiting tag must map to a MutationNode");
238
+ if (!node->isMutationNode()) {
239
+ react_native_assert(false && "exiting tag must map to a MutationNode");
240
+ return {};
241
+ }
131
242
  auto mutationNode = std::static_pointer_cast<MutationNode>(node);
132
243
  mutationNode->state = ExitingState_Legacy::DEAD;
133
- auto &[deadNodes] = surfaceContext_[surfaceId];
244
+ auto &deadNodes = getSurfaceContext(surfaceId).deadNodes;
134
245
  deadNodes.insert(mutationNode);
135
246
 
136
247
  return surfaceId;
@@ -242,12 +353,13 @@ void LayoutAnimationsProxy_Legacy::handleRemovals(
242
353
  ShadowViewMutationList &filteredMutations,
243
354
  std::vector<std::shared_ptr<MutationNode>> &roots,
244
355
  std::unordered_set<std::shared_ptr<MutationNode>> &deadNodes,
245
- bool shouldAnimate) const {
356
+ bool surfaceDropped,
357
+ bool flushDeadNodes) const {
246
358
  // iterate from the end, so that children
247
359
  // with higher indices appear first in the mutations list
248
360
  for (auto it = roots.rbegin(); it != roots.rend(); it++) {
249
361
  auto &node = *it;
250
- if (!startAnimationsRecursively(node, true, shouldAnimate, false, filteredMutations)) {
362
+ if (!startAnimationsRecursively(node, true, !surfaceDropped, false, filteredMutations)) {
251
363
  filteredMutations.push_back(node->mutation);
252
364
  node->unflattenedParent->removeChildFromUnflattenedTree(node); //???
253
365
  if (node->state != ExitingState_Legacy::MOVED) {
@@ -262,6 +374,10 @@ void LayoutAnimationsProxy_Legacy::handleRemovals(
262
374
  }
263
375
  }
264
376
 
377
+ if (!flushDeadNodes) {
378
+ // Deferred - the host still has these views mounted, bookkeeping stays.
379
+ return;
380
+ }
265
381
  for (const auto &node : deadNodes) {
266
382
  if (node->state != ExitingState_Legacy::DELETED) {
267
383
  endAnimationsRecursively(node, filteredMutations);
@@ -674,6 +790,7 @@ void LayoutAnimationsProxy_Legacy::startEnteringAnimation(const int tag, ShadowV
674
790
  auto &mutex = strongThis->mutex;
675
791
  auto lock = std::unique_lock<std::recursive_mutex>(mutex);
676
792
  #ifdef ANDROID
793
+ strongThis->uiThreadId_ = std::this_thread::get_id();
677
794
  if (consumeIsCancelled(strongThis->pendingStarts_, tag, handle)) {
678
795
  // the view was removed before this start could run
679
796
  return;
@@ -734,12 +851,12 @@ void LayoutAnimationsProxy_Legacy::startExitingAnimation(const int tag, ShadowVi
734
851
  auto &mutex = strongThis->mutex;
735
852
  auto lock = std::unique_lock<std::recursive_mutex>(mutex);
736
853
  #ifdef ANDROID
854
+ strongThis->uiThreadId_ = std::this_thread::get_id();
737
855
  if (consumeIsCancelled(strongThis->pendingStarts_, tag, handle)) {
738
- // the view was removed (e.g. its subtree was force-ended by a screen
739
- // pop) before this start could run its Remove+Delete are already on
740
- // their way to the mounting layer, so starting the animation now
741
- // would emit updates for a view that's about to be deleted
742
- strongThis->layoutAnimationsManager_->clearLayoutAnimationConfig(tag);
856
+ // The view was removed before this start could run. Deliberately no
857
+ // clearLayoutAnimationConfig: with tag reuse the config maps already
858
+ // describe the re-created view, and wiping them would leave it mounted
859
+ // at opacity 0. A dead tag's stale configs can never fire again.
743
860
  return;
744
861
  }
745
862
  #endif
@@ -797,6 +914,7 @@ void LayoutAnimationsProxy_Legacy::startLayoutAnimation(const int tag, const Sha
797
914
  auto &mutex = strongThis->mutex;
798
915
  auto lock = std::unique_lock<std::recursive_mutex>(mutex);
799
916
  #ifdef ANDROID
917
+ strongThis->uiThreadId_ = std::this_thread::get_id();
800
918
  if (consumeIsCancelled(strongThis->pendingStarts_, tag, handle)) {
801
919
  // the view was removed before this start could run
802
920
  return;
@@ -959,23 +1077,37 @@ inline bool MutationNode::isMutationNode() {
959
1077
  return true;
960
1078
  }
961
1079
 
962
- // UIManagerAnimationDelegate
963
-
964
- void LayoutAnimationsProxy_Legacy::uiManagerDidConfigureNextLayoutAnimation(
965
- jsi::Runtime &runtime,
966
- const RawValue &config,
967
- const jsi::Value &successCallbackValue,
968
- const jsi::Value &failureCallbackValue) const {}
969
-
970
- void LayoutAnimationsProxy_Legacy::setComponentDescriptorRegistry(
971
- const SharedComponentDescriptorRegistry &componentDescriptorRegistry) {}
1080
+ void LayoutAnimationsProxy_Legacy::startSurface(const SurfaceId surfaceId) {
1081
+ auto lock = std::unique_lock<std::recursive_mutex>(mutex);
1082
+ surfaceContext_.try_emplace(surfaceId);
1083
+ }
972
1084
 
973
- bool LayoutAnimationsProxy_Legacy::shouldAnimateFrame() const {
974
- return false;
1085
+ SurfaceContext &LayoutAnimationsProxy_Legacy::getSurfaceContext(const SurfaceId surfaceId) const {
1086
+ // startSurface() creates the entry for a surface before any other method
1087
+ // uses it. The proxy does not remove entries during its lifetime. Thus a
1088
+ // missing entry is an initialization bug, not a stopped surface.
1089
+ const auto it = surfaceContext_.find(surfaceId);
1090
+ react_native_assert(it != surfaceContext_.end() && "surface must be registered by startSurface");
1091
+ return it->second;
975
1092
  }
976
1093
 
977
- void LayoutAnimationsProxy_Legacy::stopSurface(SurfaceId surfaceId) {
978
- surfacesToRemove_.insert(surfaceId);
1094
+ // UIManagerCommitHook
1095
+
1096
+ // Surface teardown commits an empty root (SurfaceHandler::stop) before the
1097
+ // teardown transaction is pulled — mark it so pullTransaction skips exit
1098
+ // animations. Reading the ShadowTreeRegistry here instead would deadlock.
1099
+ RootShadowNode::Unshared LayoutAnimationsProxy_Legacy::shadowTreeWillCommit(
1100
+ const ShadowTree &shadowTree,
1101
+ const RootShadowNode::Shared & /*oldRootShadowNode*/,
1102
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept {
1103
+ const auto surfaceId = shadowTree.getSurfaceId();
1104
+ auto lock = std::unique_lock<std::recursive_mutex>(mutex);
1105
+ if (newRootShadowNode->getChildren().empty()) {
1106
+ surfacesToRemove_.insert(surfaceId);
1107
+ } else {
1108
+ surfacesToRemove_.erase(surfaceId);
1109
+ }
1110
+ return newRootShadowNode;
979
1111
  }
980
1112
 
981
1113
  } // namespace reanimated