diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 56148639ea..9ffdbb63c4 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Expose missing `data-disabled` and `data-focus` attributes on the `TabsPanel`, `MenuButton`, `PopoverButton` and `DisclosureButton` components ([#3061](https://github.com/tailwindlabs/headlessui/pull/3061))
- Fix cursor position when re-focusing the `ComboboxInput` component ([#3065](https://github.com/tailwindlabs/headlessui/pull/3065))
- Keep focus inside of the `` component ([#3073](https://github.com/tailwindlabs/headlessui/pull/3073))
+- Fix enter transitions for the `Transition` component ([#3074](https://github.com/tailwindlabs/headlessui/pull/3074))
### Changed
diff --git a/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap b/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap
index 38465fb1ef..1555d0302d 100644
--- a/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap
+++ b/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap
@@ -136,6 +136,7 @@ exports[`Setup API transition classes should be possible to passthrough the tran
exports[`Setup API transition classes should be possible to passthrough the transition classes and immediately apply the enter transitions when appear is set to true 1`] = `
diff --git a/packages/@headlessui-react/src/components/transition/transition.tsx b/packages/@headlessui-react/src/components/transition/transition.tsx
index f5b3f1e406..0095ede2bc 100644
--- a/packages/@headlessui-react/src/components/transition/transition.tsx
+++ b/packages/@headlessui-react/src/components/transition/transition.tsx
@@ -4,7 +4,6 @@ import React, {
Fragment,
createContext,
useContext,
- useEffect,
useMemo,
useRef,
useState,
@@ -18,6 +17,7 @@ import { useFlags } from '../../hooks/use-flags'
import { useIsMounted } from '../../hooks/use-is-mounted'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useLatestValue } from '../../hooks/use-latest-value'
+import { useOnDisappear } from '../../hooks/use-on-disappear'
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useTransition } from '../../hooks/use-transition'
@@ -259,26 +259,6 @@ function useNesting(done?: () => void, parent?: NestingContextValues) {
)
}
-function noop() {}
-let eventNames = ['beforeEnter', 'afterEnter', 'beforeLeave', 'afterLeave'] as const
-function ensureEventHooksExist(events: TransitionEvents) {
- let result = {} as Record void>
- for (let name of eventNames) {
- result[name] = events[name] ?? noop
- }
- return result
-}
-
-function useEvents(events: TransitionEvents) {
- let eventsRef = useRef(ensureEventHooksExist(events))
-
- useEffect(() => {
- eventsRef.current = ensureEventHooksExist(events)
- }, [events])
-
- return eventsRef
-}
-
// ---
let DEFAULT_TRANSITION_CHILD_TAG = 'div' as const
@@ -349,7 +329,7 @@ function TransitionChildFn {
+ if (immediate) return 'enter'
if (!ready) return 'idle'
if (skip) return 'idle'
return show ? 'enter' : 'leave'
@@ -380,11 +361,11 @@ function TransitionChildFn {
transitionStateFlags.addFlag(State.Opening)
- events.current.beforeEnter()
+ events.current.beforeEnter?.()
},
leave: () => {
transitionStateFlags.addFlag(State.Closing)
- events.current.beforeLeave()
+ events.current.beforeLeave?.()
},
idle: () => {},
})
@@ -394,26 +375,29 @@ function TransitionChildFn {
transitionStateFlags.removeFlag(State.Opening)
- events.current.afterEnter()
+ events.current.afterEnter?.()
},
leave: () => {
transitionStateFlags.removeFlag(State.Closing)
- events.current.afterLeave()
+ events.current.afterLeave?.()
},
idle: () => {},
})
})
+ let isTransitioning = useRef(false)
+
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.
+ // 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.current) return
+
setState(TreeStates.Hidden)
unregister(container)
}, parentNesting)
- let isTransitioning = useRef(false)
useTransition({
- immediate,
container,
classes,
direction: transitionDirection,
@@ -426,8 +410,8 @@ function TransitionChildFn is used but it is missing a `show={true | false}` prop.')
}
@@ -532,27 +531,18 @@ function TransitionRootFn(
- () => ({ show: show as boolean, appear, initial }),
+ () => ({ show, appear, initial }),
[show, appear, initial]
)
+ // Ensure we change the tree state to hidden once the transition becomes hidden
+ useOnDisappear(internalTransitionRef, () => setState(TreeStates.Hidden))
+
useIsoMorphicEffect(() => {
if (show) {
setState(TreeStates.Visible)
} else if (!hasChildren(nestingBag)) {
setState(TreeStates.Hidden)
- } else if (
- process.env.NODE_ENV !==
- 'test' /* TODO: Remove this once we have real tests! JSDOM doesn't "render", therefore getBoundingClientRect() will always result in `0`. */
- ) {
- let node = internalTransitionRef.current
- if (!node) return
- let rect = node.getBoundingClientRect()
-
- if (rect.x === 0 && rect.y === 0 && rect.width === 0 && rect.height === 0) {
- // The node is completely hidden, let's hide it
- setState(TreeStates.Hidden)
- }
}
}, [show, nestingBag])
diff --git a/packages/@headlessui-react/src/components/transition/utils/transition.test.ts b/packages/@headlessui-react/src/components/transition/utils/transition.test.ts
index 8f89ae76f2..fdbed2d3f0 100644
--- a/packages/@headlessui-react/src/components/transition/utils/transition.test.ts
+++ b/packages/@headlessui-react/src/components/transition/utils/transition.test.ts
@@ -26,9 +26,9 @@ it('should be possible to transition', async () => {
)
await new Promise((resolve) => {
- transition(
- element,
- {
+ transition(element, {
+ direction: 'enter', // Show
+ classes: {
base: [],
enter: ['enter'],
enterFrom: ['enterFrom'],
@@ -38,9 +38,8 @@ it('should be possible to transition', async () => {
leaveTo: [],
entered: ['entered'],
},
- true, // Show
- resolve
- )
+ done: resolve,
+ })
})
await new Promise((resolve) => d.nextFrame(resolve))
@@ -49,13 +48,13 @@ it('should be possible to transition', async () => {
expect(snapshots[0].content).toEqual('')
// Start of transition
- expect(snapshots[1].content).toEqual('')
+ expect(snapshots[1].content).toEqual('')
// NOTE: There is no `enter enterTo`, because we didn't define a duration. Therefore it is not
// necessary to put the classes on the element and immediately remove them.
// Cleanup phase
- expect(snapshots[2].content).toEqual('')
+ expect(snapshots[2].content).toEqual('')
d.dispose()
})
@@ -84,9 +83,9 @@ it('should wait the correct amount of time to finish a transition', async () =>
)
await new Promise((resolve) => {
- transition(
- element,
- {
+ transition(element, {
+ direction: 'enter', // Show
+ classes: {
base: [],
enter: ['enter'],
enterFrom: ['enterFrom'],
@@ -96,9 +95,8 @@ it('should wait the correct amount of time to finish a transition', async () =>
leaveTo: [],
entered: ['entered'],
},
- true, // Show
- resolve
- )
+ done: resolve,
+ })
})
await new Promise((resolve) => d.nextFrame(resolve))
@@ -154,9 +152,9 @@ it('should keep the delay time into account', async () => {
)
await new Promise((resolve) => {
- transition(
- element,
- {
+ transition(element, {
+ direction: 'enter', // Show
+ classes: {
base: [],
enter: ['enter'],
enterFrom: ['enterFrom'],
@@ -166,9 +164,8 @@ it('should keep the delay time into account', async () => {
leaveTo: [],
entered: ['entered'],
},
- true, // Show
- resolve
- )
+ done: resolve,
+ })
})
await new Promise((resolve) => d.nextFrame(resolve))
diff --git a/packages/@headlessui-react/src/components/transition/utils/transition.ts b/packages/@headlessui-react/src/components/transition/utils/transition.ts
index 659050c30b..fc2e31ebda 100644
--- a/packages/@headlessui-react/src/components/transition/utils/transition.ts
+++ b/packages/@headlessui-react/src/components/transition/utils/transition.ts
@@ -1,3 +1,4 @@
+import type { MutableRefObject } from 'react'
import { disposables } from '../../../utils/disposables'
import { match } from '../../../utils/match'
import { once } from '../../../utils/once'
@@ -10,7 +11,8 @@ function removeClasses(node: HTMLElement, ...classes: string[]) {
node && classes.length > 0 && node.classList.remove(...classes)
}
-function waitForTransition(node: HTMLElement, done: () => void) {
+function waitForTransition(node: HTMLElement, _done: () => void) {
+ let done = once(_done)
let d = disposables()
if (!node) return d.dispose
@@ -74,28 +76,32 @@ function waitForTransition(node: HTMLElement, done: () => void) {
done()
}
- // If we get disposed before the transition finishes, we should cleanup anyway.
- d.add(() => done())
-
return d.dispose
}
export function transition(
node: HTMLElement,
- classes: {
- base: string[]
- enter: string[]
- enterFrom: string[]
- enterTo: string[]
- leave: string[]
- leaveFrom: string[]
- leaveTo: string[]
- entered: string[]
- },
- show: boolean,
- done?: () => void
+ {
+ direction,
+ done,
+ classes,
+ inFlight,
+ }: {
+ direction: 'enter' | 'leave'
+ done?: () => void
+ classes: {
+ base: string[]
+ enter: string[]
+ enterFrom: string[]
+ enterTo: string[]
+ leave: string[]
+ leaveFrom: string[]
+ leaveTo: string[]
+ entered: string[]
+ }
+ inFlight?: MutableRefObject
+ }
) {
- let direction = show ? 'enter' : 'leave'
let d = disposables()
let _done = done !== undefined ? once(done) : () => {}
@@ -121,30 +127,84 @@ export function transition(
leave: () => classes.leaveFrom,
})
- removeClasses(
- node,
- ...classes.base,
- ...classes.enter,
- ...classes.enterTo,
- ...classes.enterFrom,
- ...classes.leave,
- ...classes.leaveFrom,
- ...classes.leaveTo,
- ...classes.entered
- )
- addClasses(node, ...classes.base, ...base, ...from)
+ // Prepare the transitions by ensuring that all the "before" classes are
+ // applied and flushed to the DOM.
+ prepareTransition(node, {
+ prepare() {
+ removeClasses(
+ node,
+ ...classes.base,
+ ...classes.enter,
+ ...classes.enterTo,
+ ...classes.enterFrom,
+ ...classes.leave,
+ ...classes.leaveFrom,
+ ...classes.leaveTo,
+ ...classes.entered
+ )
+ addClasses(node, ...classes.base, ...base, ...from)
+ },
+ inFlight,
+ })
+ // Mark the transition as in-flight
+ if (inFlight) inFlight.current = true
+
+ // This is a workaround for a bug in all major browsers.
+ //
+ // 1. When an element is just mounted
+ // 2. And you apply a transition to it (e.g.: via a class)
+ // 3. And you're using `getComputedStyle` and read any returned value
+ // 4. Then the `transition` immediately jumps to the end state
+ //
+ // This means that no transition happens at all. To fix this, we delay the
+ // actual transition by one frame.
d.nextFrame(() => {
+ // Wait for the transition, once the transition is complete we can cleanup.
+ // This is registered first to prevent race conditions, otherwise it could
+ // happen that the transition is already done before we start waiting for
+ // the actual event.
+ d.add(
+ waitForTransition(node, () => {
+ removeClasses(node, ...classes.base, ...base)
+ addClasses(node, ...classes.base, ...classes.entered, ...to)
+
+ // Mark the transition as done.
+ if (inFlight) inFlight.current = false
+
+ return _done()
+ })
+ )
+
+ // Initiate the transition by applying the new classes.
removeClasses(node, ...classes.base, ...base, ...from)
addClasses(node, ...classes.base, ...base, ...to)
-
- waitForTransition(node, () => {
- removeClasses(node, ...classes.base, ...base)
- addClasses(node, ...classes.base, ...classes.entered)
-
- return _done()
- })
})
return d.dispose
}
+
+function prepareTransition(
+ node: HTMLElement,
+ { inFlight, prepare }: { inFlight?: MutableRefObject; prepare: () => void }
+) {
+ // If we are already transitioning, then we don't need to force cancel the
+ // current transition (by triggering a reflow).
+ if (inFlight?.current) {
+ prepare()
+ return
+ }
+
+ let previous = node.style.transition
+
+ // Force cancel current transition
+ node.style.transition = 'none'
+
+ prepare()
+
+ // Trigger a reflow, flushing the CSS changes
+ node.offsetHeight
+
+ // Reset the transition to what it was before
+ node.style.transition = previous
+}
diff --git a/packages/@headlessui-react/src/hooks/use-disposables.ts b/packages/@headlessui-react/src/hooks/use-disposables.ts
index 0e90188137..c60a20c0aa 100644
--- a/packages/@headlessui-react/src/hooks/use-disposables.ts
+++ b/packages/@headlessui-react/src/hooks/use-disposables.ts
@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react'
import { disposables } from '../utils/disposables'
+/**
+ * The `useDisposables` hook returns a `disposables` object that is disposed
+ * when the component is unmounted.
+ */
export function useDisposables() {
// Using useState instead of useRef so that we can use the initializer function.
let [d] = useState(disposables)
diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts
index e9daf42111..2594bf85b1 100644
--- a/packages/@headlessui-react/src/hooks/use-transition.ts
+++ b/packages/@headlessui-react/src/hooks/use-transition.ts
@@ -1,13 +1,10 @@
-import type { MutableRefObject } from 'react'
+import { useRef, type MutableRefObject } from 'react'
import { transition } from '../components/transition/utils/transition'
-import { disposables } from '../utils/disposables'
import { useDisposables } from './use-disposables'
import { useIsMounted } from './use-is-mounted'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
-import { useLatestValue } from './use-latest-value'
interface TransitionArgs {
- immediate: boolean
container: MutableRefObject
classes: MutableRefObject<{
base: string[]
@@ -27,45 +24,35 @@ interface TransitionArgs {
onStop: MutableRefObject<(direction: TransitionArgs['direction']) => void>
}
-export function useTransition({
- immediate,
- container,
- direction,
- classes,
- onStart,
- onStop,
-}: TransitionArgs) {
+export function useTransition({ container, direction, classes, onStart, onStop }: TransitionArgs) {
let mounted = useIsMounted()
let d = useDisposables()
- let latestDirection = useLatestValue(direction)
+ // Track whether the transition is in flight or not. This will help us for
+ // cancelling mid-transition because in that case we don't have to force
+ // clearing existing transitions. See: `prepareTransition` in the `transition`
+ // file.
+ let inFlight = useRef(false)
useIsoMorphicEffect(() => {
- if (!immediate) return
-
- latestDirection.current = 'enter'
- }, [immediate])
-
- useIsoMorphicEffect(() => {
- let dd = disposables()
- d.add(dd.dispose)
-
let node = container.current
if (!node) return // We don't have a DOM node (yet)
- if (latestDirection.current === 'idle') return // We don't need to transition
+ if (direction === 'idle') return // We don't need to transition
if (!mounted.current) return
- dd.dispose()
-
- onStart.current(latestDirection.current)
+ onStart.current(direction)
- dd.add(
- transition(node, classes.current, latestDirection.current === 'enter', () => {
- dd.dispose()
- onStop.current(latestDirection.current)
+ d.add(
+ transition(node, {
+ direction,
+ classes: classes.current,
+ inFlight,
+ done() {
+ onStop.current(direction)
+ },
})
)
- return dd.dispose
+ return d.dispose
}, [direction])
}
diff --git a/packages/@headlessui-react/src/utils/disposables.ts b/packages/@headlessui-react/src/utils/disposables.ts
index 55ca27b6b5..3b404e5d5d 100644
--- a/packages/@headlessui-react/src/utils/disposables.ts
+++ b/packages/@headlessui-react/src/utils/disposables.ts
@@ -2,6 +2,18 @@ import { microTask } from './micro-task'
export type Disposables = ReturnType
+/**
+ * Disposables are a way to manage event handlers and functions like
+ * `setTimeout` and `requestAnimationFrame` that need to be cleaned up when they
+ * are no longer needed.
+ *
+ *
+ * When you register a disposable function, it is added to a collection of
+ * disposables. Each disposable in the collection provides a `dispose` clean up
+ * function that can be called when it's no longer needed. There is also a
+ * `dispose` function on the collection itself that can be used to clean up all
+ * pending disposables in that collection.
+ */
export function disposables() {
let _disposables: Function[] = []
@@ -59,11 +71,11 @@ export function disposables() {
},
add(cb: () => void) {
- if (_disposables.includes(cb)) {
- return
+ // Ensure we don't add the same callback twice
+ if (!_disposables.includes(cb)) {
+ _disposables.push(cb)
}
- _disposables.push(cb)
return () => {
let idx = _disposables.indexOf(cb)
if (idx >= 0) {
diff --git a/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx b/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx
index d6f017837d..5264d09b84 100644
--- a/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx
+++ b/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx
@@ -1,5 +1,5 @@
-import { Listbox } from '@headlessui/react'
-import { useEffect, useState } from 'react'
+import { Listbox, Transition } from '@headlessui/react'
+import { Fragment, useEffect, useState } from 'react'
let people = [
'Wade Cooper',
@@ -59,30 +59,40 @@ export default function Home() {
-