Skip to content

Commit 971ff6b

Browse files
authored
Fix transition bug on Firefox, triggered by clicking the PopoverButton in rapid succession (#3452)
We recently landed a fix for `Popover`s not closing correctly when using the `transition` prop (#3448). Once this fix was published, some users still ran into issues using Firefox on Windows (see: tailwindlabs/tailwind-plus-issues#1625). One fun thing I discovered is that transitions somehow behave differently based on where they are triggered from (?). What I mean by this is that holding down the <kbd>space</kbd> key on the button does properly open/close the `Popover`. But if you rapidly click the button, the `Popover` will eventually get stuck. > Note: when testing this, I made sure that the handling of the `space` key (in a `keydown` handler) and the clicking of the mouse (handled in a `click` handler) called the exact same code. It still happened. The debugging continues… One thing I noticed is that when the `Popover` gets stuck, it meant that a transition didn't properly complete. The current implementation of the internal `useTransition(…)` hook has to wait for all the transitions to finish. This is done using a `waitForTransition(…)` helper. This helper sets up some event listeners (`transitionstart`, `transitionend`, …) and waits for them to fire. This seems to be unreliable on Firefox for some unknown reason. I knew the code for waiting for transitions wasn't ideal, so I wanted to see if using the native `node.getAnimations()` simplifies this and makes it work in general. Lo and behold, it did! 🎉 This now has multiple benefits: 1. It works as expected on Firefox 2. The code is much much simpler 3. Uses native features The `getAnimations(…)` function is supported in all modern browsers (since 2020). At the time it was too early to rely on it, but right now it should be safe to use. Fixes: tailwindlabs/tailwind-plus-issues#1625
1 parent 75619ee commit 971ff6b

File tree

4 files changed

+74
-69
lines changed

4 files changed

+74
-69
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Fixed
11+
12+
- Fix transition bug on Firefox, triggered by clicking the `PopoverButton` in rapid succession ([#3452](https://github.com/tailwindlabs/headlessui/pull/3452))
1113

1214
## [2.1.4] - 2024-09-03
1315

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,46 @@
11
globalThis.IS_REACT_ACT_ENVIRONMENT = true
2+
3+
// These are not 1:1 perfect polyfills, but they implement the parts we need for
4+
// testing. The implementation of the `getAnimations` uses the `setTimeout`
5+
// approach we used in the past.
6+
//
7+
// This is only necessary because JSDOM does not implement `getAnimations` or
8+
// `CSSTransition` yet. This is a temporary solution until JSDOM implements
9+
// these features. Or, until we use proper browser tests using Puppeteer or
10+
// Playwright.
11+
{
12+
if (typeof CSSTransition === 'undefined') {
13+
globalThis.CSSTransition = class CSSTransition {
14+
constructor(duration) {
15+
this.duration = duration
16+
}
17+
18+
finished = new Promise((resolve) => {
19+
setTimeout(resolve, this.duration)
20+
})
21+
}
22+
}
23+
24+
if (typeof Element.prototype.getAnimations !== 'function') {
25+
Element.prototype.getAnimations = function () {
26+
let { transitionDuration, transitionDelay } = getComputedStyle(this)
27+
28+
let [durationMs, delayMs] = [transitionDuration, transitionDelay].map((value) => {
29+
let [resolvedValue = 0] = value
30+
.split(',')
31+
// Remove falsy we can't work with
32+
.filter(Boolean)
33+
// Values are returned as `0.3s` or `75ms`
34+
.map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
35+
.sort((a, z) => z - a)
36+
37+
return resolvedValue
38+
})
39+
40+
let totalDuration = durationMs + delayMs
41+
if (totalDuration === 0) return []
42+
43+
return [new CSSTransition(totalDuration)]
44+
}
45+
}
46+
}

packages/@headlessui-react/src/components/transition/transition.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,8 +1050,8 @@ describe('Events', () => {
10501050
'child-2-2: beforeEnter',
10511051

10521052
'child-1: afterEnter',
1053-
'child-2-2: afterEnter',
10541053
'child-2-1: afterEnter',
1054+
'child-2-2: afterEnter',
10551055
'child-2: afterEnter',
10561056
'root: afterEnter',
10571057

@@ -1064,8 +1064,8 @@ describe('Events', () => {
10641064
'root: beforeLeave',
10651065

10661066
'child-1: afterLeave',
1067-
'child-2-2: afterLeave',
10681067
'child-2-1: afterLeave',
1068+
'child-2-2: afterLeave',
10691069
'child-2: afterLeave',
10701070
'root: afterLeave',
10711071

@@ -1078,8 +1078,8 @@ describe('Events', () => {
10781078
'child-2-2: beforeEnter',
10791079

10801080
'child-1: afterEnter',
1081-
'child-2-2: afterEnter',
10821081
'child-2-1: afterEnter',
1082+
'child-2-2: afterEnter',
10831083
'child-2: afterEnter',
10841084
'root: afterEnter',
10851085

@@ -1092,8 +1092,8 @@ describe('Events', () => {
10921092
'root: beforeLeave',
10931093

10941094
'child-1: afterLeave',
1095-
'child-2-2: afterLeave',
10961095
'child-2-1: afterLeave',
1096+
'child-2-2: afterLeave',
10971097
'child-2: afterLeave',
10981098
'root: afterLeave',
10991099
])

packages/@headlessui-react/src/hooks/use-transition.ts

Lines changed: 22 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useRef, useState, type MutableRefObject } from 'react'
22
import { disposables } from '../utils/disposables'
3-
import { once } from '../utils/once'
43
import { useDisposables } from './use-disposables'
54
import { useFlags } from './use-flags'
65
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
@@ -211,84 +210,43 @@ function transition(
211210
// This means that no transition happens at all. To fix this, we delay the
212211
// actual transition by one frame.
213212
d.nextFrame(() => {
214-
// Wait for the transition, once the transition is complete we can cleanup.
215-
// This is registered first to prevent race conditions, otherwise it could
216-
// happen that the transition is already done before we start waiting for
217-
// the actual event.
218-
d.add(waitForTransition(node, done))
219-
220213
// Initiate the transition by applying the new classes.
221214
run()
215+
216+
// Wait for the transition, once the transition is complete we can cleanup.
217+
// We wait for a frame such that the browser has time to flush the changes
218+
// to the DOM.
219+
d.requestAnimationFrame(() => {
220+
d.add(waitForTransition(node, done))
221+
})
222222
})
223223

224224
return d.dispose
225225
}
226226

227-
function waitForTransition(node: HTMLElement, _done: () => void) {
228-
let done = once(_done)
227+
function waitForTransition(node: HTMLElement | null, done: () => void) {
229228
let d = disposables()
230-
231229
if (!node) return d.dispose
232230

233-
// Safari returns a comma separated list of values, so let's sort them and take the highest value.
234-
let { transitionDuration, transitionDelay } = getComputedStyle(node)
235-
236-
let [durationMs, delayMs] = [transitionDuration, transitionDelay].map((value) => {
237-
let [resolvedValue = 0] = value
238-
.split(',')
239-
// Remove falsy we can't work with
240-
.filter(Boolean)
241-
// Values are returned as `0.3s` or `75ms`
242-
.map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
243-
.sort((a, z) => z - a)
244-
245-
return resolvedValue
231+
let cancelled = false
232+
d.add(() => {
233+
cancelled = true
246234
})
247235

248-
let totalDuration = durationMs + delayMs
249-
250-
if (totalDuration !== 0) {
251-
if (process.env.NODE_ENV === 'test') {
252-
let dispose = d.setTimeout(() => {
253-
done()
254-
dispose()
255-
}, totalDuration)
256-
} else {
257-
let disposeGroup = d.group((d) => {
258-
// Mark the transition as done when the timeout is reached. This is a fallback in case the
259-
// transitionrun event is not fired.
260-
let cancelTimeout = d.setTimeout(() => {
261-
done()
262-
d.dispose()
263-
}, totalDuration)
264-
265-
// The moment the transitionrun event fires, we should cleanup the timeout fallback, because
266-
// then we know that we can use the native transition events because something is
267-
// transitioning.
268-
d.addEventListener(node, 'transitionrun', (event) => {
269-
if (event.target !== event.currentTarget) return
270-
cancelTimeout()
271-
272-
d.addEventListener(node, 'transitioncancel', (event) => {
273-
if (event.target !== event.currentTarget) return
274-
done()
275-
disposeGroup()
276-
})
277-
})
278-
})
279-
280-
d.addEventListener(node, 'transitionend', (event) => {
281-
if (event.target !== event.currentTarget) return
282-
done()
283-
d.dispose()
284-
})
285-
}
286-
} else {
287-
// No transition is happening, so we should cleanup already. Otherwise we have to wait until we
288-
// get disposed.
236+
let transitions = node.getAnimations().filter((animation) => animation instanceof CSSTransition)
237+
// If there are no transitions, we can stop early.
238+
if (transitions.length === 0) {
289239
done()
240+
return d.dispose
290241
}
291242

243+
// Wait for all the transitions to complete.
244+
Promise.allSettled(transitions.map((transition) => transition.finished)).then(() => {
245+
if (!cancelled) {
246+
done()
247+
}
248+
})
249+
292250
return d.dispose
293251
}
294252

0 commit comments

Comments
 (0)