Skip to content

Commit

Permalink
Prevent crash in environments where Element.prototype.getAnimations
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
RobinMalfait committed Sep 11, 2024
1 parent 5b365f5 commit 4737c6d
Show file tree
Hide file tree
Showing 6 changed files with 66 additions and 51 deletions.
7 changes: 3 additions & 4 deletions jest/polyfills.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import ResizeObserverPolyfill from 'resize-observer-polyfill'
import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks'

if (typeof ResizeObserver === 'undefined') {
global.ResizeObserver = ResizeObserverPolyfill
}
mockAnimationsApi() // `Element.prototype.getAnimations` and `CSSTransition` polyfill
mockResizeObserver() // `ResizeObserver` polyfill

// JSDOM Doesn't implement innerText yet: https://github.com/jsdom/jsdom/issues/1245
// So this is a hacky way of implementing it using `textContent`.
Expand Down
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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

- Prevent crash in environments where `Element.prototype.getAnimations` is not available ([#3473](https://github.com/tailwindlabs/headlessui/pull/3473))

## [2.1.6] - 2024-09-09

Expand Down
45 changes: 0 additions & 45 deletions packages/@headlessui-react/jest.setup.js
Original file line number Diff line number Diff line change
@@ -1,46 +1 @@
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)]
}
}
}
1 change: 1 addition & 0 deletions packages/@headlessui-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@testing-library/react": "^15.0.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"jsdom-testing-mocks": "^1.13.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"snapshot-diff": "^0.10.0"
Expand Down
31 changes: 30 additions & 1 deletion packages/@headlessui-react/src/hooks/use-transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@ import { useDisposables } from './use-disposables'
import { useFlags } from './use-flags'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'

if (
typeof process !== 'undefined' &&
typeof globalThis !== 'undefined' &&
// Strange string concatenation is on purpose to prevent `esbuild` from
// replacing `process.env.NODE_ENV` with `production` in the build output,
// eliminating this whole branch.
process?.env?.['NODE' + '_' + 'ENV'] === 'test'
) {
if (typeof Element.prototype.getAnimations === 'undefined') {
Element.prototype.getAnimations = function getAnimationsPolyfill() {
console.warn(
[
'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()',
'```',
].join('\n')
)

return []
}
}
}

/**
* ```
* ┌──────┐ │ ┌──────────────┐
Expand Down Expand Up @@ -233,7 +261,8 @@ function waitForTransition(node: HTMLElement | null, done: () => void) {
cancelled = true
})

let transitions = node.getAnimations().filter((animation) => animation instanceof CSSTransition)
let transitions =
node.getAnimations?.().filter((animation) => animation instanceof CSSTransition) ?? []
// If there are no transitions, we can stop early.
if (transitions.length === 0) {
done()
Expand Down

0 comments on commit 4737c6d

Please sign in to comment.