diff --git a/apps/common-app/src/apps/reanimated/examples/SuspenseLayoutAnimationCrashExample.tsx b/apps/common-app/src/apps/reanimated/examples/SuspenseLayoutAnimationCrashExample.tsx new file mode 100644 index 00000000000..5787f7075a9 --- /dev/null +++ b/apps/common-app/src/apps/reanimated/examples/SuspenseLayoutAnimationCrashExample.tsx @@ -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 } +>(); + +function useSuspendedData(key: string) { + let entry = cache.get(key); + if (!entry) { + const promise = new Promise((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 ( + + {swapped ? ( + + {`${id} · A · mode ${mode}`} + + ) : ( + + {`${id} · B · mode ${mode}`} + + )} + + ); +} + +function List({ mode, swapped }: { mode: number; swapped: boolean }) { + useSuspendedData(`page-${mode}`); + return ( + id} + itemLayoutAnimation={LinearTransition.duration(250)} + renderItem={({ item }) => } + /> + ); +} + +function Skeleton() { + return ( + + Loading… + + ); +} + +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); + }; + + return ( + +