From 4737c6df97993492dde4a2b6384f8222184e1211 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 11 Sep 2024 17:19:55 +0200 Subject: [PATCH] Prevent crash in environments where `Element.prototype.getAnimations` is not available (#3473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- jest/polyfills.ts | 7 ++- package-lock.json | 29 ++++++++++++ packages/@headlessui-react/CHANGELOG.md | 4 +- packages/@headlessui-react/jest.setup.js | 45 ------------------- packages/@headlessui-react/package.json | 1 + .../src/hooks/use-transition.ts | 31 ++++++++++++- 6 files changed, 66 insertions(+), 51 deletions(-) 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()