diff --git a/packages/@headlessui-react/src/components/transitions/transition.test.tsx b/packages/@headlessui-react/src/components/transitions/transition.test.tsx
index 391c9ab2c..a58b82163 100644
--- a/packages/@headlessui-react/src/components/transitions/transition.test.tsx
+++ b/packages/@headlessui-react/src/components/transitions/transition.test.tsx
@@ -1,4 +1,4 @@
-import React, { Fragment, useState, useRef, useLayoutEffect } from 'react'
+import React, { Fragment, useState, useRef, useLayoutEffect, useEffect } from 'react'
import { render, fireEvent } from '@testing-library/react'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
@@ -1397,4 +1397,93 @@ describe('Events', () => {
])
})
)
+
+ it(
+ 'should fire only one event for a given component change',
+ suppressConsoleLogs(async () => {
+ let eventHandler = jest.fn()
+ let enterDuration = 50
+ let leaveDuration = 75
+
+ function Example() {
+ let [show, setShow] = useState(false)
+ let [start, setStart] = useState(Date.now())
+
+ useEffect(() => setStart(Date.now()), [])
+
+ return (
+ <>
+
+ eventHandler('beforeEnter', Date.now() - start)}
+ afterEnter={() => eventHandler('afterEnter', Date.now() - start)}
+ beforeLeave={() => eventHandler('beforeLeave', Date.now() - start)}
+ afterLeave={() => eventHandler('afterLeave', Date.now() - start)}
+ enter="enter-2"
+ enterFrom="enter-from"
+ enterTo="enter-to"
+ leave="leave-2"
+ leaveFrom="leave-from"
+ leaveTo="leave-to"
+ >
+
+
+
+
+
+
+ >
+ )
+ }
+
+ render()
+
+ fireEvent.click(document.querySelector('[data-testid=show]')!)
+
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+
+ fireEvent.click(document.querySelector('[data-testid=hide]')!)
+
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+
+ expect(eventHandler).toHaveBeenCalledTimes(4)
+ expect(eventHandler.mock.calls.map(([name]) => name)).toEqual([
+ // Order is important here
+ 'beforeEnter',
+ 'afterEnter',
+ 'beforeLeave',
+ 'afterLeave',
+ ])
+ })
+ )
})
diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md
index 0ae9bd8f2..790e92c64 100644
--- a/packages/@headlessui-vue/CHANGELOG.md
+++ b/packages/@headlessui-vue/CHANGELOG.md
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-- Nothing yet!
+### Fixed
+
+- Don’t fire `afterLeave` event more than once for a given transition ([#2267](https://github.com/tailwindlabs/headlessui/pull/2267))
## [1.7.9] - 2023-02-03
diff --git a/packages/@headlessui-vue/src/components/transitions/transition.test.ts b/packages/@headlessui-vue/src/components/transitions/transition.test.ts
index e07a58f5b..fe6b32a50 100644
--- a/packages/@headlessui-vue/src/components/transitions/transition.test.ts
+++ b/packages/@headlessui-vue/src/components/transitions/transition.test.ts
@@ -1258,4 +1258,93 @@ describe('Events', () => {
expect(leaveHookDiff).toBeLessThanOrEqual(leaveDuration * 3)
})
)
+
+ it(
+ 'should fire only one event for a given component change',
+ suppressConsoleLogs(async () => {
+ let eventHandler = jest.fn()
+ let enterDuration = 50
+ let leaveDuration = 75
+
+ withStyles(`
+ .enter-1 { transition-duration: ${enterDuration * 1}ms; }
+ .enter-2 { transition-duration: ${enterDuration * 2}ms; }
+ .enter-from { opacity: 0%; }
+ .enter-to { opacity: 100%; }
+
+ .leave-1 { transition-duration: ${leaveDuration * 1}ms; }
+ .leave-2 { transition-duration: ${leaveDuration * 2}ms; }
+ .leave-from { opacity: 100%; }
+ .leave-to { opacity: 0%; }
+ `)
+
+ let Example = defineComponent({
+ components: { TransitionRoot, TransitionChild },
+ template: html`
+
+
+
+
+
+
+
+
+ `,
+ setup() {
+ let show = ref(false)
+ let start = ref(Date.now())
+
+ onMounted(() => (start.value = Date.now()))
+
+ return { show, start, eventHandler }
+ },
+ })
+
+ renderTemplate(Example)
+
+ fireEvent.click(getByTestId('show'))
+
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+
+ fireEvent.click(getByTestId('hide'))
+
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+
+ expect(eventHandler).toHaveBeenCalledTimes(4)
+ expect(eventHandler.mock.calls.map(([name]) => name)).toEqual([
+ // Order is important here
+ 'beforeEnter',
+ 'afterEnter',
+ 'beforeLeave',
+ 'afterLeave',
+ ])
+ })
+ )
})
diff --git a/packages/@headlessui-vue/src/components/transitions/transition.ts b/packages/@headlessui-vue/src/components/transitions/transition.ts
index 527c25070..875825200 100644
--- a/packages/@headlessui-vue/src/components/transitions/transition.ts
+++ b/packages/@headlessui-vue/src/components/transitions/transition.ts
@@ -188,7 +188,7 @@ export let TransitionChild = defineComponent({
let nesting = useNesting(() => {
// When all children have been unmounted we can only hide ourselves if and only if we are not
// transitioning ourselves. Otherwise we would unmount before the transitions are finished.
- if (!isTransitioning.value) {
+ if (!isTransitioning.value && state.value !== TreeStates.Hidden) {
state.value = TreeStates.Hidden
unregister(id)
emit('afterLeave')