diff --git a/packages/@headlessui-react/src/components/transition/transition.tsx b/packages/@headlessui-react/src/components/transition/transition.tsx
index 2ab9916..0a676d7 100644
--- a/packages/@headlessui-react/src/components/transition/transition.tsx
+++ b/packages/@headlessui-react/src/components/transition/transition.tsx
@@ -4,6 +4,7 @@ import React, {
Fragment,
createContext,
useContext,
+ useEffect,
useMemo,
useRef,
useState,
@@ -13,7 +14,6 @@ import React, {
} from 'react'
import { useDisposables } from '../../hooks/use-disposables'
import { useEvent } from '../../hooks/use-event'
-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'
@@ -21,7 +21,6 @@ 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'
-import { useTransitionData } from '../../hooks/use-transition-data'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import type { Props, ReactTag } from '../../types'
import { classNames } from '../../utils/class-names'
@@ -29,6 +28,7 @@ import { match } from '../../utils/match'
import {
RenderFeatures,
RenderStrategy,
+ compact,
forwardRefWithAs,
render,
type HasDisplayName,
@@ -38,17 +38,7 @@ import {
type ContainerElement = MutableRefObject
-type TransitionDirection = 'enter' | 'leave' | 'idle'
-
-/**
- * Split class lists by whitespace
- *
- * We can't check for just spaces as all whitespace characters are
- * invalid in a class name, so we have to split on ANY whitespace.
- */
-function splitClasses(classes: string = '') {
- return classes.split(/\s+/).filter((className) => className.length > 1)
-}
+type TransitionDirection = 'enter' | 'leave'
/**
* Check if we should forward the ref to the child element or not. This is to
@@ -224,11 +214,7 @@ function useNesting(done?: () => void, parent?: NestingContextValues) {
let chains = useRef<
Record][]>
- >({
- enter: [],
- leave: [],
- idle: [],
- })
+ >({ enter: [], leave: [] })
let onStart = useEvent(
(
@@ -328,13 +314,13 @@ function TransitionChildFn(null)
let requiresRef = shouldForwardRef(props)
let transitionRef = useSyncRefs(...(requiresRef ? [container, ref] : ref === null ? [] : [ref]))
- let strategy = rest.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden
+ let strategy = theirProps.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden
let { show, appear, initial } = useTransitionContext()
@@ -362,24 +348,6 @@ function TransitionChildFn {
@@ -394,43 +362,6 @@ function TransitionChildFn {
- if (immediate) return 'enter'
- if (!ready) return 'idle'
- if (skip) return 'idle'
- return show ? 'enter' : 'leave'
- })() as TransitionDirection
-
- let transitionStateFlags = useFlags(0)
-
- let beforeEvent = useEvent((direction: TransitionDirection) => {
- return match(direction, {
- enter: () => {
- transitionStateFlags.addFlag(State.Opening)
- events.current.beforeEnter?.()
- },
- leave: () => {
- transitionStateFlags.addFlag(State.Closing)
- events.current.beforeLeave?.()
- },
- idle: () => {},
- })
- })
-
- let afterEvent = useEvent((direction: TransitionDirection) => {
- return match(direction, {
- enter: () => {
- transitionStateFlags.removeFlag(State.Opening)
- events.current.afterEnter?.()
- },
- leave: () => {
- transitionStateFlags.removeFlag(State.Closing)
- events.current.afterLeave?.()
- },
- idle: () => {},
- })
- })
-
let isTransitioning = useRef(false)
let nesting = useNesting(() => {
@@ -443,77 +374,97 @@ function TransitionChildFn {
- isTransitioning.current = true
- nesting.onStart(container, direction, beforeEvent)
- }),
- onStop: useLatestValue((direction) => {
- isTransitioning.current = false
- nesting.onStop(container, direction, afterEvent)
+ let start = useEvent((show: boolean) => {
+ isTransitioning.current = true
+ let direction: TransitionDirection = show ? 'enter' : 'leave'
- if (direction === 'leave' && !hasChildren(nesting)) {
- // When we don't have children anymore we can safely unregister from the
- // parent and hide ourselves.
- setState(TreeStates.Hidden)
- unregister(container)
- }
- }),
+ nesting.onStart(container, direction, (direction) => {
+ if (direction === 'enter') beforeEnter?.()
+ else if (direction === 'leave') beforeLeave?.()
+ })
})
- let theirProps = rest
- let ourProps = { ref: transitionRef }
+ let end = useEvent((show: boolean) => {
+ let direction: TransitionDirection = show ? 'enter' : 'leave'
- // Already apply the `enter` and `enterFrom` on the server if required
- if (immediate) {
- theirProps = {
- ...theirProps,
- className: classNames(rest.className, ...classes.current.enter, ...classes.current.enterFrom),
+ isTransitioning.current = false
+ nesting.onStop(container, direction, (direction) => {
+ if (direction === 'enter') afterEnter?.()
+ else if (direction === 'leave') afterLeave?.()
+ })
+
+ if (direction === 'leave' && !hasChildren(nesting)) {
+ // When we don't have children anymore we can safely unregister from the
+ // parent and hide ourselves.
+ setState(TreeStates.Hidden)
+ unregister(container)
}
- }
+ })
- // If we are re-rendering while we are transitioning, then we should ensure that the classes are
- // not mutated by React itself because we are handling the transition ourself.
- else if (isTransitioning.current) {
- // When we re-render while we are in the middle of the transition, then we should take the
- // incoming className and the current classes that are applied.
- //
- // This is a bit dirty, but we need to make sure React is not applying changes to the class
- // attribute while we are transitioning.
- theirProps.className = classNames(rest.className, container.current?.className)
- if (theirProps.className === '') delete theirProps.className
- }
+ useEffect(() => {
+ if (requiresRef) return
- // If we were never transitioning, or we're not transitioning anymore, then
- // apply the `enterTo` and `leaveTo` classes as the final state.
- else {
- theirProps.className = classNames(
- rest.className,
- container.current?.className,
- ...match(transitionDirection, {
- enter: [...classes.current.enterTo, ...classes.current.entered],
- leave: classes.current.leaveTo,
- idle: [],
- })
- )
- if (theirProps.className === '') delete theirProps.className
- }
+ // When we don't transition, then we can complete the transition
+ // immediately.
+ start(show)
+ end(show)
+ }, [show, requiresRef])
- let [, slot] = useTransitionData(ready, container, show)
+ let enabled = (() => {
+ // If we don't require a ref, then we can't transition.
+ if (!requiresRef) return false
+
+ // If the server handoff isn't completed yet, we can't transition.
+ if (!ready) return false
+
+ // If we start in a `show` state but without the `appear` prop, then we skip
+ // the initial transition.
+ if (skip) return false
+
+ return true
+ })()
+
+ // Ignoring the `visible` state because this doesn't handle the hierarchy. If
+ // a leave transition on the `` is done, but there is still a
+ // child `` busy, then `visible` would be `false`, while
+ // `state` would still be `TreeStates.Visible`.
+ let [, slot] = useTransition(enabled, container, show, { start, end })
+
+ let ourProps = compact({
+ ref: transitionRef,
+ className:
+ classNames(
+ // Incoming classes if any
+ theirProps.className,
+
+ // Apply these classes immediately
+ immediate && enter,
+ immediate && enterFrom,
+
+ // Map data attributes to `enter`, `enterFrom` and `enterTo` classes
+ slot.enter && enter,
+ slot.enter && slot.closed && enterFrom,
+ slot.enter && !slot.closed && enterTo,
+
+ // Map data attributes to `leave`, `leaveFrom` and `leaveTo` classes
+ slot.leave && leave,
+ slot.leave && !slot.closed && leaveFrom,
+ slot.leave && slot.closed && leaveTo,
+
+ // Map data attributes to `entered` class (backwards compatibility)
+ !slot.transition && show && entered
+ )?.trim() || undefined, // If `className` is an empty string, we can omit it
+ })
+
+ let openClosedState = 0
+ if (state === TreeStates.Visible) openClosedState |= State.Open
+ if (state === TreeStates.Hidden) openClosedState |= State.Closed
+ if (slot.enter) openClosedState |= State.Opening
+ if (slot.leave) openClosedState |= State.Closing
return (
-
+
{render({
ourProps,
theirProps,
diff --git a/packages/@headlessui-react/src/components/transition/utils/transition.test.ts b/packages/@headlessui-react/src/components/transition/utils/transition.test.ts
deleted file mode 100644
index fdbed2d..0000000
--- a/packages/@headlessui-react/src/components/transition/utils/transition.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { reportChanges } from '../../../test-utils/report-dom-node-changes'
-import { disposables } from '../../../utils/disposables'
-import { transition } from './transition'
-
-beforeEach(() => {
- document.body.innerHTML = ''
-})
-
-it('should be possible to transition', async () => {
- let d = disposables()
-
- let snapshots: { content: string; recordedAt: bigint }[] = []
- let element = document.createElement('div')
- document.body.appendChild(element)
-
- d.add(
- reportChanges(
- () => document.body.innerHTML,
- (content) => {
- snapshots.push({
- content,
- recordedAt: process.hrtime.bigint(),
- })
- }
- )
- )
-
- await new Promise((resolve) => {
- transition(element, {
- direction: 'enter', // Show
- classes: {
- base: [],
- enter: ['enter'],
- enterFrom: ['enterFrom'],
- enterTo: ['enterTo'],
- leave: [],
- leaveFrom: [],
- leaveTo: [],
- entered: ['entered'],
- },
- done: resolve,
- })
- })
-
- await new Promise((resolve) => d.nextFrame(resolve))
-
- // Initial render:
- expect(snapshots[0].content).toEqual('')
-
- // Start of transition
- 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('')
-
- d.dispose()
-})
-
-it('should wait the correct amount of time to finish a transition', async () => {
- let d = disposables()
-
- let snapshots: { content: string; recordedAt: bigint }[] = []
- let element = document.createElement('div')
- document.body.appendChild(element)
-
- let duration = 20
-
- element.style.transitionDuration = `${duration}ms`
-
- d.add(
- reportChanges(
- () => document.body.innerHTML,
- (content) => {
- snapshots.push({
- content,
- recordedAt: process.hrtime.bigint(),
- })
- }
- )
- )
-
- await new Promise((resolve) => {
- transition(element, {
- direction: 'enter', // Show
- classes: {
- base: [],
- enter: ['enter'],
- enterFrom: ['enterFrom'],
- enterTo: ['enterTo'],
- leave: [],
- leaveFrom: [],
- leaveTo: [],
- entered: ['entered'],
- },
- done: resolve,
- })
- })
-
- await new Promise((resolve) => d.nextFrame(resolve))
-
- // Initial render:
- expect(snapshots[0].content).toEqual(``)
-
- // Start of transition
- expect(snapshots[1].content).toEqual(
- ``
- )
-
- expect(snapshots[2].content).toEqual(
- ``
- )
-
- let estimatedDuration = Number(
- (snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) /
- BigInt(1e6)
- )
-
- expect(estimatedDuration).toBeWithinRenderFrame(duration)
-
- // Cleanup phase
- expect(snapshots[3].content).toEqual(
- ``
- )
-})
-
-it('should keep the delay time into account', async () => {
- let d = disposables()
-
- let snapshots: { content: string; recordedAt: bigint }[] = []
- let element = document.createElement('div')
- document.body.appendChild(element)
-
- let duration = 20
- let delayDuration = 100
-
- element.style.transitionDuration = `${duration}ms`
- element.style.transitionDelay = `${delayDuration}ms`
-
- d.add(
- reportChanges(
- () => document.body.innerHTML,
- (content) => {
- snapshots.push({
- content,
- recordedAt: process.hrtime.bigint(),
- })
- }
- )
- )
-
- await new Promise((resolve) => {
- transition(element, {
- direction: 'enter', // Show
- classes: {
- base: [],
- enter: ['enter'],
- enterFrom: ['enterFrom'],
- enterTo: ['enterTo'],
- leave: [],
- leaveFrom: [],
- leaveTo: [],
- entered: ['entered'],
- },
- done: resolve,
- })
- })
-
- await new Promise((resolve) => d.nextFrame(resolve))
-
- let estimatedDuration = Number(
- (snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) /
- BigInt(1e6)
- )
-
- expect(estimatedDuration).toBeWithinRenderFrame(duration + delayDuration)
-})
diff --git a/packages/@headlessui-react/src/components/transition/utils/transition.ts b/packages/@headlessui-react/src/components/transition/utils/transition.ts
deleted file mode 100644
index a3ea34e..0000000
--- a/packages/@headlessui-react/src/components/transition/utils/transition.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import type { MutableRefObject } from 'react'
-import { disposables } from '../../../utils/disposables'
-import { match } from '../../../utils/match'
-import { once } from '../../../utils/once'
-
-function addClasses(node: HTMLElement, ...classes: string[]) {
- node && classes.length > 0 && node.classList.add(...classes)
-}
-
-function removeClasses(node: HTMLElement, ...classes: string[]) {
- node && classes.length > 0 && node.classList.remove(...classes)
-}
-
-export function waitForTransition(node: HTMLElement, _done: () => void) {
- let done = once(_done)
- let d = disposables()
-
- if (!node) return d.dispose
-
- // Safari returns a comma separated list of values, so let's sort them and take the highest value.
- let { transitionDuration, transitionDelay } = getComputedStyle(node)
-
- let [durationMs, delayMs] = [transitionDuration, transitionDelay].map((value) => {
- let [resolvedValue = 0] = value
- .split(',')
- // Remove falsy we can't work with
- .filter(Boolean)
- // Values are returned as `0.3s` or `75ms`
- .map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
- .sort((a, z) => z - a)
-
- return resolvedValue
- })
-
- let totalDuration = durationMs + delayMs
-
- if (totalDuration !== 0) {
- if (process.env.NODE_ENV === 'test') {
- let dispose = d.setTimeout(() => {
- done()
- dispose()
- }, totalDuration)
- } else {
- let disposeGroup = d.group((d) => {
- // Mark the transition as done when the timeout is reached. This is a fallback in case the
- // transitionrun event is not fired.
- let cancelTimeout = d.setTimeout(() => {
- done()
- d.dispose()
- }, totalDuration)
-
- // The moment the transitionrun event fires, we should cleanup the timeout fallback, because
- // then we know that we can use the native transition events because something is
- // transitioning.
- d.addEventListener(node, 'transitionrun', (event) => {
- if (event.target !== event.currentTarget) return
- cancelTimeout()
-
- d.addEventListener(node, 'transitioncancel', (event) => {
- if (event.target !== event.currentTarget) return
- done()
- disposeGroup()
- })
- })
- })
-
- d.addEventListener(node, 'transitionend', (event) => {
- if (event.target !== event.currentTarget) return
- done()
- d.dispose()
- })
- }
- } else {
- // No transition is happening, so we should cleanup already. Otherwise we have to wait until we
- // get disposed.
- done()
- }
-
- return d.dispose
-}
-
-export function transition(
- node: HTMLElement,
- {
- 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 d = disposables()
- let _done = done !== undefined ? once(done) : () => {}
-
- // When using unmount={false}, when the element is "hidden", then we apply a `style.display =
- // 'none'` and a `hidden` attribute. Let's remove that in case we want to make an enter
- // transition. It can happen that React is removing this a bit too late causing the element to not
- // transition at all.
- if (direction === 'enter') {
- node.removeAttribute('hidden')
- node.style.display = ''
- }
-
- let base = match(direction, {
- enter: () => classes.enter,
- leave: () => classes.leave,
- })
- let to = match(direction, {
- enter: () => classes.enterTo,
- leave: () => classes.leaveTo,
- })
- let from = match(direction, {
- enter: () => classes.enterFrom,
- leave: () => classes.leaveFrom,
- })
-
- // 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)
- })
-
- return d.dispose
-}
-
-export 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-transition-data.ts b/packages/@headlessui-react/src/hooks/use-transition-data.ts
deleted file mode 100644
index 8e93692..0000000
--- a/packages/@headlessui-react/src/hooks/use-transition-data.ts
+++ /dev/null
@@ -1,213 +0,0 @@
-import { useRef, useState, type MutableRefObject } from 'react'
-import { prepareTransition, waitForTransition } from '../components/transition/utils/transition'
-import { disposables } from '../utils/disposables'
-import { useDisposables } from './use-disposables'
-import { useFlags } from './use-flags'
-import { useIsoMorphicEffect } from './use-iso-morphic-effect'
-
-/**
- * ```
- * ┌──────┐ │ ┌──────────────┐
- * │Closed│ │ │Closed │
- * └──────┘ │ └──────────────┘
- * ┌──────┐┌──────┐┌──────┐│┌──────┐┌──────┐┌──────┐
- * │Frame ││Frame ││Frame │││Frame ││Frame ││Frame │
- * └──────┘└──────┘└──────┘│└──────┘└──────┘└──────┘
- * ┌──────────────────────┐│┌──────────────────────┐
- * │Enter │││Leave │
- * └──────────────────────┘│└──────────────────────┘
- * ┌──────────────────────┐│┌──────────────────────┐
- * │Transition │││Transition │
- * ├──────────────────────┘│└──────────────────────┘
- * │
- * └─ Applied when `Enter` or `Leave` is applied.
- * ```
- */
-enum TransitionState {
- None = 0,
-
- Closed = 1 << 0,
-
- Enter = 1 << 1,
- Leave = 1 << 2,
-}
-
-export type TransitionData = {
- closed?: boolean
- enter?: boolean
- leave?: boolean
- transition?: boolean
-}
-
-export function useTransitionData(
- enabled: boolean,
- elementRef: MutableRefObject,
- show: boolean
-): [visible: boolean, data: TransitionData] {
- let [visible, setVisible] = useState(show)
-
- let { hasFlag, addFlag, removeFlag } = useFlags(
- visible ? TransitionState.Closed : TransitionState.None
- )
- let inFlight = useRef(false)
- let cancelledRef = useRef(false)
-
- let d = useDisposables()
-
- useIsoMorphicEffect(
- function retry() {
- if (!enabled) return
-
- if (show) {
- setVisible(true)
- }
-
- let node = elementRef.current
- if (!node) {
- // Retry if the DOM node isn't available yet
- if (show) {
- addFlag(TransitionState.Enter | TransitionState.Closed)
- return d.nextFrame(() => retry())
- }
- return
- }
-
- return transition(node, {
- inFlight,
- prepare() {
- if (cancelledRef.current) {
- // Cancelled a cancellation, we're back to the original state.
- cancelledRef.current = false
- } else {
- // If we were already in-flight, then we want to cancel the current
- // transition.
- cancelledRef.current = inFlight.current
- }
-
- inFlight.current = true
-
- if (cancelledRef.current) return
-
- if (show) {
- addFlag(TransitionState.Enter | TransitionState.Closed)
- removeFlag(TransitionState.Leave)
- } else {
- addFlag(TransitionState.Leave)
- removeFlag(TransitionState.Enter)
- }
- },
- run() {
- if (cancelledRef.current) {
- // If we cancelled a transition, then the `show` state is going to
- // be inverted already, but that doesn't mean we have to go to that
- // new state.
- //
- // What we actually want is to revert to the "idle" state (the
- // stable state where an `Enter` transitions to, and a `Leave`
- // transitions from.)
- //
- // Because of this, it might look like we are swapping the flags in
- // the following branches, but that's not the case.
- if (show) {
- removeFlag(TransitionState.Enter | TransitionState.Closed)
- addFlag(TransitionState.Leave)
- } else {
- removeFlag(TransitionState.Leave)
- addFlag(TransitionState.Enter | TransitionState.Closed)
- }
- } else {
- if (show) {
- removeFlag(TransitionState.Closed)
- } else {
- addFlag(TransitionState.Closed)
- }
- }
- },
- done() {
- if (cancelledRef.current) {
- if (typeof node.getAnimations === 'function' && node.getAnimations().length > 0) {
- return
- }
- }
-
- inFlight.current = false
-
- removeFlag(TransitionState.Enter | TransitionState.Leave | TransitionState.Closed)
-
- if (!show) {
- setVisible(false)
- }
- },
- })
- },
- [enabled, show, elementRef, d]
- )
-
- if (!enabled) {
- return [
- show,
- {
- closed: undefined,
- enter: undefined,
- leave: undefined,
- transition: undefined,
- },
- ] as const
- }
-
- return [
- visible,
- {
- closed: hasFlag(TransitionState.Closed),
- enter: hasFlag(TransitionState.Enter),
- leave: hasFlag(TransitionState.Leave),
- transition: hasFlag(TransitionState.Enter) || hasFlag(TransitionState.Leave),
- },
- ] as const
-}
-
-function transition(
- node: HTMLElement,
- {
- prepare,
- run,
- done,
- inFlight,
- }: {
- prepare: () => void
- run: () => void
- done: () => void
- inFlight: MutableRefObject
- }
-) {
- let d = disposables()
-
- // Prepare the transitions by ensuring that all the "before" classes are
- // applied and flushed to the DOM.
- prepareTransition(node, {
- prepare,
- inFlight,
- })
-
- // 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, done))
-
- // Initiate the transition by applying the new classes.
- run()
- })
-
- return d.dispose
-}
diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts
index e8114dd..7977184 100644
--- a/packages/@headlessui-react/src/hooks/use-transition.ts
+++ b/packages/@headlessui-react/src/hooks/use-transition.ts
@@ -1,66 +1,314 @@
-import { useRef, type MutableRefObject } from 'react'
-import { transition } from '../components/transition/utils/transition'
+import { useRef, useState, type MutableRefObject } from 'react'
+import { disposables } from '../utils/disposables'
+import { once } from '../utils/once'
import { useDisposables } from './use-disposables'
-import { useIsMounted } from './use-is-mounted'
+import { useFlags } from './use-flags'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
-interface TransitionArgs {
- container: MutableRefObject
- classes: MutableRefObject<{
- base: string[]
+/**
+ * ```
+ * ┌──────┐ │ ┌──────────────┐
+ * │Closed│ │ │Closed │
+ * └──────┘ │ └──────────────┘
+ * ┌──────┐┌──────┐┌──────┐│┌──────┐┌──────┐┌──────┐
+ * │Frame ││Frame ││Frame │││Frame ││Frame ││Frame │
+ * └──────┘└──────┘└──────┘│└──────┘└──────┘└──────┘
+ * ┌──────────────────────┐│┌──────────────────────┐
+ * │Enter │││Leave │
+ * └──────────────────────┘│└──────────────────────┘
+ * ┌──────────────────────┐│┌──────────────────────┐
+ * │Transition │││Transition │
+ * ├──────────────────────┘│└──────────────────────┘
+ * │
+ * └─ Applied when `Enter` or `Leave` is applied.
+ * ```
+ */
+enum TransitionState {
+ None = 0,
- enter: string[]
- enterFrom: string[]
- enterTo: string[]
+ Closed = 1 << 0,
- leave: string[]
- leaveFrom: string[]
- leaveTo: string[]
-
- entered: string[]
- }>
- direction: 'enter' | 'leave' | 'idle'
- onStart: MutableRefObject<(direction: TransitionArgs['direction']) => void>
- onStop: MutableRefObject<(direction: TransitionArgs['direction']) => void>
+ Enter = 1 << 1,
+ Leave = 1 << 2,
}
-export function useTransition({ container, direction, classes, onStart, onStop }: TransitionArgs) {
- let mounted = useIsMounted()
+export type TransitionData = {
+ closed?: boolean
+ enter?: boolean
+ leave?: boolean
+ transition?: boolean
+}
+
+export function useTransition(
+ enabled: boolean,
+ elementRef: MutableRefObject,
+ show: boolean,
+ events?: {
+ start?(show: boolean): void
+ end?(show: boolean): void
+ }
+): [visible: boolean, data: TransitionData] {
+ let [visible, setVisible] = useState(show)
+
+ let { hasFlag, addFlag, removeFlag } = useFlags(
+ enabled && visible ? TransitionState.Enter | TransitionState.Closed : TransitionState.None
+ )
+ let inFlight = useRef(false)
+ let cancelledRef = useRef(false)
+
let d = useDisposables()
- // 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(
+ function retry() {
+ if (!enabled) return
- useIsoMorphicEffect(() => {
- if (direction === 'idle') return // We don't need to transition
- if (!mounted.current) return
+ if (show) {
+ setVisible(true)
+ }
- onStart.current(direction)
+ let node = elementRef.current
+ if (!node) {
+ // Retry if the DOM node isn't available yet
+ if (show) {
+ addFlag(TransitionState.Enter | TransitionState.Closed)
+ return d.nextFrame(() => retry())
+ }
+ return
+ }
- let node = container.current
- if (!node) {
- // No node, so let's skip the transition and call the `onStop` callback
- // immediately because there is no transition to wait for anyway.
- onStop.current(direction)
- }
+ events?.start?.(show)
- // We do have a node, let's transition it!
- else {
- d.add(
- transition(node, {
- direction,
- classes: classes.current,
- inFlight,
- done() {
- onStop.current(direction)
- },
- })
- )
- }
+ return transition(node, {
+ inFlight,
+ prepare() {
+ if (cancelledRef.current) {
+ // Cancelled a cancellation, we're back to the original state.
+ cancelledRef.current = false
+ } else {
+ // If we were already in-flight, then we want to cancel the current
+ // transition.
+ cancelledRef.current = inFlight.current
+ }
- return d.dispose
- }, [direction])
+ inFlight.current = true
+
+ if (cancelledRef.current) return
+
+ if (show) {
+ addFlag(TransitionState.Enter | TransitionState.Closed)
+ removeFlag(TransitionState.Leave)
+ } else {
+ addFlag(TransitionState.Leave)
+ removeFlag(TransitionState.Enter)
+ }
+ },
+ run() {
+ if (cancelledRef.current) {
+ // If we cancelled a transition, then the `show` state is going to
+ // be inverted already, but that doesn't mean we have to go to that
+ // new state.
+ //
+ // What we actually want is to revert to the "idle" state (the
+ // stable state where an `Enter` transitions to, and a `Leave`
+ // transitions from.)
+ //
+ // Because of this, it might look like we are swapping the flags in
+ // the following branches, but that's not the case.
+ if (show) {
+ removeFlag(TransitionState.Enter | TransitionState.Closed)
+ addFlag(TransitionState.Leave)
+ } else {
+ removeFlag(TransitionState.Leave)
+ addFlag(TransitionState.Enter | TransitionState.Closed)
+ }
+ } else {
+ if (show) {
+ removeFlag(TransitionState.Closed)
+ } else {
+ addFlag(TransitionState.Closed)
+ }
+ }
+ },
+ done() {
+ if (cancelledRef.current) {
+ if (typeof node.getAnimations === 'function' && node.getAnimations().length > 0) {
+ return
+ }
+ }
+
+ inFlight.current = false
+
+ removeFlag(TransitionState.Enter | TransitionState.Leave | TransitionState.Closed)
+
+ if (!show) {
+ setVisible(false)
+ }
+
+ events?.end?.(show)
+ },
+ })
+ },
+ [enabled, show, elementRef, d]
+ )
+
+ if (!enabled) {
+ return [
+ show,
+ {
+ closed: undefined,
+ enter: undefined,
+ leave: undefined,
+ transition: undefined,
+ },
+ ] as const
+ }
+
+ return [
+ visible,
+ {
+ closed: hasFlag(TransitionState.Closed),
+ enter: hasFlag(TransitionState.Enter),
+ leave: hasFlag(TransitionState.Leave),
+ transition: hasFlag(TransitionState.Enter) || hasFlag(TransitionState.Leave),
+ },
+ ] as const
+}
+
+function transition(
+ node: HTMLElement,
+ {
+ prepare,
+ run,
+ done,
+ inFlight,
+ }: {
+ prepare: () => void
+ run: () => void
+ done: () => void
+ inFlight: MutableRefObject
+ }
+) {
+ let d = disposables()
+
+ // Prepare the transitions by ensuring that all the "before" classes are
+ // applied and flushed to the DOM.
+ prepareTransition(node, {
+ prepare,
+ inFlight,
+ })
+
+ // 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, done))
+
+ // Initiate the transition by applying the new classes.
+ run()
+ })
+
+ return d.dispose
+}
+
+function waitForTransition(node: HTMLElement, _done: () => void) {
+ let done = once(_done)
+ let d = disposables()
+
+ if (!node) return d.dispose
+
+ // Safari returns a comma separated list of values, so let's sort them and take the highest value.
+ let { transitionDuration, transitionDelay } = getComputedStyle(node)
+
+ let [durationMs, delayMs] = [transitionDuration, transitionDelay].map((value) => {
+ let [resolvedValue = 0] = value
+ .split(',')
+ // Remove falsy we can't work with
+ .filter(Boolean)
+ // Values are returned as `0.3s` or `75ms`
+ .map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
+ .sort((a, z) => z - a)
+
+ return resolvedValue
+ })
+
+ let totalDuration = durationMs + delayMs
+
+ if (totalDuration !== 0) {
+ if (process.env.NODE_ENV === 'test') {
+ let dispose = d.setTimeout(() => {
+ done()
+ dispose()
+ }, totalDuration)
+ } else {
+ let disposeGroup = d.group((d) => {
+ // Mark the transition as done when the timeout is reached. This is a fallback in case the
+ // transitionrun event is not fired.
+ let cancelTimeout = d.setTimeout(() => {
+ done()
+ d.dispose()
+ }, totalDuration)
+
+ // The moment the transitionrun event fires, we should cleanup the timeout fallback, because
+ // then we know that we can use the native transition events because something is
+ // transitioning.
+ d.addEventListener(node, 'transitionrun', (event) => {
+ if (event.target !== event.currentTarget) return
+ cancelTimeout()
+
+ d.addEventListener(node, 'transitioncancel', (event) => {
+ if (event.target !== event.currentTarget) return
+ done()
+ disposeGroup()
+ })
+ })
+ })
+
+ d.addEventListener(node, 'transitionend', (event) => {
+ if (event.target !== event.currentTarget) return
+ done()
+ d.dispose()
+ })
+ }
+ } else {
+ // No transition is happening, so we should cleanup already. Otherwise we have to wait until we
+ // get disposed.
+ 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/playgrounds/react/pages/transitions/both-apis.tsx b/playgrounds/react/pages/transitions/both-apis.tsx
new file mode 100644
index 0000000..b876f9a
--- /dev/null
+++ b/playgrounds/react/pages/transitions/both-apis.tsx
@@ -0,0 +1,55 @@
+import { Transition } from '@headlessui/react'
+import clsx from 'clsx'
+import { useState } from 'react'
+
+export default function Example() {
+ const [open, setOpen] = useState(false)
+ return (
+
+
+
+
+
+
+ )
+}
+
+function Before({ open }: { open: boolean }) {
+ return (
+
+ Using specific props
+
+ )
+}
+
+function After({ open }: { open: boolean }) {
+ return (
+
+
+ Using data attributes
+
+
+ )
+}