From ce999b65297271b4ed81d4376328bf441341ecd2 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 17 Jul 2024 11:40:32 +0200 Subject: [PATCH] fix(motion): improve Web Animations API detection in tests --- ...-6948133f-0a73-49c2-9916-32dd176969ae.json | 7 + .../factories/createMotionComponent.test.tsx | 11 -- .../createPresenceComponent-jest.test.tsx | 8 +- .../createPresenceComponent-node.test.tsx | 47 ++++++ .../createPresenceComponent.test.tsx | 133 ++++++++--------- .../src/hooks/useAnimateAtoms-node.test.tsx | 9 +- .../library/src/hooks/useAnimateAtoms.ts | 137 ++++++++++-------- 7 files changed, 201 insertions(+), 151 deletions(-) create mode 100644 change/@fluentui-react-motion-6948133f-0a73-49c2-9916-32dd176969ae.json create mode 100644 packages/react-components/react-motion/library/src/factories/createPresenceComponent-node.test.tsx diff --git a/change/@fluentui-react-motion-6948133f-0a73-49c2-9916-32dd176969ae.json b/change/@fluentui-react-motion-6948133f-0a73-49c2-9916-32dd176969ae.json new file mode 100644 index 00000000000000..350e9c192c3305 --- /dev/null +++ b/change/@fluentui-react-motion-6948133f-0a73-49c2-9916-32dd176969ae.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: improve Web Animations API detection in tests", + "packageName": "@fluentui/react-motion", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-motion/library/src/factories/createMotionComponent.test.tsx b/packages/react-components/react-motion/library/src/factories/createMotionComponent.test.tsx index 18377a9e42d268..ac25b66e29e898 100644 --- a/packages/react-components/react-motion/library/src/factories/createMotionComponent.test.tsx +++ b/packages/react-components/react-motion/library/src/factories/createMotionComponent.test.tsx @@ -4,13 +4,6 @@ import * as React from 'react'; import type { AtomMotion } from '../types'; import { createMotionComponent } from './createMotionComponent'; -jest.mock('./createMotionComponent', () => { - // Add a mock for the `animate` method on the HTMLElement prototype as jsdom does not support it - Element.prototype.animate = jest.fn(); - - return jest.requireActual('./createMotionComponent'); -}); - const motion: AtomMotion = { keyframes: [{ opacity: 0 }, { opacity: 1 }], duration: 500, @@ -42,10 +35,6 @@ function createElementMock() { } describe('createMotionComponent', () => { - it('has mock for .animate()', () => { - expect(Element.prototype.animate).toBeDefined(); - }); - it('creates a motion and plays it', () => { const TestAtom = createMotionComponent(motion); const { animateMock, ElementMock } = createElementMock(); diff --git a/packages/react-components/react-motion/library/src/factories/createPresenceComponent-jest.test.tsx b/packages/react-components/react-motion/library/src/factories/createPresenceComponent-jest.test.tsx index f92256bfa0eb55..74781b38a77573 100644 --- a/packages/react-components/react-motion/library/src/factories/createPresenceComponent-jest.test.tsx +++ b/packages/react-components/react-motion/library/src/factories/createPresenceComponent-jest.test.tsx @@ -8,7 +8,7 @@ import { createPresenceComponent } from './createPresenceComponent'; const keyframes = [{ opacity: 0 }, { opacity: 1 }]; const options = { duration: 500, fill: 'forwards' as const }; -const TestAtom = createPresenceComponent({ +const TestPresence = createPresenceComponent({ enter: { keyframes, ...options }, exit: { keyframes: keyframes.slice().reverse(), ...options }, }); @@ -23,9 +23,9 @@ const TestComponent: React.FC<{ appear?: boolean; finish?: () => void }> = props return ( <> - +
Hello
-
+ ); }; @@ -37,7 +37,7 @@ const TestComponent: React.FC<{ appear?: boolean; finish?: () => void }> = props // // This test suite ensures that the component works correctly in tests using that environment. -describe('createPresenceComponent', () => { +describe('createPresenceComponent (jest)', () => { it('does not support .animate()', () => { expect(Element.prototype.animate).toBeUndefined(); }); diff --git a/packages/react-components/react-motion/library/src/factories/createPresenceComponent-node.test.tsx b/packages/react-components/react-motion/library/src/factories/createPresenceComponent-node.test.tsx new file mode 100644 index 00000000000000..abd389fbda0f7e --- /dev/null +++ b/packages/react-components/react-motion/library/src/factories/createPresenceComponent-node.test.tsx @@ -0,0 +1,47 @@ +/* + * @jest-environment node + */ + +// 👆 this is intentionally to test in SSR like environment + +import * as React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { createPresenceComponent } from './createPresenceComponent'; + +const keyframes = [{ opacity: 0 }, { opacity: 1 }]; +const options = { duration: 500, fill: 'forwards' as const }; + +const TestPresence = createPresenceComponent({ + enter: { keyframes, ...options }, + exit: { keyframes: keyframes.slice().reverse(), ...options }, +}); + +// Heads up! +// +// Unfortunately, jsdom (the optional environment for Jest) does not support the Web Animations API, which is used by +// createPresenceComponent() to animate elements. +// +// This test suite ensures that the component works correctly in tests using that environment. + +describe('createPresenceComponent (node)', () => { + it('renders a child component when "visible" is "true"', () => { + const html = renderToStaticMarkup( + +
+ , + ); + + expect(html).toMatchInlineSnapshot(`"
"`); + }); + + it('does not render a child component when "visible" is "false" & "unmountOnExit"', () => { + const html = renderToStaticMarkup( + +
+ , + ); + + expect(html).toMatchInlineSnapshot(`""`); + }); +}); diff --git a/packages/react-components/react-motion/library/src/factories/createPresenceComponent.test.tsx b/packages/react-components/react-motion/library/src/factories/createPresenceComponent.test.tsx index 9848ba68b571ba..0959f1ee8b3dd3 100644 --- a/packages/react-components/react-motion/library/src/factories/createPresenceComponent.test.tsx +++ b/packages/react-components/react-motion/library/src/factories/createPresenceComponent.test.tsx @@ -5,13 +5,6 @@ import type { PresenceMotion } from '../types'; import { createPresenceComponent } from './createPresenceComponent'; import { PresenceGroupChildContext } from '../contexts/PresenceGroupChildContext'; -jest.mock('./createPresenceComponent', () => { - // Add a mock for the `animate` method on the HTMLElement prototype as jsdom does not support it - Element.prototype.animate = jest.fn(); - - return jest.requireActual('./createPresenceComponent'); -}); - const enterKeyframes = [{ opacity: 0 }, { opacity: 1 }]; const exitKeyframes = [{ opacity: 1 }, { opacity: 0 }]; const options = { duration: 500 as const, fill: 'forwards' as const }; @@ -47,32 +40,28 @@ function createElementMock() { } describe('createPresenceComponent', () => { - it('has mock for .animate()', () => { - expect(Element.prototype.animate).toBeDefined(); - }); - describe('appear', () => { it('does not animate by default', () => { - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { animateMock, ElementMock } = createElementMock(); render( - + - , + , ); expect(animateMock).not.toHaveBeenCalled(); }); it('animates when is "true"', () => { - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { animateMock, ElementMock } = createElementMock(); render( - + - , + , ); expect(animateMock).toHaveBeenCalledWith(enterKeyframes, options); @@ -83,13 +72,13 @@ describe('createPresenceComponent', () => { describe('exit', () => { it('is not called on first render', () => { const onMotionStart = jest.fn(); - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { ElementMock } = createElementMock(); render( - + - , + , ); expect(onMotionStart).toHaveBeenCalledTimes(0); @@ -97,13 +86,13 @@ describe('createPresenceComponent', () => { it('is called when visible becomes false', () => { const onMotionStart = jest.fn(); - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { ElementMock } = createElementMock(); const { rerender } = render( - + - , + , ); expect(onMotionStart).toHaveBeenCalledTimes(1); @@ -112,9 +101,9 @@ describe('createPresenceComponent', () => { // --- rerender( - + - , + , ); expect(onMotionStart).toHaveBeenCalledTimes(2); @@ -125,13 +114,13 @@ describe('createPresenceComponent', () => { describe('enter', () => { it('is not called on first render without appear', () => { const onMotionStart = jest.fn(); - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { ElementMock } = createElementMock(); render( - + - , + , ); expect(onMotionStart).toHaveBeenCalledTimes(0); @@ -139,13 +128,13 @@ describe('createPresenceComponent', () => { it('is called on first render with appear', () => { const onMotionStart = jest.fn(); - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { ElementMock } = createElementMock(); render( - + - , + , ); expect(onMotionStart).toHaveBeenCalledTimes(1); @@ -154,22 +143,22 @@ describe('createPresenceComponent', () => { it('is called when visible becomes true', () => { const onMotionStart = jest.fn(); - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { ElementMock } = createElementMock(); const { rerender } = render( - + - , + , ); expect(onMotionStart).toHaveBeenCalledTimes(0); // --- rerender( - + - , + , ); expect(onMotionStart).toHaveBeenCalledTimes(1); @@ -181,13 +170,13 @@ describe('createPresenceComponent', () => { describe('onMotionFinish', () => { it('is not called on first render', () => { const onMotionFinish = jest.fn(); - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { ElementMock } = createElementMock(); render( - + - , + , ); expect(onMotionFinish).toHaveBeenCalledTimes(0); @@ -195,20 +184,20 @@ describe('createPresenceComponent', () => { it('calls "onMotionFinish" when animation finishes', async () => { const onMotionFinish = jest.fn(); - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { ElementMock } = createElementMock(); const { rerender } = render( - + - , + , ); await act(async () => { rerender( - + - , + , ); }); @@ -219,14 +208,14 @@ describe('createPresenceComponent', () => { describe('visible', () => { it('animates when state changes', () => { - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const onRender = jest.fn(); const { animateMock, ElementMock, finishMock } = createElementMock(); const { rerender } = render( - + - , + , ); expect(animateMock).not.toHaveBeenCalled(); @@ -237,9 +226,9 @@ describe('createPresenceComponent', () => { jest.clearAllMocks(); rerender( - + - , + , ); expect(animateMock).toHaveBeenCalledWith(exitKeyframes, options); @@ -248,13 +237,13 @@ describe('createPresenceComponent', () => { }); it('calls ".finish()" on first mount when "visible" is "false"', () => { - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { animateMock, ElementMock, finishMock } = createElementMock(); render( - + - , + , ); expect(animateMock).toHaveBeenCalledWith(exitKeyframes, options); @@ -264,14 +253,14 @@ describe('createPresenceComponent', () => { describe('unmountOnExit', () => { it('unmounts when state changes', async () => { - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const onRender = jest.fn(); const { animateMock, ElementMock } = createElementMock(); const { rerender, queryByText } = render( - + - , + , ); expect(queryByText('ElementMock')).toBeTruthy(); @@ -284,9 +273,9 @@ describe('createPresenceComponent', () => { await act(async () => { rerender( - + - , + , ); }); @@ -296,14 +285,14 @@ describe('createPresenceComponent', () => { }); it('mounts when state changes', () => { - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const onRender = jest.fn(); const { animateMock, ElementMock } = createElementMock(); const { rerender, queryByText } = render( - + - , + , ); expect(queryByText('ElementMock')).toBe(null); @@ -314,9 +303,9 @@ describe('createPresenceComponent', () => { jest.clearAllMocks(); rerender( - + - , + , ); expect(queryByText('ElementMock')).toBeTruthy(); @@ -328,13 +317,13 @@ describe('createPresenceComponent', () => { describe('definitions', () => { it('supports functions as motion definitions', () => { const fnMotion = jest.fn().mockImplementation(() => motion); - const TestAtom = createPresenceComponent(fnMotion); + const TestPresence = createPresenceComponent(fnMotion); const { animateMock, ElementMock } = createElementMock(); const { rerender } = render( - + - , + , ); expect(animateMock).not.toHaveBeenCalled(); @@ -344,9 +333,9 @@ describe('createPresenceComponent', () => { jest.clearAllMocks(); rerender( - + - , + , ); expect(fnMotion).toHaveBeenCalledTimes(1); @@ -360,7 +349,7 @@ describe('createPresenceComponent', () => { describe('PresenceGroupChildContext', () => { it('calls "onExit" when "visible" changes to "false"', async () => { const onExit = jest.fn(); - const TestAtom = createPresenceComponent(motion); + const TestPresence = createPresenceComponent(motion); const { ElementMock } = createElementMock(); const Wrapper: React.FC<{ visible: boolean }> = ({ children, visible }) => ( @@ -371,9 +360,9 @@ describe('PresenceGroupChildContext', () => { const { queryByText, rerender } = render( - + - + , ); @@ -385,9 +374,9 @@ describe('PresenceGroupChildContext', () => { await act(async () => { rerender( - + - + , ); }); diff --git a/packages/react-components/react-motion/library/src/hooks/useAnimateAtoms-node.test.tsx b/packages/react-components/react-motion/library/src/hooks/useAnimateAtoms-node.test.tsx index 772667b84a7385..2a94b592f51747 100644 --- a/packages/react-components/react-motion/library/src/hooks/useAnimateAtoms-node.test.tsx +++ b/packages/react-components/react-motion/library/src/hooks/useAnimateAtoms-node.test.tsx @@ -4,13 +4,14 @@ // 👆 this is intentionally to test in SSR like environment +import { renderHook } from '@testing-library/react-hooks'; +import { useAnimateAtoms } from './useAnimateAtoms'; + describe('useAnimateAtoms (node)', () => { it('handles node/server environments', () => { - const win = typeof document === 'object' ? document.defaultView?.window : undefined; - const SUPPORTS_WEB_ANIMATIONS = win && typeof win.Element.prototype.animate === 'function'; + const { result } = renderHook(() => useAnimateAtoms()); - expect(win).toBe(undefined); - expect(SUPPORTS_WEB_ANIMATIONS).toBeFalsy(); + expect(result.current).toBeInstanceOf(Function); }); }); diff --git a/packages/react-components/react-motion/library/src/hooks/useAnimateAtoms.ts b/packages/react-components/react-motion/library/src/hooks/useAnimateAtoms.ts index 23213a174bd6eb..66ea0c35e1b6be 100644 --- a/packages/react-components/react-motion/library/src/hooks/useAnimateAtoms.ts +++ b/packages/react-components/react-motion/library/src/hooks/useAnimateAtoms.ts @@ -1,66 +1,7 @@ import * as React from 'react'; import type { AnimationHandle, AtomMotion } from '../types'; -// eslint-disable-next-line no-restricted-globals -const win = typeof document === 'object' ? document.defaultView?.window : undefined; - -// Heads up! "Element." is a side-effect for minifiers, should be kept as IIFE to avoid leaking after minification. -const SUPPORTS_WEB_ANIMATIONS = /*@__PURE__*/ (() => win && typeof win.Element.prototype.animate === 'function')(); - -/** - * In test environments, this hook is used to delay the execution of a callback until the next render. This is necessary - * to ensure that the callback is not executed synchronously, which would cause the test to fail. - * - * @see https://github.com/microsoft/fluentui/issues/31701 - */ -function useAnimateAtomsInTestEnvironment() { - const [count, setCount] = React.useState(0); - const callbackRef = React.useRef<() => void>(); - - React.useEffect(() => { - if (count > 0) { - callbackRef.current?.(); - } - }, [count]); - - return React.useCallback((): AnimationHandle => { - return { - setMotionEndCallbacks(onfinish: () => void) { - callbackRef.current = onfinish; - setCount(v => v + 1); - }, - - set playbackRate(rate: number) { - /* no-op */ - }, - cancel() { - /* no-op */ - }, - pause() { - /* no-op */ - }, - play() { - /* no-op */ - }, - finish() { - /* no-op */ - }, - }; - }, []); -} - -/** - * @internal - */ -export function useAnimateAtoms() { - 'use no memo'; - - if (process.env.NODE_ENV === 'test' && !SUPPORTS_WEB_ANIMATIONS) { - // eslint-disable-next-line react-hooks/rules-of-hooks - return useAnimateAtomsInTestEnvironment(); - } - - // eslint-disable-next-line react-hooks/rules-of-hooks +function useAnimateAtomsInSupportedEnvironment() { return React.useCallback( ( element: HTMLElement, @@ -135,3 +76,79 @@ export function useAnimateAtoms() { [], ); } + +/** + * In test environments, this hook is used to delay the execution of a callback until the next render. This is necessary + * to ensure that the callback is not executed synchronously, which would cause the test to fail. + * + * @see https://github.com/microsoft/fluentui/issues/31701 + */ +function useAnimateAtomsInTestEnvironment() { + const [count, setCount] = React.useState(0); + const callbackRef = React.useRef<() => void>(); + + const realAnimateAtoms = useAnimateAtomsInSupportedEnvironment(); + + React.useEffect(() => { + if (count > 0) { + callbackRef.current?.(); + } + }, [count]); + + return React.useCallback( + ( + element: HTMLElement, + value: AtomMotion | AtomMotion[], + options: { + isReducedMotion: boolean; + }, + ): AnimationHandle => { + const ELEMENT_SUPPORTS_WEB_ANIMATIONS = typeof element.animate === 'function'; + + // Heads up! + // If the environment supports Web Animations API, we can use the native implementation. + if (ELEMENT_SUPPORTS_WEB_ANIMATIONS) { + return realAnimateAtoms(element, value, options); + } + + return { + setMotionEndCallbacks(onfinish: () => void) { + callbackRef.current = onfinish; + setCount(v => v + 1); + }, + + set playbackRate(rate: number) { + /* no-op */ + }, + cancel() { + /* no-op */ + }, + pause() { + /* no-op */ + }, + play() { + /* no-op */ + }, + finish() { + /* no-op */ + }, + }; + }, + [realAnimateAtoms], + ); +} + +/** + * @internal + */ +export function useAnimateAtoms() { + 'use no memo'; + + if (process.env.NODE_ENV === 'test') { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useAnimateAtomsInTestEnvironment(); + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + return useAnimateAtomsInSupportedEnvironment(); +}