Bug: ViewTransition with enter/exit hard-crashes iOS Safari
I'm experiencing an iOS-only crash (iOS 26.1) when there's ViewTransition with enter and another with exit:
import { Suspense, use } from "react";
import { ViewTransition } from "react";
import "./App.css";
const promise = new Promise((r) => setTimeout(() => r("Done"), 3000));
function Content() {
return <div>{use(promise)}</div>;
}
export default function App() {
return (
<Suspense
fallback={
<ViewTransition exit="vt-reveal">
<div>Loading...</div>
</ViewTransition>
}
>
<ViewTransition enter="vt-reveal">
<Content />
</ViewTransition>
</Suspense>
);
}
::view-transition-old(.vt-reveal) {
animation: 300ms ease-out both vt-out;
}
::view-transition-new(.vt-reveal) {
animation: 300ms ease-out both vt-in;
}
@keyframes vt-out {
to {
opacity: 0;
}
}
@keyframes vt-in {
from {
opacity: 0;
}
}
This is a hard crash, meaning the browser tab literally dies.
Sandbox: https://codesandbox.io/p/sandbox/gns365?file=%2Fsrc%2FApp.js
Here's what Claude had to say about the root cause based on a crash dump from my phone and looking at React and Webkit source.
Crash dump: com.apple.WebKit.WebContent-2025-12-09-203451.log
Stack trace:
WebCore::compareStyleOriginatedAnimationOwningElementPositionsInDocumentTreeOrder
↓
WebCore::compareCSSAnimations
↓
std::__1::__stable_sort_move (sorting animations)
↓
WebCore::Document::matchingAnimations
↓
WebCore::Element::getAnimations ← JS API call
Claude claims the problem is in this block of code (which is already a workaround for another Safari issue):
https://github.com/facebook/react/blob/55480b4d228986e502f4651f8e53a6f264a1858e/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L1999-L2017
Crash location: WebCore::compareStyleOriginatedAnimationOwningElementPositionsInDocumentTreeOrder at offset 0x40 (null pointer dereference)
Call stack: Element::getAnimations() → Document::matchingAnimations() → stable_sort() → compareCSSAnimations() → compareStyleOriginatedAnimationOwningElementPositionsInDocumentTreeOrder() → CRASH
Root cause:
When Suspense resolves with both exit and enter ViewTransitions:
- React calls document.startViewTransition()
- The fallback element (with exit) gets removed from the DOM
- The content element (with enter) gets added
- After transition.ready resolves, React calls documentElement.getAnimations({subtree: true}) to optimize/adjust the animations
- WebKit internally sorts the returned animations to determine their composite order
- The sort comparator (compareStyleOriginatedAnimationOwningElementPositionsInDocumentTreeOrder) tries to compare animations that include ones from the now-removed fallback element
- The Styleable struct holds an Element& reference that's now stale/dangling
- Accessing a member on that element (at offset 0x40) crashes
Why both are required:
- With only enter: there's only one animation, no sorting comparison needed
- With only exit: the exiting element's animation exists but there's no second animation to compare against
- With both: WebKit must sort multiple animations, and the comparator accesses the removed element
WebKit bug: The animation collection/sorting doesn't properly filter out or guard against animations whose owning elements have been removed from the document tree.
Speculative fix from Claude https://github.com/facebook/react/pull/35337
Non-React repro if someone wants to file a radar:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
::view-transition-old(*) { animation: 300ms both fade-out; }
::view-transition-new(*) { animation: 300ms both fade-in; }
@keyframes fade-out { to { opacity: 0; } }
@keyframes fade-in { from { opacity: 0; } }
</style>
</head>
<body>
<div id="a" style="view-transition-name:a">A</div>
<script>
const t = document.startViewTransition(() => {
a.remove();
const c = document.createElement('div');
c.id = 'c';
c.textContent = 'C';
c.style.viewTransitionName = 'c';
document.body.appendChild(c);
});
t.ready.then(() => document.documentElement.getAnimations({subtree:true}));
t.finished.then(() => document.documentElement.getAnimations({subtree:true}));
</script>
</body>
</html>