The crash that only happened while you were leaving
The best bugs only show up when nobody's looking. This one crashed the app the moment a user left the screen, right after they'd stopped caring about it.
It came in as a crash report that was easy to wave off. An IllegalArgumentException deep inside React Native's animation code, on Android, with a message that almost sounds bored:
java.lang.IllegalArgumentException: Mapped property node does not exist at com.facebook.react.animated.PropsAnimatedNode.updateView(PropsAnimatedNode.kt) at com.facebook.react.animated.NativeAnimatedNodesManager.updateNodes(NativeAnimatedNodesManager.java) at com.facebook.react.animated.NativeAnimatedNodesManager.runUpdates(NativeAnimatedNodesManager.java)
It didn't reproduce on demand. It showed up in the wild, on some devices, some of the time, usually during screen changes. Real users kept hitting it and I couldn't trigger it once on my own machine. That pattern almost always means a race condition. Races are where "it works on my machine" goes to die.
What was actually happening
React Native's old animation system keeps a graph of nodes. A props node holds one child node per animated property. Every frame, it walks that list and writes each value onto the view. The line that crashed looked like this:
val node = nativeAnimatedNodesManager.getNodeById(value)
requireNotNull(node) { "Mapped property node does not exist" }
requireNotNull is a promise. It says: this is never null, and if it is, something is badly broken, so crash. Here it could be null, for a completely ordinary reason.
That promise wasn't wrong the day someone wrote it. It assumed the node is always there while an update is running, and once, it was. Concurrency made it false later, quietly, without anyone touching the line. A lot of "impossible" crashes are just an old assumption meeting new timing.
When a component closes, say during a screen change, its animated child nodes get removed. But an update for the parent props node can already be running. So the order that crashes is this:
1. an update for the props node is queued 2. the component closes, so the child node is removed 3. the queued update runs, looks for the missing node, and throws
By the time step 3 runs the view is closing anyway. There's nothing worth writing for that property on that frame. The "error" the code was so sure about was just a teardown that hadn't finished yet.
The fix is small. That is the point.
Once you see the race, the fix writes itself. Don't assume the node is there. Check, and if it's already gone, skip it:
Two things told me this was the right skip and not a try/catch hiding a real bug. First, the same method already skipped work when the view itself was gone (connectedViewTag == -1). So "the thing I'm updating is gone, move on" was already how this code behaved. Second, iOS did exactly that. It walks its nodes and quietly skips a missing one. Android was the odd one out, turning an ordinary teardown into a hard crash.
A good fix makes the code agree with what the system was already telling you.
Making the race reproducible
Reasoning is not proof, and swallowing an error is an easy thing to get wrong. The trouble is that the crash depends on timing. On a device you can only coax it out: take a screen with a lot of native-driver animations, turn up Animator duration scale in Android's developer options to widen the window, then navigate in and out until it fires. Good enough for a test plan. Useless as a regression test.
In a unit test you can cause the race outright. Drop the mapped node that the props node points at, then run the update:
nativeAnimatedNodesManager.dropAnimatedNode(2) assertThat(nativeAnimatedNodesManager.getNodeById(2)).isNull() val propsNode = nativeAnimatedNodesManager.getNodeById(3) as PropsAnimatedNode propsNode.updateView()
Before the fix, that throws. After it, it returns. The bug went from "sometimes, on some phones" to a test that fails the moment someone puts the requireNotNull back. That is the difference between a fix and a mute button.
Shipping it to Meta
I opened the pull request against react/react-native, linked the issue it closed, and wrote the test plan honestly: the device repro is probabilistic, here is how to widen the window, and here is the test that isn't.
Two Meta engineers reviewed it, zeyap and christophpurrer. It was imported as D109253948 and tagged into v0.87.0-rc.0. If you're on React Native 0.87, you already have it.
Six changed lines and a regression test, closing an issue that had been open since 2023. The work wasn't in the diff. It was in reading the race right.