Skip to content

Ensure Transition unmounts when its reversed transition starts late - #3906

Open
SahinurDEV wants to merge 2 commits into
tailwindlabs:mainfrom
SahinurDEV:fix/transition-stuck-after-reversal
Open

SahinurDEV wants to merge 2 commits into
tailwindlabs:mainfrom
SahinurDEV:fix/transition-stuck-after-reversal

Conversation

@SahinurDEV

@SahinurDEV SahinurDEV commented Oct 16, 2026 •

Copy link
Copy Markdown

Fixes #3902

When show flips back while a transition is still running, useTransition reverses it: run() flips the flags and one frame later waitForTransition captures getAnimations(). If React hasn't committed the flag change by then (busy main thread), we capture the old enter transitions. The commit then starts the reverse transitions, which cancels the captured ones, so done() runs right away. Because the transition was cancelled, done() sees the reverse transitions still running and returns early, and nothing ever calls it again. The element stays mounted with data-closed data-enter data-transition and, for a Dialog, keeps swallowing clicks.

The fix: after the captured transitions settle, waitForTransition checks getAnimations() again and keeps waiting until no CSSTransition is running, then calls done(). The pending-transition filter (CSSTransition that isn't finished) is now shared with hasPendingTransitions, so a finished transition that is still listed can't cause a loop.

The Vue package isn't affected: it waits on transition-duration with a timeout, not on getAnimations().

Tests

  • Added src/hooks/use-transition.test.tsx. jsdom doesn't run CSS transitions, so the test controls what getAnimations() returns: open, close mid-enter while only the old transition is visible, then swap in the reversed transition. Without the fix the panel stays mounted with data-closed data-enter data-transition. With the fix it unmounts once the reversed transition finishes.
  • npm run react test: 31 suites, 1069 passed.
  • tsc --noEmit for @headlessui/react and prettier are clean.
  • Added a changelog entry.

When a transition is reversed (e.g. closing while the enter transition is
still running), we capture the running transitions one frame after flipping
the flags. If React hasn't committed the flag change yet, we capture the old
transitions instead of the reversed ones. Once those settle, `done()` sees
the reversed transitions still running and bails out, and nothing calls it
again, so the element stays mounted forever.

Re-check for running transitions after the captured ones settle and keep
waiting until none are left before calling `done()`.

Fixes tailwindlabs#3902
@vercel

vercel Bot commented Oct 16, 2026

Copy link
Copy Markdown

@SahinurDEV is attempting to deploy a commit to the Tailwind Labs Team on Vercel.

A member of the Team first needs to authorize it.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transition: overlay stays mounted forever when closed during its enter transition and React commits after the next frame

1 participant