diff --git a/jest/polyfills.ts b/jest/polyfills.ts index ace5ad661a..6fd55ebbc8 100644 --- a/jest/polyfills.ts +++ b/jest/polyfills.ts @@ -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`. diff --git a/package-lock.json b/package-lock.json index c7d413721d..0daa70140d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3522,6 +3522,13 @@ "node": ">=0.10.0" } }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "dev": true, + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.2.0", "license": "MIT", @@ -4041,6 +4048,13 @@ "node": ">=8" } }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", + "dev": true, + "license": "BSD" + }, "node_modules/css.escape": { "version": "1.5.1", "dev": true, @@ -7007,6 +7021,20 @@ } } }, + "node_modules/jsdom-testing-mocks": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/jsdom-testing-mocks/-/jsdom-testing-mocks-1.13.1.tgz", + "integrity": "sha512-8BAsnuoO4DLGTf7LDbSm8fcx5CUHSv4h+bdUbwyt6rMYAXWjeHLRx9f8sYiSxoOTXy3S1e06pe87KER39o1ckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bezier-easing": "^2.1.0", + "css-mediaquery": "^0.1.2" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/jsesc": { "version": "2.5.2", "dev": true, @@ -11484,6 +11512,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" diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 42ac6e79fc..39a1d9ebaf 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-react/jest.setup.js b/packages/@headlessui-react/jest.setup.js index 771a30cb96..ef4af5454b 100644 --- a/packages/@headlessui-react/jest.setup.js +++ b/packages/@headlessui-react/jest.setup.js @@ -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)] - } - } -} diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index ac01c1d444..22d84bfe79 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -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" diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts index 6c83f466c4..bb7bec4c7b 100644 --- a/packages/@headlessui-react/src/hooks/use-transition.ts +++ b/packages/@headlessui-react/src/hooks/use-transition.ts @@ -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 [] + } + } +} + /** * ``` * ┌──────┐ │ ┌──────────────┐ @@ -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()