Skip to content

Commit

Permalink
rely on node.getAnimations() to know when transitions are done
Browse files Browse the repository at this point in the history
Up until now, we've tried to do this ourselves by listening to the
correct events. The new `node.getAnimations()` API is a much simpler API
to use.

The only requirement is that we call `node.getAnimations()` in a
`requestAnimationFrame` so that the browser can flush all the changes.
We couldn't do this before, because we needed to setup the event
listeners to prevent race conditions.

Now there are no race conditions, in fact, if all transitions already
complete before we can call the `waitForTransition`, then the
`node.getAnimations()` list will be empty and we can call the `done()`
function.

The `Element.prototype.getAnimations` has been available in browsers
since mid 2020, but at the time it was too new to use. Now seems like a
safe time to use this.

See: https://developer.mozilla.org/en-US/docs/Web/API/Element/getAnimations#browser_compatibility
  • Loading branch information
RobinMalfait committed Sep 4, 2024
1 parent d0513eb commit 62a2ae2
Showing 1 changed file with 22 additions and 64 deletions.
86 changes: 22 additions & 64 deletions packages/@headlessui-react/src/hooks/use-transition.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useRef, useState, type MutableRefObject } from 'react'
import { disposables } from '../utils/disposables'
import { once } from '../utils/once'
import { useDisposables } from './use-disposables'
import { useFlags } from './use-flags'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
Expand Down Expand Up @@ -211,84 +210,43 @@ function transition(
// 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()

// Wait for the transition, once the transition is complete we can cleanup.
// We wait for a frame such that the browser has time to flush the changes
// to the DOM.
d.requestAnimationFrame(() => {
d.add(waitForTransition(node, done))
})
})

return d.dispose
}

function waitForTransition(node: HTMLElement, _done: () => void) {
let done = once(_done)
function waitForTransition(node: HTMLElement | null, done: () => void) {
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 canceled = false
d.add(() => {
canceled = true
})

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.
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
}

// Wait for all the transitions to complete.
Promise.allSettled(transitions.map((transition) => transition.finished)).then(() => {
if (!canceled) {
done()
}
})

return d.dispose
}

Expand Down

0 comments on commit 62a2ae2

Please sign in to comment.