react icon indicating copy to clipboard operation
react copied to clipboard

Bug: ViewTransition with enter/exit hard-crashes iOS Safari

Open gaearon opened this issue 1 month ago • 3 comments

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

gaearon avatar Dec 09 '25 12:12 gaearon

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:

  1. React calls document.startViewTransition()
  2. The fallback element (with exit) gets removed from the DOM
  3. The content element (with enter) gets added
  4. After transition.ready resolves, React calls documentElement.getAnimations({subtree: true}) to optimize/adjust the animations
  5. WebKit internally sorts the returned animations to determine their composite order
  6. The sort comparator (compareStyleOriginatedAnimationOwningElementPositionsInDocumentTreeOrder) tries to compare animations that include ones from the now-removed fallback element
  7. The Styleable struct holds an Element& reference that's now stale/dangling
  8. 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.

gaearon avatar Dec 09 '25 12:12 gaearon

Speculative fix from Claude https://github.com/facebook/react/pull/35337

gaearon avatar Dec 09 '25 12:12 gaearon

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>

gaearon avatar Dec 09 '25 12:12 gaearon