Skip to content

Transition: enter/leave animation is skipped when React commits after the next frame (main thread busy) #3915

Open

Description

@Alletkla

What package within Headless UI are you using?

@headlessui/react

What version of that package are you using?

2.2.0 in the reproduction. use-transition.ts on main (2.2.10) has the same logic, and I checked the fix against its source.

What browser are you using?

Chromium (Playwright 1.56.1, headless and headed) and Chrome Version 157.0.8103.39. React 19.2.7, Tailwind CSS 3/4.

Reproduction URL

https://github.com/Alletkla/headlessui/tree/repro/headlessui-transition-snap

npm install
npm run dev

Open http://localhost:5179/, click Open, Close, Open. No CPU throttling is needed. The Idle main thread button switches the load off for comparison.

Describe your issue

A Transition/TransitionChild that uses the enter/enterFrom/enterTo/leave/… props sometimes skips its animation and appears or disappears in place when the main thread is busy. The Repro shows Last open: SNAPPED when this happens.

In the reproduction, a sheet is mounted inside a Transition. Each time it opens, an effect in the opening click task does 100 ms of work and queues 20 timers of 15 ms each, like network callbacks or timers in a real app. With these defaults, every open snaps, including the first, even after long pauses. With Idle main thread (no queued work) almost every open slides again, but I would say having no work on open is just a theoretical prove for the issue. Closing still slides in both cases.

Cause

In use-transition.ts, run() changes the flags through React state, and one requestAnimationFrame later waitForTransition calls node.getAnimations() once. React does not render that state update synchronously. It renders it in a Scheduler task. If other tasks are queued in front of it, the browser renders another frame first. The getAnimations() read then runs before run()'s change is committed, finds no transition, and calls done() immediately. done() removes enter, enterFrom and the transition-* classes in the same commit that should have applied enterTo, so the browser never creates a transition.

The reproduction counts getAnimations() calls that return nothing while data-closed still has its pre-run() value. It goes up by 2 per open (overlay and panel) when the race happens. This counter shows the race, not the visible result: with a permanent transition-* class plus data-[closed]: variants (api=data in the page) the race still fires but the sheet still animates, because done() never removes the transition declaration. Only the class-based API visibly snaps. The page's "Last open: animated / SNAPPED" indicator shows the visible outcome.

Related issues and PRs

This is the same race as #3902: the read happens before React has committed run()'s change. In #3902 the first read captures the old enter transitions and the element stays mounted. Here the first read finds nothing and done() runs at once, so the transition is skipped. This second variant is also described in a comment on #3902 (an Escape press that skipped the leave in CI). The PRs #3895 and #3906 re-read getAnimations() after the captured transitions settle. As far as I can tell, they don't cover the case where the first read is empty.

Possible fix

Don't read getAnimations() until React has committed the flag change from run(). useTransition records the last committed Closed flag in a ref (updated in a layout effect on every commit). transition() waits, one frame at a time, until that matches what run() set, with a 2 s safety limit, and then reads the animations:

https://github.com/Alletkla/headlessui/tree/fix/transition-wait-for-committed-state

With this change (Chromium, no throttling, real clicks):

  • The reproduction animates on every open and leave under the default load, and under 40 and 60 queued timers with 300 ms of opening work.
  • A rebuilt version of the Transition: overlay stays mounted forever when closed during its enter transition and React commits after the next frame #3902 issue scenario (Dialog with DialogPanel transition, 200 ms opacity transition, close 60 ms after opening, React's scheduler messages delayed by 80 ms) gives unmounted 10 of 10. The same scenario on main gives STUCK 10 of 10.
  • The package's jest suite passes (31 suites, 1069 tests), and there is a new regression test that fails without the change.

I haven't tested WebKit or Firefox. I haven't opened a PR since two related PRs are already open, and I'm happy to open one or rebase onto them if you want it.

#3092 isn't a duplicate. It has the same root cause but a different symptom, and the open PR for #3902 doesn't fix ours. -> But ours would fix both. I therefore opened a new issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions