Repository navigation
fix(LayoutAnimations): don't crash when a view hidden under LayoutAnimation is shown again - #10916
Conversation
…ation removes and re-creates in one transaction On iOS, React Native hides a `display: none` subtree (also react-freeze and `<Activity mode="hidden">`) by taking its host views out of the mounted tree: the differ emits Remove+Delete, and showing it again emits Create+Insert with the same tags. With a LayoutAnimation delete config, LayoutAnimationKeyFrameManager runs before the proxy and defers each Remove+Delete as a keyframe. When the subtree is shown again before the keyframe ends, the Create conflicts with it, so the deferred Remove+Delete are flushed into the same transaction, ahead of the Create+Insert. reconcileContradictedRemovals only handled a Create of a node whose removal the proxy already withholds. Here the node is still LIVE, so updateLightTree processed the Remove+Delete, started the view's Reanimated exiting animation and kept the node mapped. The following Create then hit the "LightNode already exists" assert in debug builds. In release builds, the node was overwritten while its view stayed mounted. reconcileContradictedRemovals now handles both cases of React re-creating a tag that the light tree still holds. Both keep the mounted view: the Create becomes an Update of it and the Delete is dropped, because Android mounts the Deletes of a transaction after its Creates. A withheld node is torn down as before. A live node is converted only when the same transaction deletes it, and the conversion happens in place in React's mutations, so updateLightTree sees a plain move (Remove+Insert) and the exiting config is not consumed. A Create of a live tag that the transaction does not delete still hits the assert. Because the live view only moves, showing it again does not run its Reanimated entering animation, unlike a withheld view, which is inserted again. React Native's LayoutAnimation already handles the view's opacity in that case.
📝 Walkthrough
Priority: ➖ Normal Change: Bug fix · Severity of issue fixed: Medium Merge Risk: 🟡 Moderate · up to
|
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
e2c58650-79da-4266-bc16-60093ebd880c
📒 Files selected for processing: 5
apps/common-app/runtime-tests/reanimated/suites.tsapps/common-app/runtime-tests/reanimated/tests/layoutAnimations/hiddenUnderLayoutAnimation.test.tsxpackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy.cpppackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy.hpackages/react-native-reanimated/changelog/layout-animation-recreated-live-view.fix.md
Summary
On iOS, React Native hides a subtree (
display: none, react-freeze,<Activity mode="hidden">) by removing its host views from the mounted tree. Showing it again creates the same tags. When the hide runs underLayoutAnimationwith a delete config, React Native'sLayoutAnimationKeyFrameManagerdefers the Remove and Delete. If the subtree is shown again before that animation ends, the deferred Remove and Delete arrive in the same transaction as the Create of the same tag.reconcileContradictedRemovalsonly handled a Create of a node whose removal the proxy withholds. Here the node is still live, soupdateLightTreestarted the view's Reanimated exiting animation and kept the node. The following Create then aborted onLightNode already existsin debug builds. In release builds, the proxy overwrote the node while the view stayed mounted.Now
reconcileContradictedRemovalsalso handles a live node that the same transaction deletes and creates again. As for a withheld node, the view stays mounted: the Create becomes an Update of the mounted view and the Delete is dropped. The proxy then sees a move, so the exiting animation does not start and the view keeps its layout animation configs. Showing the view again does not run itsenteringanimation, because the view is moved rather than inserted. A Create of a live tag that the transaction does not delete still asserts.Example
Wis a wrapper that getsdisplay: none, andBis anAnimated.Viewwithexitinginside it. Each diagram shows React's host tree, the proxy's light tree and the mounted native views at one point in time.flowchart LR subgraph react["React host tree"] direction TB r1[Root] --> rw1[W] --> rb1[B] end subgraph light["Light tree"] direction TB l1[Root] --> lw1["W · LIVE"] --> lb1["B · LIVE"] end subgraph native["Native views"] direction TB n1[Root] --> nw1[W] --> nb1[B] end react ~~~ light ~~~ nativeWin a commit underLayoutAnimation.configureNext. React's diff removes and deletesWandB.LayoutAnimationKeyFrameManagerholds those mutations back and fades both views out, so the proxy only receives opacity Updates. The light tree and the native views still holdWandB.flowchart LR subgraph react["React host tree"] direction TB r2[Root] end subgraph light["Light tree"] direction TB l2[Root] --> lw2["W · LIVE"] --> lb2["B · LIVE"] end subgraph native["Native views"] direction TB n2[Root] --> nw2["W · fading out"] --> nb2["B · fading out"] end react ~~~ light ~~~ nativeWagain before the fade ends. Its diff createsWandBwith the same tags. Their Creates conflict with the running fade, soLayoutAnimationKeyFrameManagersends the held-back Removes and Deletes in the same transaction: Remove, Delete, Create and Insert ofWandB.Before this change, the proxy handled the Removes and Deletes as a removal of
W. It startedB's exiting animation and kept both nodes in the light tree whileBanimated. The Create ofWthen found its tag still in the light tree.flowchart LR subgraph react["React host tree"] direction TB r3[Root] --> rw3["W (same tag)"] --> rb3["B (same tag)"] end subgraph light["Light tree after Remove and Delete"] direction TB l3[Root] --> lw3["W · WAITING for B"] --> lb3["B · ANIMATING exiting"] c3["Create W: LightNode already exists"] end subgraph native["Native views"] direction TB n3[Root] --> nw3[W] --> nb3[B] end react ~~~ light ~~~ native classDef exiting stroke-dasharray: 5 5 classDef failure fill:#f8d7da,stroke:#c0392b,color:#7b1d1d class lw3,lb3 exiting class c3 failureNow the proxy turns each Create into an Update of the mounted view and drops the Delete before it updates the light tree. What remains is a Remove and an Insert of the same views, which the proxy handles as a move. No exiting animation starts, and the native views stay the same.
flowchart LR subgraph react["React host tree"] direction TB r4[Root] --> rw4["W (same tag)"] --> rb4["B (same tag)"] end subgraph light["Light tree"] direction TB l4[Root] --> lw4["W · LIVE"] --> lb4["B · LIVE, keeps its exiting config"] end subgraph native["Native views"] direction TB n4[Root] --> nw4["W · same view, opacity 1"] --> nb4["B · same view, opacity 1"] end react ~~~ light ~~~ nativeTest plan
layout animations hidden under LayoutAnimation. It hides anAnimated.ViewwithexitingunderLayoutAnimation, shows it again while the animation runs, and checks that the view keeps its tag, becomes opaque again and still runs its exiting animation when it unmounts. It coversdisplay: none, react-freeze andActivity.main, all three cases abort onLightNode already existsin an iOS DebugRuntimeTests build.Changelog
yarn changelog:addfor each changed package, or this PR does not changereact-native-reanimatedorreact-native-worklets.