Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Fixed

- Ensure `Transition` unmounts when its reversed transition starts late ([#3906](https://github.com/tailwindlabs/headlessui/pull/3906))

## [2.2.10] - 2026-04-07

Expand Down
87 changes: 87 additions & 0 deletions packages/@headlessui-react/src/hooks/use-transition.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { act, fireEvent, render } from '@testing-library/react'
import React, { useState } from 'react'
import { Transition } from '../components/transition/transition'

function wait(ms: number) {
return act(() => new Promise<void>((resolve) => setTimeout(resolve, ms)))
}

// `jsdom` doesn't run CSS transitions, so we control what `getAnimations()`
// returns ourselves.
class FakeCSSTransition {
playState: AnimationPlayState = 'running'
finished: Promise<this>
finish!: () => void

constructor() {
this.finished = new Promise((resolve) => {
this.finish = () => {
this.playState = 'finished'
resolve(this)
}
})
}
}

let originalCSSTransition = (globalThis as any).CSSTransition
let animations: FakeCSSTransition[] = []

beforeEach(() => {
animations = []
;(globalThis as any).CSSTransition = FakeCSSTransition
jest
.spyOn(Element.prototype, 'getAnimations')
.mockImplementation(() => animations as unknown as Animation[])
})

afterEach(() => {
jest.restoreAllMocks()
;(globalThis as any).CSSTransition = originalCSSTransition
})

it('should unmount when the reversed transition starts after the old transitions were captured', async () => {
function Example() {
let [show, setShow] = useState(false)

return (
<>
<button onClick={() => setShow((v) => !v)}>toggle</button>
<Transition show={show}>
<div data-testid="panel" className="transition-opacity data-[closed]:opacity-0" />
</Transition>
</>
)
}

let { getByText, queryByTestId } = render(<Example />)

// Open the panel, the enter transition starts running.
let enter = new FakeCSSTransition()
animations = [enter]
fireEvent.click(getByText('toggle'))
await wait(100)
expect(queryByTestId('panel')).not.toBeNull()

// Close the panel while the enter transition is still running. The
// transition is reversed, but at the time we capture the running transitions
// the reversed one has not started yet (e.g. React hasn't committed the
// change because the main thread is busy), so we only see the old one.
fireEvent.click(getByText('toggle'))
await wait(100)

// Now the reversed transition starts, which cancels the old enter transition.
let reversed = new FakeCSSTransition()
animations = [reversed]
enter.finish()
await wait(50)

// The reversed transition is still running, so the panel is still visible.
expect(queryByTestId('panel')).not.toBeNull()

// Once the reversed transition finishes, the panel should be unmounted.
reversed.finish()
animations = []
await wait(100)

expect(queryByTestId('panel')).toBeNull()
})
41 changes: 28 additions & 13 deletions packages/@headlessui-react/src/hooks/use-transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,20 +262,31 @@ function waitForTransition(node: HTMLElement | null, done: () => void) {
cancelled = true
})

let transitions =
node.getAnimations?.().filter((animation) => animation instanceof CSSTransition) ?? []
// If there are no transitions, we can stop early.
if (transitions.length === 0) {
done()
return d.dispose
}
let element = node
function wait() {
let transitions = getPendingTransitions(element)

// Wait for all the transitions to complete.
Promise.allSettled(transitions.map((transition) => transition.finished)).then(() => {
if (!cancelled) {
// If there are no (more) running transitions, we are done.
if (transitions.length === 0) {
done()
return
}
})

// Wait for all the transitions to complete, then check again. The
// transitions we captured are not necessarily the ones that end up
// running: if React commits the new state after we captured them (e.g.
// when the main thread is busy), the captured transitions are cancelled
// and replaced by new ones. Calling `done()` at that point would leave the
// element stuck, because nothing else would call `done()` once the new
// transitions finish.
Promise.allSettled(transitions.map((transition) => transition.finished)).then(() => {
if (!cancelled) {
wait()
}
})
}

wait()

return d.dispose
}
Expand Down Expand Up @@ -305,10 +316,14 @@ function prepareTransition(
node.style.transition = previous
}

function hasPendingTransitions(node: HTMLElement) {
function getPendingTransitions(node: HTMLElement) {
let animations = node.getAnimations?.() ?? []

return animations.some((animation) => {
return animations.filter((animation) => {
return animation instanceof CSSTransition && animation.playState !== 'finished'
})
}

function hasPendingTransitions(node: HTMLElement) {
return getPendingTransitions(node).length > 0
}