From d63e440f218d09de8d54eb67f986910b39d1fb6c Mon Sep 17 00:00:00 2001 From: Sahinur Islam Date: Fri, 16 Oct 2026 10:05:58 +0600 Subject: [PATCH 1/2] Ensure `Transition` unmounts when its reversed transition starts late 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 #3902 --- .../src/hooks/use-transition.test.tsx | 87 +++++++++++++++++++ .../src/hooks/use-transition.ts | 41 ++++++--- 2 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 packages/@headlessui-react/src/hooks/use-transition.test.tsx diff --git a/packages/@headlessui-react/src/hooks/use-transition.test.tsx b/packages/@headlessui-react/src/hooks/use-transition.test.tsx new file mode 100644 index 0000000000..eb2cb20fbe --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-transition.test.tsx @@ -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((resolve) => setTimeout(resolve, ms))) +} + +// `jsdom` doesn't run CSS transitions, so we control what `getAnimations()` +// returns ourselves. +class FakeCSSTransition { + playState: AnimationPlayState = 'running' + finished: Promise + 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 ( + <> + + +
+ + + ) + } + + let { getByText, queryByTestId } = render() + + // 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() +}) diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts index dd6fef96b1..b8b2eac2e5 100644 --- a/packages/@headlessui-react/src/hooks/use-transition.ts +++ b/packages/@headlessui-react/src/hooks/use-transition.ts @@ -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 } @@ -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 +} From 803c492ec48f862c1dbfcdde366b09b0b8289b23 Mon Sep 17 00:00:00 2001 From: Sahinur Islam Date: Fri, 16 Oct 2026 10:06:29 +0600 Subject: [PATCH 2/2] update changelog --- packages/@headlessui-react/CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index c2915c43f6..f7cfa73f20 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -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