Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import React, { Suspense, useReducer, useRef, useState } from 'react';
import { Button, Text, View } from 'react-native';
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
} from 'react-native-reanimated';

// Minimal reproduction of a Fabric mounting crash caused by Reanimated's
// legacy LayoutAnimationsProxy (EXC_BAD_ACCESS at 0x18 on RN 0.83 /
// NSRangeException in -[RCTMountingManager performTransaction:] on RN 0.85+).
//
// Required ingredients:
// 1. a Suspense boundary that re-suspends whenever `mode` changes
// (offscreen hide + fallback swap, not a normal unmount),
// 2. Animated.FlatList with itemLayoutAnimation,
// 3. list items with layout + entering + EXITING animations — exiting is
// essential: it makes the proxy withhold Remove/Delete mutations,
// 4. a conditional subview swap with entering+exiting inside each item,
// 5. mixed Insert/Remove/Update churn in one parent (stable keys whose text
// changes + keys that mount/unmount per mode).
//
// Tapping the button alternates mode switches and swap toggles every 200ms,
// so each state change lands while exiting/layout animations from the
// previous one are still in flight. Release builds crash within seconds.

const SUSPEND_MS = 150;

const cache = new Map<
string,
{ status: 'pending' | 'done'; promise: Promise<void> }
>();

function useSuspendedData(key: string) {
let entry = cache.get(key);
if (!entry) {
const promise = new Promise<void>((resolve) => {
setTimeout(() => {
const e = cache.get(key);
if (e) {
e.status = 'done';
}
resolve();
}, SUSPEND_MS);
});
entry = { status: 'pending', promise };
cache.set(key, entry);
}
if (entry.status === 'pending') {
throw entry.promise;
}
}

function buildItems(mode: number): string[] {
const variant = mode % 3;
// stable keys — survive mode switches
const items = Array.from({ length: 10 }, (_, i) => `stable-${i}`);
// key spliced into the middle — shifts the indices of the stable items
items.splice(3, 0, `mid-${variant}`);
// varying tail — mounts/unmounts (Insert/Remove with exiting) per mode
for (let i = 0; i < 3 + variant * 4; i++) {
items.push(`temp-${variant}-${i}`);
}
return items;
}

function Item({ id, mode, swapped }: { id: string; mode: number; swapped: boolean }) {
// Two views per item: the item itself (withheld Remove/Delete via `exiting`)
// and a conditional subtree swap — every toggle fires an exiting animation
// on the outgoing view and an entering one on the incoming view. The Text
// includes `mode`, so stable items also get Paragraph updates every switch.
return (
<Animated.View
layout={LinearTransition.duration(250)}
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(800)}>
{swapped ? (
<Animated.View
key="a"
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(600)}>
<Text>{`${id} · A · mode ${mode}`}</Text>
</Animated.View>
) : (
<Animated.View
key="b"
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(600)}>
<Text>{`${id} · B · mode ${mode}`}</Text>
</Animated.View>
)}
</Animated.View>
);
}

function List({ mode, swapped }: { mode: number; swapped: boolean }) {
useSuspendedData(`page-${mode}`);
return (
<Animated.FlatList
data={buildItems(mode)}
keyExtractor={(id) => id}
itemLayoutAnimation={LinearTransition.duration(250)}
renderItem={({ item }) => <Item id={item} mode={mode} swapped={swapped} />}
/>
);
}

function Skeleton() {
return (
<Animated.View
entering={FadeIn.duration(150)}
exiting={FadeOut.duration(400)}>
<Text>Loading…</Text>
</Animated.View>
);
}

export default function SuspenseLayoutAnimationCrashExample() {
const [mode, nextMode] = useReducer((m: number) => m + 1, 0);
const [swapped, setSwapped] = useState(false);
const started = useRef(false);

const start = () => {
if (started.current) {
return;
}
started.current = true;
let tick = 0;
// never cleared — the app crashes before cleanup matters
setInterval(() => {
tick += 1;
if (tick % 2 === 0) {
nextMode();
} else {
setSwapped((s) => !s);
}
}, 200);
};
Comment on lines +118 to +138

return (
<View style={{ flex: 1, paddingTop: 8 }}>
<Button title="Start stress (crashes in seconds)" onPress={start} />
<Suspense fallback={<Skeleton />}>
<List mode={mode} swapped={swapped} />
</Suspense>
</View>
);
}
10 changes: 10 additions & 0 deletions apps/common-app/src/apps/reanimated/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ const EmojiWaterfallExample: React.FC = () =>
React.createElement(require('./EmojiWaterfallExample').default as React.FC);
const EmptyExample: React.FC = () =>
React.createElement(require('./EmptyExample').default as React.FC);
const SuspenseLayoutAnimationCrashExample: React.FC = () =>
React.createElement(
require('./SuspenseLayoutAnimationCrashExample').default as React.FC
);
const ExtrapolationExample: React.FC = () =>
React.createElement(require('./ExtrapolationExample').default as React.FC);
const FetchExample: React.FC = () =>
Expand Down Expand Up @@ -520,6 +524,12 @@ export const EXAMPLES: Record<string, Example> = {
screen: AboutExample,
},

SuspenseLayoutAnimationCrashExample: {
icon: '💥',
title: 'Suspense + Layout Animation Crash',
screen: SuspenseLayoutAnimationCrashExample,
},

// Empty example for test purposes
EmptyExample: {
icon: '👻',
Expand Down
2 changes: 1 addition & 1 deletion apps/fabric-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"IOS_CSS_CORE_ANIMATION": true,
"USE_SYNCHRONIZABLE_FOR_MUTABLES": true,
"USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS": true,
"ENABLE_SHARED_ELEMENT_TRANSITIONS": true,
"ENABLE_SHARED_ELEMENT_TRANSITIONS": false,
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": true,
"USE_ANIMATION_BACKEND": false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Legacy::pullTransaction
std::vector<std::shared_ptr<MutationNode>> roots;
std::unordered_map<Tag, Tag> movedViews;

// If React re-creates or re-inserts a tag whose exiting removal we are still
// withholding, it has contradicted that withheld Remove/Delete. Flush it now
// instead of letting it fire later against a stale hierarchy (which would
// unmount the wrong, still-live view and crash the mounting layer).
//
// This must run before addOngoingAnimations (which would otherwise emit an
// Update for a tag we are about to Delete this frame) and before
// parseRemoveMutations, so the rest of the pipeline sees clean bookkeeping.
Comment on lines +41 to +48

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.

Move this comment out of pullTransaction. It can be instead a doc comment on the reconcile method

reconcileContradictedRemovals(mutations, filteredMutations, surfaceId);

addOngoingAnimations(surfaceId, filteredMutations);

#ifdef ANDROID
Expand Down Expand Up @@ -69,6 +79,32 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Legacy::pullTransaction
return MountingTransaction{surfaceId, transactionNumber, std::move(filteredMutations), telemetry};
}

void LayoutAnimationsProxy_Legacy::reconcileContradictedRemovals(
ShadowViewMutationList &mutations,
ShadowViewMutationList &filteredMutations,
SurfaceId surfaceId) const {
auto &[deadNodes] = surfaceContext_[surfaceId];
for (auto &mutation : mutations) {
if (mutation.type != ShadowViewMutation::Type::Create && mutation.type != ShadowViewMutation::Type::Insert) {
continue;
}
auto tag = mutation.newChildShadowView.tag;
auto it = nodeForTag_.find(tag);
// Only a MutationNode represents a withheld removal; a plain Node is just a
// live parent of some removed child and must be left untouched.
if (it == nodeForTag_.end() || !it->second->isMutationNode()) {
continue;
}
auto node = std::static_pointer_cast<MutationNode>(it->second);
// Flush the withheld Remove/Delete for this tag (and its withheld subtree)
// right now, mirroring the deadNodes cleanup in handleRemovals. This removes
// the stale view before React's Create/Insert re-registers the same tag.
endAnimationsRecursively(node, filteredMutations);
maybeDropAncestors(node->unflattenedParent, node, filteredMutations);
deadNodes.erase(node);
}
}

std::optional<SurfaceId> LayoutAnimationsProxy_Legacy::progressLayoutAnimation(int tag, const jsi::Object &newStyle) {
#ifdef LAYOUT_ANIMATIONS_LOGS
LOG(INFO) << "progress layout animation for tag " << tag << std::endl;
Expand Down Expand Up @@ -127,7 +163,13 @@ std::optional<SurfaceId> LayoutAnimationsProxy_Legacy::endLayoutAnimation(int ta
}

auto node = nodeForTag_[tag];

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.

This access here is wrong probably and only works due to [] creating the node, maybe the condition below should be if contains and isMutationNode, because:

  1. endAnimationsRecursively removes from nodeForTag_
  2. an exiting animation on this view's child could put node back in nodeForTag_, but it wouldn't be a mutation node.

On the other hand, why does this run after an animation got canceled? I think this needs to be verified

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.

It seems that we are still emitting endLayoutAnimation after it got cancelled. I think we could either skip it, or pass a flag here so that we know it was cancelled and can react accordingly, instead of making the defensive checks

react_native_assert(node->isMutationNode() && "exiting tag must map to a MutationNode");
if (!node->isMutationNode()) {
// The node was replaced by a plain Node (e.g. by a reparenting move) after
// the exiting animation started, so there is no withheld removal to
// finalize. Casting it to MutationNode here would corrupt the heap, and the
// react_native_assert that used to guard this is compiled out in release.
Comment on lines +167 to +170

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.

Suggested change
// The node was replaced by a plain Node (e.g. by a reparenting move) after
// the exiting animation started, so there is no withheld removal to
// finalize. Casting it to MutationNode here would corrupt the heap, and the
// react_native_assert that used to guard this is compiled out in release.
// The node was re-added (probably by Suspense), we can't finish the exiting animation

return {};
}
auto mutationNode = std::static_pointer_cast<MutationNode>(node);
mutationNode->state = ExitingState_Legacy::DEAD;
auto &[deadNodes] = surfaceContext_[surfaceId];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
std::optional<SurfaceId> endLayoutAnimation(int tag, bool shouldRemove) override;
void maybeCancelAnimation(const int tag) const;

void reconcileContradictedRemovals(
ShadowViewMutationList &mutations,
ShadowViewMutationList &filteredMutations,
SurfaceId surfaceId) const;
void parseRemoveMutations(
std::unordered_map<Tag, Tag> &movedViews,
ShadowViewMutationList &mutations,
Expand Down
Loading