Skip to content

refactor(LayoutAnimations): make shared container ownership explicit - #10370

Merged
bartlomiejbloniarz merged 4 commits into
mainfrom
yzm/03-container-ownership
Sep 15, 2026
Merged

bartlomiejbloniarz merged 4 commits into
mainfrom
yzm/03-container-ownership

Conversation

@bartlomiejbloniarz

Copy link
Copy Markdown
Member

Before this change, the state of one shared container was split across several maps and sets. Tags connected these structures, but no single object owned the complete container state. A cleanup path could remove one part and leave another behind. Tag reuse could then match a new node with stale restore state.

Now sharedContainers_ stores one SharedContainer record with the container node and its restore nodes. Creation, updates, and removeSharedContainer all work on that record. This gives each container one ownership boundary and one cleanup path. Complete cleanup is now enforced by the structure of the data.

Test Plan

  • Run shared transition examples in both navigation directions.
  • Repeat a transition after returning to the source screen.

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The experimental layout-animation proxy now uses per-surface state and TransactionMeta for transaction-scoped animation data. Layout and transition configurations flow into typed animation requests. Shared transitions track source and target nodes, validate containers, restore nodes, and remove mounted containers through transaction state. The layout animation manager now exposes configuration retrieval and exiting-configuration extraction. Surface unmount clears shared containers. A changelog entry documents shared-transition fixes.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: making shared-container ownership explicit in layout animations.
Description check ✅ Passed The description directly explains the shared-container ownership refactor, cleanup behavior, stale restore-state prevention, and test plan.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp (1)

238-277: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Bruce thinks the duplicated "newest container for a shared tag" scan is the real smoking gun here.

Lines 239-243 scan sharedContainers_ for the highest tag with a matching sharedTag. Lines 321-327 in handleSharedTransitionsStart repeat the same scan with an extra animation filter. Extract one helper so both call sites stay in sync when the selection rule changes.

♻️ Suggested helper
// LayoutAnimationsProxy_Experimental.h
Tag findNewestContainer(const SharedTag &sharedTag, bool requireActiveAnimation) const;

// SharedTransitions.cpp
Tag LayoutAnimationsProxy_Experimental::findNewestContainer(
    const SharedTag &sharedTag,
    const bool requireActiveAnimation) const {
  auto result = Tag{-1};
  for (const auto &[tag, container] : sharedContainers_) {
    if (container.sharedTag != sharedTag || tag <= result) {
      continue;
    }
    if (requireActiveAnimation && !hasPendingLayoutAnimation(tag) && !layoutAnimations_.contains(tag)) {
      continue;
    }
    result = tag;
  }
  return result;
}

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ef7389da-c1d7-4abf-bf73-340a8cb20949

📥 Commits

Reviewing files that changed from the base of the PR and between a263940 and 4570a46.

📒 Files selected for processing (4)
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.h
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp
💤 Files with no reviewable changes (1)
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.h

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch from 4570a46 to de30f18 Compare August 24, 2026 07:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp (2)

154-158: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add the null check that endLayoutAnimation already applies.

Bruce found the real smoking gun here on line 155. it->second->state dereferences the mapped value without checking it. lightNodes_ is populated with operator[] in several places in this file, for example lines 224, 268, 269, 300, and 303, and operator[] inserts a null shared_ptr for an absent key. A null entry survives into the next transaction, and reconcileContradictedRemovals runs first in pullTransaction.

The comment at lines 430-432 in endLayoutAnimation states this hazard and guards against it. Apply the same guard here. The rest of this function is already release-safe, with if (!parent) and if (index == -1).

🛡️ Proposed guard
     const auto it = lightNodes_.find(tag);
-    if (it == lightNodes_.end() || it->second->state == UNDEFINED) {
+    if (it == lightNodes_.end() || !it->second || it->second->state == UNDEFINED) {
       continue;
     }

499-511: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard parent and index before use, as the sibling cleanup path does.

Bruce found the real smoking gun here in the new completed-removal loop. Lines 505 and 507 use react_native_assert, which compiles out in release builds. Line 506 then dereferences parent, and line 509 passes index to endAnimationsRecursively, which builds ShadowViewMutation::RemoveMutation(parent->current.tag, node->current, index) at line 597. In release, an index of -1 reaches the mounting layer as an out-of-range child removal.

The path is reachable inside this same loop. maybeDropAncestors at line 510 detaches ancestors and clears children, so a later tag in completedRemovalTags can resolve to a node whose parent link is already gone.

reconcileContradictedRemovals handles the identical situation at lines 166-175 and returns early on both conditions. Line 500 also needs the null check described in my comment on lines 154-158; restating the reasoning would be the virtual smoking gun here.

🛡️ Proposed guards
     const auto nodeIt = lightNodes_.find(tag);
-    if (nodeIt == lightNodes_.end() || nodeIt->second->state != DEAD) {
+    if (nodeIt == lightNodes_.end() || !nodeIt->second || nodeIt->second->state != DEAD) {
       continue;
     }
     const auto node = nodeIt->second;
     auto parent = node->parent.lock();
     react_native_assert(parent && "Parent node is nullptr");
+    if (!parent) {
+      continue;
+    }
     auto index = parent->removeChild(node);
     react_native_assert(index != -1 && "Dead node not found");
+    if (index == -1) {
+      continue;
+    }
 
     endAnimationsRecursively(node, index, filteredMutations);
     maybeDropAncestors(parent, filteredMutations);

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cbfc8a2e-62d7-4ea3-a8c4-132fdb607635

📥 Commits

Reviewing files that changed from the base of the PR and between 4570a46 and de30f18.

📒 Files selected for processing (2)
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.

@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch from de30f18 to 4ddc75a Compare August 24, 2026 14:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp (1)

109-111: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Look up transitionTag_ with find before dereferencing it.

Bruce found the real smoking gun here. lightNodes_[transitionTag_] uses operator[], which inserts a null std::shared_ptr<LightNode> when the tag is gone. findBoundaryGuess then dereferences that null pointer in isSETBoundary(node), so the app crashes. The map also keeps the null entry, so later lookups report the tag as present.

transitionTag_ is set in an earlier transaction by onTransitionProgress. The screen node can be unmounted before this handler runs. Lines 396-399 and 427-430 already guard the same map with find, so apply the same guard here. Do not return early, because the state machine at lines 187-212 must still run.

🐛 Proposed fix
     auto beforeTopScreen = topScreen_;
-    auto afterTopScreen = findBoundaryGuess(lightNodes_[transitionTag_]);
+    std::shared_ptr<LightNode> afterTopScreen;
+    if (const auto transitionNodeIt = lightNodes_.find(transitionTag_);
+        transitionNodeIt != lightNodes_.end() && transitionNodeIt->second) {
+      afterTopScreen = findBoundaryGuess(transitionNodeIt->second);
+    }
     if (beforeTopScreen && afterTopScreen && beforeTopScreen != afterTopScreen) {

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b3defd30-2360-4bc6-b635-9a443ab32e7f

📥 Commits

Reviewing files that changed from the base of the PR and between de30f18 and 4ddc75a.

📒 Files selected for processing (2)
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.h
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.

@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch from 4ddc75a to e08485b Compare August 24, 2026 15:35
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch from e08485b to c394a8f Compare August 24, 2026 15:38
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch 2 times, most recently from 2234ebf to dfee579 Compare August 24, 2026 15:51
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch 3 times, most recently from 8e85dd6 to f020535 Compare August 24, 2026 16:03
@bartlomiejbloniarz

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bartlomiejbloniarz

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bartlomiejbloniarz

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bartlomiejbloniarz

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch from f020535 to 3e6e9d7 Compare August 25, 2026 13:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp (1)

110-112: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Look up transitionTag_ with find instead of operator[].

lightNodes_[transitionTag_] inserts a null std::shared_ptr<LightNode> when the tag is absent. Two consequences follow. findBoundaryGuess then dereferences a null node. The map also keeps a null entry for that tag, which later lookups in cleanupSharedTransitions and getOrCreateContainer must tolerate.

onTransitionProgress validates the node only when it sets transitionTag_. The screen can unmount before this transaction runs.

🛡️ Proposed fix
-    auto beforeTopScreen = topScreen_;
-    auto afterTopScreen = findBoundaryGuess(lightNodes_[transitionTag_]);
+    auto beforeTopScreen = topScreen_;
+    const auto transitionNodeIt = lightNodes_.find(transitionTag_);
+    const auto afterTopScreen = (transitionNodeIt == lightNodes_.end() || !transitionNodeIt->second)
+        ? nullptr
+        : findBoundaryGuess(transitionNodeIt->second);
🧹 Nitpick comments (1)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp (1)

314-321: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated container selection and restore-node update.

Three blocks repeat the same logic. Lines 244-248 and lines 330-336 both scan sharedContainers_ for the newest container with a matching sharedTag. Lines 316-319 and lines 351-354 both queue the previous restoreAfterNode and then assign the new one. Two small private helpers, for example findNewestContainerForTag(sharedTag, requireActive) and setRestoreAfterNode(container, afterNode, transaction), keep the two branches in sync when this logic changes again.

Also applies to: 330-336, 350-354


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b03246b5-c857-468a-ab83-829232dbec4d

📥 Commits

Reviewing files that changed from the base of the PR and between f020535 and 3e6e9d7.

📒 Files selected for processing (2)
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsManager.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/SharedTransitions.cpp

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.

@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch from 3e6e9d7 to c2d66a1 Compare August 25, 2026 14:23
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch from c2d66a1 to fa0f195 Compare August 26, 2026 08:55
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch 2 times, most recently from 2fd1e22 to 2f2e07e Compare August 26, 2026 09:38
@bartlomiejbloniarz
bartlomiejbloniarz marked this pull request as ready for review August 26, 2026 10:15
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch from 2f2e07e to 3c006f8 Compare August 26, 2026 15:42
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch 2 times, most recently from c5d9b0e to e9deafb Compare August 28, 2026 15:19
Base automatically changed from yzm/02-centralize-operations to main August 31, 2026 07:52
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/03-container-ownership branch from e9deafb to 6929ca8 Compare August 31, 2026 07:52
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.
…upAnimations

Starting an animation on a tag erases its completedAnimations_ entry, so a
completed tag can never also be in layoutAnimations_.
…etes 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.
@bartlomiejbloniarz
bartlomiejbloniarz merged commit 9a56869 into main Sep 15, 2026
22 checks passed
@bartlomiejbloniarz
bartlomiejbloniarz deleted the yzm/03-container-ownership branch September 15, 2026 09:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants