From fcd31e184280a1e35cc741037115020dbbc46750 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 27 Jul 2026 15:16:54 +0200 Subject: [PATCH 1/2] Fix crash from withheld exiting layout animations in experimental proxy When React re-creates a tag whose exiting removal is still withheld (e.g. a Suspense boundary re-suspending over animating views), the experimental proxy either overwrote or missed the stale bookkeeping, letting the old still-mounted view corrupt the mounting layer. Port of the reconcile fix from #9821 to LayoutAnimationsProxy_Experimental, extended to its bookkeeping model: reconcile contradicted removals for nodes tracked in lightNodes_ and deadNodes, register WAITING subtree members in lightNodes_ so the reconcile can find them, erase lightNodes_ entries when withheld nodes are deleted, and guard the endLayoutAnimation lookup against a release-mode null deref. Co-Authored-By: Claude Fable 5 --- .../LayoutAnimationsProxy_Experimental.cpp | 88 ++++++++++++++++++- .../LayoutAnimationsProxy_Experimental.h | 3 + 2 files changed, 88 insertions(+), 3 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 bc62f8fc31a1..84ded7d2b2e6 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 @@ -30,6 +30,8 @@ std::optional LayoutAnimationsProxy_Experimental::pullTrans const std::vector> roots; const bool isInTransition = static_cast(transitionState_); + reconcileContradictedRemovals(mutations, filteredMutations); + if (isInTransition) { updateLightTree(propsParserContext, mutations, filteredMutations); handleProgressTransition(filteredMutations, mutations, propsParserContext, surfaceId); @@ -107,6 +109,68 @@ std::optional LayoutAnimationsProxy_Experimental::pullTrans return MountingTransaction{surfaceId, transactionNumber, std::move(filteredMutations), telemetry}; } +// If React re-creates or re-inserts a tag whose exiting removal we are still +// withholding, it has contradicted that withheld removal. Flush it now instead +// of letting the stale node linger: updateLightTree would overwrite its +// lightNodes_ entry (the "LightNode already exists" assert is compiled out in +// release), orphaning the still-mounted exiting view, and the eventual +// endLayoutAnimation would then remove the wrong, live view and crash the +// mounting layer. +// +// This must run before updateLightTree (so the tag is re-registered cleanly) +// and before addOngoingAnimations (which would otherwise emit an Update for a +// tag we are about to Delete this frame). +void LayoutAnimationsProxy_Experimental::reconcileContradictedRemovals( + const ShadowViewMutationList &mutations, + ShadowViewMutationList &filteredMutations) const { + for (const auto &mutation : mutations) { + if (mutation.type != ShadowViewMutation::Type::Create && mutation.type != ShadowViewMutation::Type::Insert) { + continue; + } + const auto tag = mutation.newChildShadowView.tag; + std::shared_ptr node; + if (const auto it = lightNodes_.find(tag); it != lightNodes_.end() && it->second->state != UNDEFINED) { + node = it->second; + lightNodes_.erase(it); + if (node->state == DELETED) { + // already unmounted — only the stale map entry had to go + continue; + } + } else { + // A settled exiting view (state DEAD) has already left lightNodes_ but is + // still mounted, awaiting the deadNodes cleanup in handleRemovals. That + // cleanup runs at the end of the transaction — after this Create would + // have re-registered the tag in the mounting layer's view registry — so + // it must be flushed now instead. + const auto deadIt = std::find_if(deadNodes.begin(), deadNodes.end(), [tag](const auto &deadNode) { + return deadNode->current.tag == tag; + }); + if (deadIt == deadNodes.end()) { + continue; + } + node = *deadIt; + deadNodes.erase(deadIt); + if (node->state == DELETED) { + continue; + } + } + // Flush the withheld removal for this tag (and its withheld subtree) right + // now, mirroring the deadNodes cleanup in handleRemovals. + const auto parent = node->parent.lock(); + react_native_assert(parent && "Parent node is nullptr"); + if (!parent) { + continue; + } + const auto index = parent->removeChild(node); + react_native_assert(index != -1 && "Exiting node not found"); + if (index == -1) { + continue; + } + endAnimationsRecursively(node, index, filteredMutations); + maybeDropAncestors(parent, filteredMutations); + } +} + bool LayoutAnimationsProxy_Experimental::shouldOverridePullTransaction() const { // we need to listen to every possible mutation to keep the light tree updated return true; @@ -305,11 +369,18 @@ std::optional LayoutAnimationsProxy_Experimental::endLayoutAnimation( return surfaceId; } - auto node = lightNodes_[tag]; - react_native_assert(node && "LightNode not found"); + const auto nodeIt = lightNodes_.find(tag); + // the withheld removal may have already been flushed (e.g. reconciled after + // React re-created the tag) — the assert alone is compiled out in release + // and operator[] would insert a null node here + if (nodeIt == lightNodes_.end() || !nodeIt->second) { + react_native_assert(false && "LightNode not found"); + return surfaceId; + } + auto node = nodeIt->second; node->state = DEAD; - lightNodes_.erase(tag); + lightNodes_.erase(nodeIt); deadNodes.insert(node); return surfaceId; @@ -349,6 +420,7 @@ void LayoutAnimationsProxy_Experimental::handleRemovals( parent->children.push_back(node); if (node->state == UNDEFINED) { node->state = WAITING; + lightNodes_[node->current.tag] = node; } } else { maybeCancelAnimation(node->current.tag); @@ -432,6 +504,10 @@ void LayoutAnimationsProxy_Experimental::endAnimationsRecursively( ShadowViewMutationList &mutations) const { maybeCancelAnimation(node->current.tag); node->state = DELETED; + // drop the tag mapping unless it was already re-registered for a new node + if (const auto it = lightNodes_.find(node->current.tag); it != lightNodes_.end() && it->second == node) { + lightNodes_.erase(it); + } // iterate from the end, so that children // with higher indices appear first in the mutations list @@ -463,6 +539,9 @@ void LayoutAnimationsProxy_Experimental::maybeDropAncestors( react_native_assert(index != -1 && "Child node not found"); node->state = DELETED; + if (const auto it = lightNodes_.find(node->current.tag); it != lightNodes_.end() && it->second == node) { + lightNodes_.erase(it); + } maybeCancelAnimation(node->current.tag); cleanupMutations.push_back(ShadowViewMutation::RemoveMutation(parent->current.tag, node->current, index)); cleanupMutations.push_back(ShadowViewMutation::DeleteMutation(node->current)); @@ -515,6 +594,9 @@ bool LayoutAnimationsProxy_Experimental::startAnimationsRecursively( mutations.push_back(ShadowViewMutation::DeleteMutation(subNode->current)); } else { subNode->state = WAITING; + // register withheld subtree members, so that reconcileContradictedRemovals + // can find them when React re-creates their tags + lightNodes_[subNode->current.tag] = subNode; } } 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 fcc677f02006..d6b9a29636c8 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 @@ -109,6 +109,9 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon, const ShadowViewMutationList &mutations, ShadowViewMutationList &filteredMutations) const; + void reconcileContradictedRemovals(const ShadowViewMutationList &mutations, ShadowViewMutationList &filteredMutations) + const; + void handleSharedTransitionsStart( const std::shared_ptr &afterTopScreen, const std::shared_ptr &beforeTopScreen, From 066e79dc796f5abacbaecce39e5b3f712c716e6d Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 27 Jul 2026 17:34:39 +0200 Subject: [PATCH 2/2] Reformat files --- .../LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 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 84ded7d2b2e6..0f0862251891 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 @@ -142,9 +142,8 @@ void LayoutAnimationsProxy_Experimental::reconcileContradictedRemovals( // cleanup runs at the end of the transaction — after this Create would // have re-registered the tag in the mounting layer's view registry — so // it must be flushed now instead. - const auto deadIt = std::find_if(deadNodes.begin(), deadNodes.end(), [tag](const auto &deadNode) { - return deadNode->current.tag == tag; - }); + const auto deadIt = std::find_if( + deadNodes.begin(), deadNodes.end(), [tag](const auto &deadNode) { return deadNode->current.tag == tag; }); if (deadIt == deadNodes.end()) { continue; }