From a8837c66b3bfc1c3e4aa0c00d09309acfa9e3ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Tue, 18 Aug 2026 22:44:25 +0200 Subject: [PATCH 1/4] refactor(LayoutAnimations): make shared container ownership explicit Collapse containerTags_, restoreMap_, activeTransitions_, and the container entries in the cross-surface tagToName_ map into a single sharedContainers_ map owning the container's LightNode plus references to the restore source/target nodes. Restores are validated by node identity instead of tag, and TransactionMeta::hiddenNodes suppresses restoring a view that the same transaction just hid for a new transition. Fixes folded into the ownership change: - Container LightNodes are now erased from lightNodes_ on removal (previously leaked one stale entry per container). - Identity-checked restores no longer un-hide an unrelated view that reused a recycled tag. - Replacing a completed container eagerly restores and removes the old one instead of silently overwriting the mapping. - Container tags are no longer registered in (or leaked into) the shared tagToName_ map. --- packages/react-native-reanimated/CHANGELOG.md | 1 + .../LayoutAnimationsManager.h | 3 - .../LayoutAnimationsProxy_Experimental.cpp | 23 +- .../LayoutAnimationsProxy_Experimental.h | 31 ++- .../LayoutAnimations/SharedTransitions.cpp | 257 +++++++++++------- 5 files changed, 200 insertions(+), 115 deletions(-) diff --git a/packages/react-native-reanimated/CHANGELOG.md b/packages/react-native-reanimated/CHANGELOG.md index 9ed17cea4c7a..e19983f1ea3d 100644 --- a/packages/react-native-reanimated/CHANGELOG.md +++ b/packages/react-native-reanimated/CHANGELOG.md @@ -38,6 +38,7 @@ - Fix animated styles on sticky headers throwing an immutable-object mutation error in development. ([#10389](https://github.com/software-mansion/react-native-reanimated/pull/10389) by [@ngocdevv](https://github.com/ngocdevv)) - Fix shared element transitions never running on Android for npm installs by publishing `react-native.config.js`, which registers the Shared Transition Boundary component descriptor for autolinking. ([#10375](https://github.com/software-mansion/react-native-reanimated/pull/10375) by [@dennytosp](https://github.com/dennytosp)) - Keep exiting views at their position in the host tree, so they no longer draw above later siblings. ([#10392](https://github.com/software-mansion/react-native-reanimated/pull/10392) by [@pawicao](https://github.com/pawicao)) +- Fix Shared Transitions cleanup, restoration, and positioning in native-stack screens and modals. ([#10370](https://github.com/software-mansion/react-native-reanimated/pull/10370) by [@bartlomiejbloniarz](https://github.com/bartlomiejbloniarz)) - Fix Layout Animations replacement and completion ordering by centralizing native lifecycle operations. ([#10369](https://github.com/software-mansion/react-native-reanimated/pull/10369) by [@bartlomiejbloniarz](https://github.com/bartlomiejbloniarz)) - Fix Layout Animations state leaking between Fabric surfaces by using one proxy per surface. ([#10368](https://github.com/software-mansion/react-native-reanimated/pull/10368) by [@bartlomiejbloniarz](https://github.com/bartlomiejbloniarz)) diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.h b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.h index a14cf4346165..c7b79c04a317 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.h +++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.h @@ -27,9 +27,6 @@ struct Transition { std::optional transform[2]; }; -using TransitionMap = std::unordered_map; -using Transitions = std::vector>; - struct SharedTransitionManager { std::mutex mutex_; std::unordered_map tagToName_; diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp index 7a4e55a69631..45dc0a77f530 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp @@ -107,9 +107,9 @@ std::optional LayoutAnimationsProxy_Experimental::pullTrans // To keep things simple, we put mutations related to source views before all muatations // and mutations to hide target views after all mutations. std::vector mergedMutations; - hideTransitioningViews(BEFORE, transaction.transitions, mergedMutations, propsParserContext); + hideTransitioningViews(BEFORE, mergedMutations, transaction, propsParserContext); mergedMutations.insert(mergedMutations.end(), filteredMutations.begin(), filteredMutations.end()); - hideTransitioningViews(AFTER, transaction.transitions, mergedMutations, propsParserContext); + hideTransitioningViews(AFTER, mergedMutations, transaction, propsParserContext); std::swap(filteredMutations, mergedMutations); } @@ -716,13 +716,8 @@ void LayoutAnimationsProxy_Experimental::maybeCancelAnimation(const int tag) con void LayoutAnimationsProxy_Experimental::surfaceDidUnmount() { LayoutAnimationsProxyCommon::surfaceDidUnmount(); - { - auto lock = std::unique_lock(mutex); - auto sharedTransitionLock = std::unique_lock(sharedTransitionManager_->mutex_); - for (const auto &[_, containerTag] : containerTags_) { - sharedTransitionManager_->tagToName_.erase(containerTag); - } - } + auto lock = std::unique_lock(mutex); + sharedContainers_.clear(); } // When entering animations start, we temporarily set opacity to 0 @@ -746,14 +741,16 @@ void LayoutAnimationsProxy_Experimental::cleanupAnimations( const PropsParserContext &propsParserContext) const { ReanimatedSystraceSection s("cleanupAnimations"); for (const auto &[tag, completedAnimation] : completedAnimations_) { - if (hasPendingLayoutAnimation(tag)) { + if (hasPendingLayoutAnimation(tag) || layoutAnimations_.contains(tag)) { continue; } - const auto restoreIt = restoreMap_.find(tag); - if (restoreIt == restoreMap_.end()) { + const auto containerIt = sharedContainers_.find(tag); + if (containerIt == sharedContainers_.end()) { continue; } - transaction.tagsToRestore.push_back(restoreIt->second[AFTER]); + if (containerIt->second.restoreAfterNode) { + transaction.nodesToRestore.push_back(containerIt->second.restoreAfterNode); + } removeSharedContainer(tag, transaction); } diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h index 3d027586fd72..ea1fb848167c 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h +++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -39,28 +40,42 @@ struct PendingNodeAnimation { std::shared_ptr config; }; +struct SharedContainer { + SharedTag sharedTag; + std::shared_ptr node; + std::shared_ptr restoreBeforeNode; + std::shared_ptr restoreAfterNode; +}; + +struct CollectedTransition { + Transition transition; + std::array, 2> nodes; +}; + +using CollectedTransitionMap = std::unordered_map; +using CollectedTransitions = std::vector>; + struct TransactionMeta { ShadowViewMutationList filteredMutations; ShadowViewMutationList teardownMutations; - TransitionMap transitionMap; - Transitions transitions; + CollectedTransitionMap transitionMap; + CollectedTransitions transitions; std::vector layout; std::vector entering; std::vector exiting; std::vector> containersToInsert; - std::vector tagsToRestore; - std::vector sharedContainersToRemove; + std::vector> nodesToRestore; + std::unordered_set> hiddenNodes; + std::vector> containersToRemove; }; struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon { - mutable std::unordered_set activeTransitions_; mutable Tag transitionTag_; mutable double transitionProgress_; mutable bool transitionUpdated_; mutable TransitionState transitionState_ = TransitionState::NONE; mutable std::shared_ptr topScreen_; - mutable std::unordered_map restoreMap_; - mutable std::unordered_map containerTags_; + mutable std::unordered_map sharedContainers_; mutable bool synchronized_ = true; mutable Tag closingScreenTag_ = -1; std::shared_ptr sharedTransitionManager_; @@ -112,8 +127,8 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon { void hideTransitioningViews( BeforeOrAfter index, - const Transitions &transitions, ShadowViewMutationList &mutations, + TransactionMeta &transaction, const PropsParserContext &propsParserContext) const; std::optional endLayoutAnimation(int tag, bool shouldRemove) override; diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp index 401860e34d56..78891e3d8ada 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -74,7 +75,8 @@ void LayoutAnimationsProxy_Experimental::findSharedElementsOnScreen( absolutePositions = getAbsolutePositionsForRootPathView(node); copy.layoutMetrics.frame.origin = absolutePositions[0]; - auto &transition = transaction.transitionMap[*sharedTag]; + auto &collectedTransition = transaction.transitionMap[*sharedTag]; + auto &transition = collectedTransition.transition; auto &[snapshot, parentTag, transform] = transition; auto newTransform = parseParentTransforms(node, absolutePositions); const auto &parent = node->parent.lock(); @@ -84,12 +86,13 @@ void LayoutAnimationsProxy_Experimental::findSharedElementsOnScreen( transform[indexNum] = std::move(newTransform); snapshot[indexNum] = copy; parentTag[indexNum] = parent->current.tag; + collectedTransition.nodes[indexNum] = node; if (parentTag[BEFORE] && parentTag[AFTER]) { - transaction.transitions.emplace_back(*sharedTag, transition); + transaction.transitions.emplace_back(*sharedTag, collectedTransition); } else if (parentTag[AFTER]) { // TODO (future): this is adding unnecessary views to the list - transaction.tagsToRestore.push_back(snapshot[AFTER].tag); + transaction.nodesToRestore.push_back(node); } } for (auto &child : node->children) { @@ -117,27 +120,40 @@ void LayoutAnimationsProxy_Experimental::handleProgressTransition( if (beforeTopScreen && afterTopScreen && beforeTopScreen != afterTopScreen) { findSharedElementsOnScreen(beforeTopScreen, BEFORE, propsParserContext, transaction); findSharedElementsOnScreen(afterTopScreen, AFTER, propsParserContext, transaction); - hideTransitioningViews(BEFORE, transaction.transitions, filteredMutations, propsParserContext); - hideTransitioningViews(AFTER, transaction.transitions, filteredMutations, propsParserContext); + hideTransitioningViews(BEFORE, filteredMutations, transaction, propsParserContext); + hideTransitioningViews(AFTER, filteredMutations, transaction, propsParserContext); - for (auto &[sharedTag, transition] : transaction.transitions) { + for (auto &[sharedTag, collectedTransition] : transaction.transitions) { + auto &transition = collectedTransition.transition; auto &[before, after] = transition.snapshot; const auto &transform = transition.transform; overrideTransform(before, transform[BEFORE], propsParserContext); overrideTransform(after, transform[AFTER], propsParserContext); - auto containerTag = getOrCreateContainer(before, sharedTag, transaction); + const auto &[beforeNode, afterNode] = collectedTransition.nodes; + react_native_assert(beforeNode && "Shared transition source not found"); + react_native_assert(afterNode && "Shared transition target not found"); + if (!beforeNode || !afterNode) { + continue; + } - restoreMap_[containerTag][BEFORE] = before.tag; - restoreMap_[containerTag][AFTER] = after.tag; + const auto containerTag = getOrCreateContainer(before, sharedTag, transaction); + auto &container = sharedContainers_.at(containerTag); + container.restoreBeforeNode = beforeNode; + if (container.restoreAfterNode && container.restoreAfterNode != afterNode) { + transaction.nodesToRestore.push_back(container.restoreAfterNode); + } + container.restoreAfterNode = afterNode; before.tag = containerTag; after.tag = containerTag; - activeTransitions_.insert(containerTag); startProgressTransition(containerTag, before, after); } } } else if (transitionState_ == TransitionState::ACTIVE) { - for (auto tag : activeTransitions_) { + for (const auto &[tag, container] : sharedContainers_) { + if (!container.restoreBeforeNode) { + continue; + } if (hasPendingLayoutAnimation(tag)) { continue; } @@ -180,10 +196,20 @@ void LayoutAnimationsProxy_Experimental::handleProgressTransition( if (transitionState_ == TransitionState::START) { transitionState_ = TransitionState::ACTIVE; } else if (transitionState_ == TransitionState::END || transitionState_ == TransitionState::CANCELLED) { - for (auto tag : activeTransitions_) { - transaction.tagsToRestore.push_back(restoreMap_[tag][AFTER]); + std::vector progressContainerTags; + for (const auto &[tag, container] : sharedContainers_) { + if (container.restoreBeforeNode) { + progressContainerTags.push_back(tag); + } + } + for (const auto tag : progressContainerTags) { + const auto &container = sharedContainers_.at(tag); + react_native_assert(container.restoreAfterNode && "Shared transition target not found"); + if (container.restoreAfterNode) { + transaction.nodesToRestore.push_back(container.restoreAfterNode); + } if (transitionState_ == TransitionState::CANCELLED) { - transaction.tagsToRestore.push_back(restoreMap_[tag][BEFORE]); + transaction.nodesToRestore.push_back(container.restoreBeforeNode); } removeSharedContainer(tag, transaction); maybeCancelAnimation(tag); @@ -191,7 +217,6 @@ void LayoutAnimationsProxy_Experimental::handleProgressTransition( if (transitionState_ == TransitionState::END) { synchronized_ = false; } - activeTransitions_.clear(); transitionState_ = TransitionState::NONE; } } @@ -223,30 +248,45 @@ Tag LayoutAnimationsProxy_Experimental::getOrCreateContainer( const ShadowView &before, const SharedTag &sharedTag, TransactionMeta &transaction) const { - const auto containerIt = containerTags_.find(sharedTag); - auto containerTag = containerIt == containerTags_.end() ? -1 : containerIt->second; - const auto shouldCreateContainer = - containerTag == -1 || (!layoutAnimations_.contains(containerTag) && !hasPendingLayoutAnimation(containerTag)); - - if (shouldCreateContainer) { - { - auto lock = std::unique_lock(sharedTransitionManager_->mutex_); - containerTag = sharedTransitionManager_->nextContainerTag_; - sharedTransitionManager_->nextContainerTag_ += 2; - sharedTransitionManager_->tagToName_[containerTag] = sharedTag; + auto containerTag = Tag{-1}; + for (const auto &[tag, container] : sharedContainers_) { + if (container.sharedTag == sharedTag && tag > containerTag) { + containerTag = tag; } - auto &root = lightNodes_[surfaceId_]; - ShadowView container = before; - - container.tag = containerTag; - auto node = std::make_shared(); - node->current = std::move(container); - root->children.push_back(node); - transaction.containersToInsert.push_back(node); - lightNodes_[containerTag] = std::move(node); - - containerTags_[sharedTag] = containerTag; } + if (containerTag != -1) { + if (hasPendingLayoutAnimation(containerTag) || layoutAnimations_.contains(containerTag)) { + return containerTag; + } + const auto &container = sharedContainers_.at(containerTag); + react_native_assert(completedAnimations_.contains(containerTag) && "Shared container has no animation"); + if (container.restoreAfterNode) { + transaction.nodesToRestore.push_back(container.restoreAfterNode); + } + removeSharedContainer(containerTag, transaction); + } + + { + auto lock = std::unique_lock(sharedTransitionManager_->mutex_); + containerTag = sharedTransitionManager_->nextContainerTag_; + sharedTransitionManager_->nextContainerTag_ += 2; + } + const auto root = lightNodes_.at(surfaceId_); + auto containerView = before; + containerView.tag = containerTag; + auto node = std::make_shared(); + node->current = std::move(containerView); + node->parent = root; + root->children.push_back(node); + transaction.containersToInsert.push_back(node); + const auto [_, inserted] = lightNodes_.emplace(containerTag, node); + react_native_assert(inserted && "Shared container already exists"); + sharedContainers_.emplace( + containerTag, + SharedContainer{ + .sharedTag = sharedTag, + .node = std::move(node), + }); return containerTag; } @@ -263,7 +303,8 @@ void LayoutAnimationsProxy_Experimental::handleSharedTransitionsStart( } if (beforeTopScreen != afterTopScreen) { - for (auto &[sharedTag, transition] : transaction.transitions) { + for (auto &[sharedTag, collectedTransition] : transaction.transitions) { + auto &transition = collectedTransition.transition; auto &[before, after] = transition.snapshot; const auto &transform = transition.transform; overrideTransform(before, transform[BEFORE], propsParserContext); @@ -273,48 +314,61 @@ void LayoutAnimationsProxy_Experimental::handleSharedTransitionsStart( if (!config) { continue; } + const auto &afterNode = collectedTransition.nodes[AFTER]; + react_native_assert(afterNode && "Shared transition target not found"); + if (!afterNode) { + continue; + } auto containerTag = getOrCreateContainer(before, sharedTag, transaction); - - restoreMap_[containerTag][AFTER] = after.tag; + auto &container = sharedContainers_.at(containerTag); + if (container.restoreAfterNode && container.restoreAfterNode != afterNode) { + transaction.nodesToRestore.push_back(container.restoreAfterNode); + } + container.restoreAfterNode = afterNode; before.tag = containerTag; after.tag = containerTag; startSharedTransition(containerTag, before, after, config); } } else if (!mutations.empty()) { - for (auto &[sharedTag, transition] : transaction.transitions) { + for (auto &[sharedTag, collectedTransition] : transaction.transitions) { + auto &transition = collectedTransition.transition; auto &[before, after] = transition.snapshot; - const auto containerIt = containerTags_.find(sharedTag); - if (containerIt == containerTags_.end()) { + auto containerTag = Tag{-1}; + for (const auto &[tag, container] : sharedContainers_) { + if (container.sharedTag == sharedTag && (hasPendingLayoutAnimation(tag) || layoutAnimations_.contains(tag)) && + tag > containerTag) { + containerTag = tag; + } + } + if (containerTag == -1) { continue; } - const auto containerTag = containerIt->second; - if (hasPendingLayoutAnimation(containerTag)) { - const auto config = layoutAnimationsManager_->getLayoutAnimationConfig( - before.tag, LayoutAnimationType::SHARED_ELEMENT_TRANSITION); - if (!config) { - continue; - } - overrideTransform(after, transition.transform[AFTER], propsParserContext); - after.tag = containerTag; - updateLayoutAnimationTarget(containerTag, after, config); + const auto config = layoutAnimationsManager_->getLayoutAnimationConfig( + before.tag, LayoutAnimationType::SHARED_ELEMENT_TRANSITION); + if (!config) { continue; } - const auto layoutAnimationIt = layoutAnimations_.find(containerTag); - if (layoutAnimationIt == layoutAnimations_.end()) { + const auto &afterNode = collectedTransition.nodes[AFTER]; + react_native_assert(afterNode && "Shared transition target not found"); + if (!afterNode) { continue; } - const auto &la = layoutAnimationIt->second; - if (la.finalView.layoutMetrics != after.layoutMetrics) { - const auto config = layoutAnimationsManager_->getLayoutAnimationConfig( - before.tag, LayoutAnimationType::SHARED_ELEMENT_TRANSITION); - if (!config) { - continue; - } - overrideTransform(after, transition.transform[AFTER], propsParserContext); - after.tag = containerTag; - startSharedTransition(containerTag, la.currentView, after, config); + auto &container = sharedContainers_.at(containerTag); + if (container.restoreAfterNode && container.restoreAfterNode != afterNode) { + transaction.nodesToRestore.push_back(container.restoreAfterNode); + } + container.restoreAfterNode = afterNode; + overrideTransform(after, transition.transform[AFTER], propsParserContext); + after.tag = containerTag; + if (hasPendingLayoutAnimation(containerTag)) { + updateLayoutAnimationTarget(containerTag, after, config); + continue; + } + const auto &layoutAnimation = layoutAnimations_.at(containerTag); + if (layoutAnimation.finalView.layoutMetrics != after.layoutMetrics) { + startSharedTransition(containerTag, layoutAnimation.currentView, after, config); } } } @@ -322,16 +376,22 @@ void LayoutAnimationsProxy_Experimental::handleSharedTransitionsStart( void LayoutAnimationsProxy_Experimental::hideTransitioningViews( BeforeOrAfter index, - const Transitions &transitions, ShadowViewMutationList &mutations, + TransactionMeta &transaction, const PropsParserContext &propsParserContext) const { - for (auto &[sharedTag, transition] : transitions) { + for (const auto &[_, collectedTransition] : transaction.transitions) { + const auto &transition = collectedTransition.transition; int indexNum = static_cast(index); const auto &shadowView = transition.snapshot[indexNum]; const auto &parentTag = transition.parentTag[indexNum]; auto m = ShadowViewMutation::UpdateMutation( shadowView, cloneViewWithoutOpacity(shadowView, propsParserContext), parentTag); mutations.push_back(m); + const auto &node = collectedTransition.nodes[indexNum]; + react_native_assert(node && "Shared transition view not found"); + if (node) { + transaction.hiddenNodes.insert(node); + } } } @@ -398,8 +458,13 @@ void LayoutAnimationsProxy_Experimental::insertContainers(TransactionMeta &trans } void LayoutAnimationsProxy_Experimental::removeSharedContainer(Tag containerTag, TransactionMeta &transaction) const { - transaction.sharedContainersToRemove.push_back(containerTag); - std::erase_if(containerTags_, [containerTag](const auto &entry) { return entry.second == containerTag; }); + const auto containerIt = sharedContainers_.find(containerTag); + react_native_assert(containerIt != sharedContainers_.end() && "Unknown shared container"); + if (containerIt == sharedContainers_.end()) { + return; + } + transaction.containersToRemove.push_back(std::move(containerIt->second.node)); + sharedContainers_.erase(containerIt); } void LayoutAnimationsProxy_Experimental::cleanupSharedTransitions( @@ -407,33 +472,43 @@ void LayoutAnimationsProxy_Experimental::cleanupSharedTransitions( const PropsParserContext &propsParserContext) const { ReanimatedSystraceSection s1("cleanupSharedTransitions"); auto &filteredMutations = transaction.filteredMutations; - for (auto &tag : transaction.tagsToRestore) { + for (const auto &node : transaction.nodesToRestore) { ReanimatedSystraceSection s("Restore tag"); - auto &node = lightNodes_[tag]; - if (node) { - auto view = node->current; - const auto &parent = node->parent.lock(); - react_native_assert(parent && "Parent node is nullptr"); - auto parentTag = parent->current.tag; - const auto opacity = static_cast(*view.props).opacity; - auto m = ShadowViewMutation::UpdateMutation( - cloneViewWithoutOpacity(view, propsParserContext), - cloneViewWithOpacity(view, opacity, propsParserContext), - parentTag); - filteredMutations.push_back(m); + if (transaction.hiddenNodes.contains(node)) { + continue; + } + const auto nodeIt = lightNodes_.find(node->current.tag); + if (nodeIt == lightNodes_.end() || nodeIt->second != node) { + continue; + } + auto view = node->current; + const auto parent = node->parent.lock(); + react_native_assert(parent && "Parent node is nullptr"); + if (!parent) { + continue; } + const auto opacity = static_cast(*view.props).opacity; + filteredMutations.push_back(ShadowViewMutation::UpdateMutation( + cloneViewWithoutOpacity(view, propsParserContext), + cloneViewWithOpacity(view, opacity, propsParserContext), + parent->current.tag)); } ReanimatedSystraceSection s2("remove shared containers"); - for (auto &tag : transaction.sharedContainersToRemove) { - auto root = lightNodes_[surfaceId_]; - for (int i = 0; i < root->children.size(); i++) { - auto &child = root->children[i]; - if (child->current.tag == tag) { - filteredMutations.push_back(ShadowViewMutation::RemoveMutation(surfaceId_, child->current, i)); - filteredMutations.push_back(ShadowViewMutation::DeleteMutation(child->current)); - root->children.erase(root->children.begin() + i); - } + const auto root = lightNodes_.at(surfaceId_); + for (const auto &node : transaction.containersToRemove) { + const auto childIt = std::ranges::find(root->children, node); + react_native_assert(childIt != root->children.end() && "Shared container is not mounted"); + if (childIt != root->children.end()) { + const auto index = static_cast(std::distance(root->children.begin(), childIt)); + filteredMutations.push_back(ShadowViewMutation::RemoveMutation(surfaceId_, node->current, index)); + filteredMutations.push_back(ShadowViewMutation::DeleteMutation(node->current)); + root->children.erase(childIt); + } + + const auto nodeIt = lightNodes_.find(node->current.tag); + if (nodeIt != lightNodes_.end() && nodeIt->second == node) { + lightNodes_.erase(nodeIt); } } } From e4a0e81aaf294bcda0c282afaf02155eac99d616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Tue, 25 Aug 2026 14:49:37 +0200 Subject: [PATCH 2/4] chore(LayoutAnimations): fix stale comment and missing include --- .../cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.cpp | 2 +- .../cpp/reanimated/LayoutAnimations/SharedTransitions.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.cpp index b6d7fbe3a1f8..23696d5ab173 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.cpp @@ -27,7 +27,7 @@ void LayoutAnimationsManager::configureAnimationBatch(const std::vectortagToName_.erase(tag); } else { diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp index 78891e3d8ada..18443dbe750d 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include namespace reanimated { From 79f8de4df0a9ed4ef382990fcc0a80b6474cc73a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Fri, 11 Sep 2026 17:17:41 +0200 Subject: [PATCH 3/4] fix(LayoutAnimations): drop redundant active-animation check in cleanupAnimations Starting an animation on a tag erases its completedAnimations_ entry, so a completed tag can never also be in layoutAnimations_. --- .../LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp index 45dc0a77f530..1017ab1e620e 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp @@ -740,8 +740,8 @@ void LayoutAnimationsProxy_Experimental::cleanupAnimations( TransactionMeta &transaction, const PropsParserContext &propsParserContext) const { ReanimatedSystraceSection s("cleanupAnimations"); - for (const auto &[tag, completedAnimation] : completedAnimations_) { - if (hasPendingLayoutAnimation(tag) || layoutAnimations_.contains(tag)) { + for (const auto &[tag, _] : completedAnimations_) { + if (hasPendingLayoutAnimation(tag)) { continue; } const auto containerIt = sharedContainers_.find(tag); From b12201de85f9af5b733e5b81255786b7f513139b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Mon, 14 Sep 2026 09:37:54 +0200 Subject: [PATCH 4/4] fix(LayoutAnimations): restore a shared target whose transition completes within the pull A shared transition whose source and target match exactly has nothing to animate, so it completes inside the pull that started it and cleanup removes its container right away. The restore of the hidden target was then skipped because the target had been hidden in the same pull, leaving it at opacity 0. The skip existed to keep a previous target hidden when it becomes the source or target of the transition being started. Decide that where the old target is queued instead: restoreOldTarget skips only nodes the new transition hides, and the per-pull hidden set is gone. --- .../LayoutAnimationsProxy_Experimental.h | 7 ++-- .../LayoutAnimations/SharedTransitions.cpp | 35 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h index ea1fb848167c..55b9bd2fa3c7 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h +++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h @@ -65,7 +65,6 @@ struct TransactionMeta { std::vector exiting; std::vector> containersToInsert; std::vector> nodesToRestore; - std::unordered_set> hiddenNodes; std::vector> containersToRemove; }; @@ -156,7 +155,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon { std::vector getAbsolutePositionsForRootPathView(const std::shared_ptr &node) const; - Tag getOrCreateContainer(const ShadowView &before, const SharedTag &sharedTag, TransactionMeta &transaction) const; + Tag getOrCreateContainer( + const ShadowView &before, + const SharedTag &sharedTag, + const std::array, 2> &nodes, + TransactionMeta &transaction) const; void overrideTransform( ShadowView &shadowView, diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp index 18443dbe750d..07c836172d1b 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp @@ -101,6 +101,16 @@ void LayoutAnimationsProxy_Experimental::findSharedElementsOnScreen( } } +static void restoreOldTarget( + const SharedContainer &container, + const std::array, 2> &nodes, + TransactionMeta &transaction) { + const auto &oldTarget = container.restoreAfterNode; + if (oldTarget && oldTarget != nodes[BEFORE] && oldTarget != nodes[AFTER]) { + transaction.nodesToRestore.push_back(oldTarget); + } +} + void LayoutAnimationsProxy_Experimental::handleProgressTransition( TransactionMeta &transaction, const ShadowViewMutationList &mutations, @@ -137,12 +147,10 @@ void LayoutAnimationsProxy_Experimental::handleProgressTransition( continue; } - const auto containerTag = getOrCreateContainer(before, sharedTag, transaction); + const auto containerTag = getOrCreateContainer(before, sharedTag, collectedTransition.nodes, transaction); auto &container = sharedContainers_.at(containerTag); container.restoreBeforeNode = beforeNode; - if (container.restoreAfterNode && container.restoreAfterNode != afterNode) { - transaction.nodesToRestore.push_back(container.restoreAfterNode); - } + restoreOldTarget(container, collectedTransition.nodes, transaction); container.restoreAfterNode = afterNode; before.tag = containerTag; after.tag = containerTag; @@ -248,6 +256,7 @@ void LayoutAnimationsProxy_Experimental::overrideTransform( Tag LayoutAnimationsProxy_Experimental::getOrCreateContainer( const ShadowView &before, const SharedTag &sharedTag, + const std::array, 2> &nodes, TransactionMeta &transaction) const { auto containerTag = Tag{-1}; for (const auto &[tag, container] : sharedContainers_) { @@ -261,9 +270,7 @@ Tag LayoutAnimationsProxy_Experimental::getOrCreateContainer( } const auto &container = sharedContainers_.at(containerTag); react_native_assert(completedAnimations_.contains(containerTag) && "Shared container has no animation"); - if (container.restoreAfterNode) { - transaction.nodesToRestore.push_back(container.restoreAfterNode); - } + restoreOldTarget(container, nodes, transaction); removeSharedContainer(containerTag, transaction); } @@ -320,11 +327,9 @@ void LayoutAnimationsProxy_Experimental::handleSharedTransitionsStart( if (!afterNode) { continue; } - auto containerTag = getOrCreateContainer(before, sharedTag, transaction); + auto containerTag = getOrCreateContainer(before, sharedTag, collectedTransition.nodes, transaction); auto &container = sharedContainers_.at(containerTag); - if (container.restoreAfterNode && container.restoreAfterNode != afterNode) { - transaction.nodesToRestore.push_back(container.restoreAfterNode); - } + restoreOldTarget(container, collectedTransition.nodes, transaction); container.restoreAfterNode = afterNode; before.tag = containerTag; after.tag = containerTag; @@ -388,11 +393,6 @@ void LayoutAnimationsProxy_Experimental::hideTransitioningViews( auto m = ShadowViewMutation::UpdateMutation( shadowView, cloneViewWithoutOpacity(shadowView, propsParserContext), parentTag); mutations.push_back(m); - const auto &node = collectedTransition.nodes[indexNum]; - react_native_assert(node && "Shared transition view not found"); - if (node) { - transaction.hiddenNodes.insert(node); - } } } @@ -475,9 +475,6 @@ void LayoutAnimationsProxy_Experimental::cleanupSharedTransitions( auto &filteredMutations = transaction.filteredMutations; for (const auto &node : transaction.nodesToRestore) { ReanimatedSystraceSection s("Restore tag"); - if (transaction.hiddenNodes.contains(node)) { - continue; - } const auto nodeIt = lightNodes_.find(node->current.tag); if (nodeIt == lightNodes_.end() || nodeIt->second != node) { continue;