Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix transition bug on Firefox, triggered by clicking the PopoverButton in rapid succession #3452

Merged
merged 5 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Fixed

- Fix transition bug on Firefox, triggered by clicking the `PopoverButton` in rapid succession ([#3452](https://github.com/tailwindlabs/headlessui/pull/3452))

## [2.1.4] - 2024-09-03

Expand Down
45 changes: 45 additions & 0 deletions packages/@headlessui-react/jest.setup.js
Original file line number Diff line number Diff line change
@@ -1 +1,46 @@
globalThis.IS_REACT_ACT_ENVIRONMENT = true

// These are not 1:1 perfect polyfills, but they implement the parts we need for
// testing. The implementation of the `getAnimations` uses the `setTimeout`
// approach we used in the past.
//
// This is only necessary because JSDOM does not implement `getAnimations` or
// `CSSTransition` yet. This is a temporary solution until JSDOM implements
// these features. Or, until we use proper browser tests using Puppeteer or
// Playwright.
{
if (typeof CSSTransition === 'undefined') {
globalThis.CSSTransition = class CSSTransition {
constructor(duration) {
this.duration = duration
}

finished = new Promise((resolve) => {
setTimeout(resolve, this.duration)
})
}
}

if (typeof Element.prototype.getAnimations !== 'function') {
Element.prototype.getAnimations = function () {
let { transitionDuration, transitionDelay } = getComputedStyle(this)

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) return []

return [new CSSTransition(totalDuration)]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1050,8 +1050,8 @@ describe('Events', () => {
'child-2-2: beforeEnter',

'child-1: afterEnter',
'child-2-2: afterEnter',
'child-2-1: afterEnter',
'child-2-2: afterEnter',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know why the order of these changed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's based on the order that the setTimeout is being called. I can get the old behavior by applying this diff:

diff --git a/packages/@headlessui-react/jest.setup.js b/packages/@headlessui-react/jest.setup.js
index 771a30cb..9e656463 100644
--- a/packages/@headlessui-react/jest.setup.js
+++ b/packages/@headlessui-react/jest.setup.js
@@ -15,9 +15,11 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true
         this.duration = duration
       }
 
-      finished = new Promise((resolve) => {
-        setTimeout(resolve, this.duration)
-      })
+      get finished() {
+        return new Promise((resolve) => {
+          setTimeout(resolve, this.duration)
+        })
+      }
     }
   }

But the exact reason why I am still unsure about. The order is also only different on the same level (siblings) which is the less important part I think. The more important part is the relation between child and parent and that that order is correct.

That said, note that the order here (and in the old code) was determined by the setTimeout (not by the real events because JSDOM doesn't have proper support for them all).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could revert the change to the test, and apply the diff above. But it feels incorrect since that would rely on us accessing the finished property, instead of relying on the creation of the CSSTransition object.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that's fine — was just curious and hoping it wasn't a side effect of some "problem"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, if you look at the browser example of this:

The output is in the exact same order (notice the order of the siblings "overlay" and "panel")

Main version:

Events:
2024-09-04T14:04:49.428Z - [Root] Before enter
2024-09-04T14:04:49.430Z - [Overlay] Before enter
2024-09-04T14:04:49.430Z - [Panel] Before enter
2024-09-04T14:04:49.765Z - [Overlay] After enter
2024-09-04T14:04:49.794Z - [Panel] After enter
2024-09-04T14:04:49.794Z - [Root] After enter
2024-09-04T14:04:50.873Z - [Overlay] Before leave
2024-09-04T14:04:50.873Z - [Panel] Before leave
2024-09-04T14:04:50.873Z - [Root] Before leave
2024-09-04T14:04:51.116Z - [Overlay] After leave
2024-09-04T14:04:51.124Z - [Panel] After leave
2024-09-04T14:04:51.124Z - [Root] After leave

PR version:

Events:
2024-09-04T14:04:17.440Z - [Root] Before enter
2024-09-04T14:04:17.441Z - [Overlay] Before enter
2024-09-04T14:04:17.441Z - [Panel] Before enter
2024-09-04T14:04:17.781Z - [Overlay] After enter
2024-09-04T14:04:17.820Z - [Panel] After enter
2024-09-04T14:04:17.820Z - [Root] After enter
2024-09-04T14:04:19.504Z - [Overlay] Before leave
2024-09-04T14:04:19.504Z - [Panel] Before leave
2024-09-04T14:04:19.504Z - [Root] Before leave
2024-09-04T14:04:19.765Z - [Overlay] After leave
2024-09-04T14:04:19.767Z - [Panel] After leave
2024-09-04T14:04:19.767Z - [Root] After leave

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sweet 👍

'child-2: afterEnter',
'root: afterEnter',

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

'child-1: afterLeave',
'child-2-2: afterLeave',
'child-2-1: afterLeave',
'child-2-2: afterLeave',
'child-2: afterLeave',
'root: afterLeave',

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

'child-1: afterEnter',
'child-2-2: afterEnter',
'child-2-1: afterEnter',
'child-2-2: afterEnter',
'child-2: afterEnter',
'root: afterEnter',

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

'child-1: afterLeave',
'child-2-2: afterLeave',
'child-2-1: afterLeave',
'child-2-2: afterLeave',
'child-2: afterLeave',
'root: afterLeave',
])
Expand Down
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 cancelled = false
d.add(() => {
cancelled = 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 (!cancelled) {
done()
}
})

return d.dispose
}

Expand Down