Skip to content

Commit 4737c6d

Browse files
authored
Prevent crash in environments where Element.prototype.getAnimations is not available (#3473)
Recently we made improvements to the `Transition` component and internal `useTransition` hook. We now use the `Element.prototype.getAnimations` API to know whether or not all transitions are done. This API has been available in browsers since 2020, however jsdom doesn't have support for this. This results in a lot of failing tests where users rely on jsdom (e.g. inside of Jest or Vitest). In a perfect world, jsdom is not used because it's not a real browser and there is a lot you need to workaround to even mimic a real browser. I understand that just switching to real browser tests (using Playwright for example) is not an easy task that can be done easily. Even our tests still rely on jsdom… So to make the development experience better, we polyfill the `Element.prototype.getAnimations` API only in tests (`process.env.NODE_ENV === 'test'`) and show a warning in the console on how to proceed. The polyfill we ship simply returns an empty array for `node.getAnimations()`. This means that it will be _enough_ for most tests to pass. The exception is if you are actually relying on `transition-duration` and `transition-delay` CSS properties. The warning you will get looks like this: `````` Headless UI has polyfilled `Element.prototype.getAnimations` for your tests. Please install a proper polyfill e.g. `jsdom-testing-mocks`, to silence these warnings. Example usage: ```js import { mockAnimationsApi } from 'jsdom-testing-mocks' mockAnimationsApi() ``` `````` Fixes: #3470 Fixes: #3469 Fixes: #3468
1 parent 5b365f5 commit 4737c6d

File tree

6 files changed

+66
-51
lines changed

6 files changed

+66
-51
lines changed

jest/polyfills.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import ResizeObserverPolyfill from 'resize-observer-polyfill'
1+
import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks'
22

3-
if (typeof ResizeObserver === 'undefined') {
4-
global.ResizeObserver = ResizeObserverPolyfill
5-
}
3+
mockAnimationsApi() // `Element.prototype.getAnimations` and `CSSTransition` polyfill
4+
mockResizeObserver() // `ResizeObserver` polyfill
65

76
// JSDOM Doesn't implement innerText yet: https://github.com/jsdom/jsdom/issues/1245
87
// So this is a hacky way of implementing it using `textContent`.

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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+
- Prevent crash in environments where `Element.prototype.getAnimations` is not available ([#3473](https://github.com/tailwindlabs/headlessui/pull/3473))
1113

1214
## [2.1.6] - 2024-09-09
1315

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1 @@
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/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@testing-library/react": "^15.0.7",
5050
"@types/react": "^18.3.3",
5151
"@types/react-dom": "^18.3.0",
52+
"jsdom-testing-mocks": "^1.13.1",
5253
"react": "^18.3.1",
5354
"react-dom": "^18.3.1",
5455
"snapshot-diff": "^0.10.0"

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,34 @@ import { useDisposables } from './use-disposables'
44
import { useFlags } from './use-flags'
55
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
66

7+
if (
8+
typeof process !== 'undefined' &&
9+
typeof globalThis !== 'undefined' &&
10+
// Strange string concatenation is on purpose to prevent `esbuild` from
11+
// replacing `process.env.NODE_ENV` with `production` in the build output,
12+
// eliminating this whole branch.
13+
process?.env?.['NODE' + '_' + 'ENV'] === 'test'
14+
) {
15+
if (typeof Element.prototype.getAnimations === 'undefined') {
16+
Element.prototype.getAnimations = function getAnimationsPolyfill() {
17+
console.warn(
18+
[
19+
'Headless UI has polyfilled `Element.prototype.getAnimations` for your tests.',
20+
'Please install a proper polyfill e.g. `jsdom-testing-mocks`, to silence these warnings.',
21+
'',
22+
'Example usage:',
23+
'```js',
24+
"import { mockAnimationsApi } from 'jsdom-testing-mocks'",
25+
'mockAnimationsApi()',
26+
'```',
27+
].join('\n')
28+
)
29+
30+
return []
31+
}
32+
}
33+
}
34+
735
/**
836
* ```
937
* ┌──────┐ │ ┌──────────────┐
@@ -233,7 +261,8 @@ function waitForTransition(node: HTMLElement | null, done: () => void) {
233261
cancelled = true
234262
})
235263

236-
let transitions = node.getAnimations().filter((animation) => animation instanceof CSSTransition)
264+
let transitions =
265+
node.getAnimations?.().filter((animation) => animation instanceof CSSTransition) ?? []
237266
// If there are no transitions, we can stop early.
238267
if (transitions.length === 0) {
239268
done()

0 commit comments

Comments
 (0)