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`] = `
Children
diff --git a/packages/@headlessui-react/src/components/transition/transition.test.tsx b/packages/@headlessui-react/src/components/transition/transition.test.tsx index 1b33ed6b57..2b1b10e50d 100644 --- a/packages/@headlessui-react/src/components/transition/transition.test.tsx +++ b/packages/@headlessui-react/src/components/transition/transition.test.tsx @@ -343,6 +343,7 @@ describe('Setup API', () => {
Children
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() { -
- - {people.map((name) => ( - - - {name} - - - - - - - - ))} - -
+ +
+ + {people.map((name) => ( + + + {name} + + + + + + + + ))} + +
+
diff --git a/playgrounds/react/pages/menu/menu-with-transition.tsx b/playgrounds/react/pages/menu/menu-with-transition.tsx index 89191e626a..bef7e0497d 100644 --- a/playgrounds/react/pages/menu/menu-with-transition.tsx +++ b/playgrounds/react/pages/menu/menu-with-transition.tsx @@ -1,4 +1,5 @@ import { Menu, Transition } from '@headlessui/react' +import { Fragment } from 'react' import { Button } from '../../components/button' import { classNames } from '../../utils/class-names' @@ -29,10 +30,11 @@ export default function Home() { console.log('Before enter')} @@ -40,7 +42,10 @@ export default function Home() { beforeLeave={() => console.log('Before leave')} afterLeave={() => console.log('After leave')} > - +

Signed in as