From 905bbbe77549a7454f7eb611cfb2adf8e91fdc72 Mon Sep 17 00:00:00 2001 From: Inomdzhon Mirdzhamolov Date: Tue, 30 Jul 2024 12:05:33 +0300 Subject: [PATCH] feat(useCSSKeyframesAnimationController): exclude first rerender (#7266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Состояние `entered` выставлялось с ре-рендером при инициализации. --- ...useCSSKeyframesAnimationController.test.ts | 147 +++++++++++------- .../useCSSKeyframesAnimationController.ts | 108 ++++++------- 2 files changed, 133 insertions(+), 122 deletions(-) diff --git a/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.test.ts b/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.test.ts index 74480c629a..4dbc28daf1 100644 --- a/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.test.ts +++ b/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.test.ts @@ -3,96 +3,123 @@ import { renderHook } from '@testing-library/react'; import { useCSSKeyframesAnimationController } from './useCSSKeyframesAnimationController'; describe(useCSSKeyframesAnimationController, () => { - describe.each([false, true])('`disableInitAnimation` prop is `%s`', (disableInitAnimation) => { - const callbacks = { - onEnter: jest.fn(), - onEntering: jest.fn(), - onEntered: jest.fn(), - onExit: jest.fn(), - onExiting: jest.fn(), - onExited: jest.fn(), - }; - - beforeEach(() => { - for (const key in callbacks) { - if (callbacks.hasOwnProperty(key)) { - callbacks[key].mockClear(); - } + const callbacks = { + onEnter: jest.fn(), + onEntering: jest.fn(), + onEntered: jest.fn(), + onExit: jest.fn(), + onExiting: jest.fn(), + onExited: jest.fn(), + }; + + beforeEach(() => { + for (const key in callbacks) { + if (callbacks.hasOwnProperty(key)) { + callbacks[key].mockClear(); } - }); + } + }); - it('should enter', () => { - const { result } = renderHook(() => - useCSSKeyframesAnimationController('enter', callbacks, disableInitAnimation), + describe.each([ + { callbacks, disableInitAnimation: false }, + { callbacks, disableInitAnimation: true }, + { callbacks: undefined, disableInitAnimation: false }, + { callbacks: undefined, disableInitAnimation: true }, + ])('`disableInitAnimation` prop is `%s`', ({ callbacks, disableInitAnimation }) => { + it('should enter and exit', () => { + const { result, rerender } = renderHook((state: 'enter' | 'exit' = 'enter') => + useCSSKeyframesAnimationController(state, callbacks, disableInitAnimation), ); - !disableInitAnimation && expect(result.current[0]).toBe('enter'); + if (disableInitAnimation) { + expect(result.current[0]).not.toBe('enter'); + callbacks && expect(callbacks.onEnter).toHaveBeenCalledTimes(0); + } else { + expect(result.current[0]).toBe('enter'); + callbacks && expect(callbacks.onEnter).toHaveBeenCalledTimes(1); + } + + act(() => result.current[1].onAnimationStart()); - act(result.current[1].onAnimationStart); - if (!disableInitAnimation) { + if (disableInitAnimation) { + expect(result.current[0]).not.toBe('entering'); + callbacks && expect(callbacks.onEntering).toHaveBeenCalledTimes(0); + } else { expect(result.current[0]).toBe('entering'); - expect(callbacks.onEntering).toHaveBeenCalledTimes(1); + callbacks && expect(callbacks.onEntering).toHaveBeenCalledTimes(1); } - act(result.current[1].onAnimationEnd); + act(() => result.current[1].onAnimationEnd()); + expect(result.current[0]).toBe('entered'); - expect(callbacks.onEntered).toHaveBeenCalledTimes(1); - }); + if (disableInitAnimation) { + callbacks && expect(callbacks.onEntered).toHaveBeenCalledTimes(0); + } else { + callbacks && expect(callbacks.onEntered).toHaveBeenCalledTimes(1); + } - it('should exit', () => { - const { result } = renderHook(() => - useCSSKeyframesAnimationController('exit', callbacks, disableInitAnimation), - ); + rerender('exit'); - !disableInitAnimation && expect(result.current[0]).toBe('exit'); + expect(result.current[0]).toBe('exit'); + callbacks && expect(callbacks.onExit).toHaveBeenCalledTimes(1); - act(result.current[1].onAnimationStart); - if (!disableInitAnimation) { - expect(result.current[0]).toBe('exiting'); - expect(callbacks.onExiting).toHaveBeenCalledTimes(1); - } + act(() => result.current[1].onAnimationStart()); + + expect(result.current[0]).toBe('exiting'); + callbacks && expect(callbacks.onExiting).toHaveBeenCalledTimes(1); + + act(() => result.current[1].onAnimationEnd()); - act(result.current[1].onAnimationEnd); expect(result.current[0]).toBe('exited'); - expect(callbacks.onExited).toHaveBeenCalledTimes(1); + callbacks && expect(callbacks.onExited).toHaveBeenCalledTimes(1); }); - it.each([true, false])('should exit after enter (withCallbacks: %s)', (withCallbacks) => { - const { rerender, result } = renderHook((state: 'enter' | 'exit' = 'enter') => - useCSSKeyframesAnimationController(state, withCallbacks ? callbacks : undefined), + it('should exit and enter', () => { + const { result, rerender } = renderHook((state: 'enter' | 'exit' = 'exit') => + useCSSKeyframesAnimationController(state, callbacks, disableInitAnimation), ); - act(result.current[1].onAnimationStart); - act(result.current[1].onAnimationEnd); + if (disableInitAnimation) { + expect(result.current[0]).not.toBe('exit'); + callbacks && expect(callbacks.onExit).toHaveBeenCalledTimes(0); + } else { + expect(result.current[0]).toBe('exit'); + callbacks && expect(callbacks.onExit).toHaveBeenCalledTimes(1); + } - expect(result.current[0]).toBe('entered'); + act(() => result.current[1].onAnimationStart()); - rerender('exit'); - expect(callbacks.onExit).toHaveBeenCalledTimes(withCallbacks ? 1 : 0); + if (disableInitAnimation) { + expect(result.current[0]).not.toBe('exiting'); + callbacks && expect(callbacks.onExiting).toHaveBeenCalledTimes(0); + } else { + expect(result.current[0]).toBe('exiting'); + callbacks && expect(callbacks.onExiting).toHaveBeenCalledTimes(1); + } - act(result.current[1].onAnimationStart); - act(result.current[1].onAnimationEnd); + act(() => result.current[1].onAnimationEnd()); expect(result.current[0]).toBe('exited'); - }); + if (disableInitAnimation) { + callbacks && expect(callbacks.onExited).toHaveBeenCalledTimes(0); + } else { + callbacks && expect(callbacks.onExited).toHaveBeenCalledTimes(1); + } - it.each([true, false])('should enter after exit (withCallbacks: %s)', (withCallbacks) => { - const { rerender, result } = renderHook((state: 'enter' | 'exit' = 'exit') => - useCSSKeyframesAnimationController(state, withCallbacks ? callbacks : undefined), - ); + rerender('enter'); - act(result.current[1].onAnimationStart); - act(result.current[1].onAnimationEnd); + expect(result.current[0]).toBe('enter'); + callbacks && expect(callbacks.onEnter).toHaveBeenCalledTimes(1); - expect(result.current[0]).toBe('exited'); + act(() => result.current[1].onAnimationStart()); - rerender('enter'); - expect(callbacks.onEnter).toHaveBeenCalledTimes(withCallbacks ? 1 : 0); + expect(result.current[0]).toBe('entering'); + callbacks && expect(callbacks.onEntering).toHaveBeenCalledTimes(1); - act(result.current[1].onAnimationStart); - act(result.current[1].onAnimationEnd); + act(() => result.current[1].onAnimationEnd()); expect(result.current[0]).toBe('entered'); + callbacks && expect(callbacks.onEntered).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.ts b/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.ts index b78578b7bd..589dd22353 100644 --- a/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.ts +++ b/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.ts @@ -1,111 +1,95 @@ -import * as React from 'react'; +import { useState } from 'react'; import { noop } from '@vkontakte/vkjs'; +import { usePrevious } from '../../hooks/usePrevious'; import { useStableCallback } from '../../hooks/useStableCallback'; import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'; export type UseCSSAnimationControllerCallback = { - onEnter?: () => void; - onEntering?: () => void; - onEntered?: () => void; - onExit?: () => void; - onExiting?: () => void; - onExited?: () => void; + onEnter?: VoidFunction; + onEntering?: VoidFunction; + onEntered?: VoidFunction; + onExit?: VoidFunction; + onExiting?: VoidFunction; + onExited?: VoidFunction; }; export type AnimationState = 'enter' | 'entering' | 'entered' | 'exit' | 'exiting' | 'exited'; -export type AnimationHandlers = { onAnimationStart: () => void; onAnimationEnd: () => void }; +export type AnimationHandlers = { onAnimationStart: VoidFunction; onAnimationEnd: VoidFunction }; export const useCSSKeyframesAnimationController = ( stateProp: 'enter' | 'exit', { - onEnter: onEnterProp = noop, - onEntering: onEnteringProp = noop, - onEntered: onEnteredProp = noop, - onExit: onExitProp = noop, - onExiting: onExitingProp = noop, - onExited: onExitedProp = noop, + onEnter: onEnterProp, + onEntering, + onEntered, + onExit: onExitProp, + onExiting, + onExited, }: UseCSSAnimationControllerCallback = {}, disableInitAnimation = false, ): [AnimationState, AnimationHandlers] => { - const isFirstInitRef = React.useRef(disableInitAnimation); - const [state, setState] = React.useState(stateProp); - const [willBeEnter, setWillBeEnter] = React.useState(stateProp === 'enter'); - const [willBeExit, setWillBeExit] = React.useState(stateProp === 'exit'); - - const onEnter = useStableCallback(onEnterProp); - const onEntering = useStableCallback(onEnteringProp); - const onEntered = useStableCallback(onEnteredProp); - const onExit = useStableCallback(onExitProp); - const onExiting = useStableCallback(onExitingProp); - const onExited = useStableCallback(onExitedProp); - - const entered = React.useCallback(() => { - setState('entered'); - setWillBeEnter(false); - onEntered(); - }, [onEntered]); - - const exited = React.useCallback(() => { - setState('exited'); - setWillBeExit(false); - onExited(); - }, [onExited]); + const [state, setState] = useState(() => + disableInitAnimation ? (stateProp === 'enter' ? 'entered' : 'exited') : stateProp, + ); + const prevState = usePrevious(stateProp); const onAnimationStart = () => { - if (state === 'enter' && willBeEnter) { + if (state === 'enter') { setState('entering'); - onEntering(); - } else if (state === 'exit' && willBeExit) { + if (onEntering) { + onEntering(); + } + } else if (state === 'exit') { setState('exiting'); - onExiting(); + if (onExiting) { + onExiting(); + } } }; const onAnimationEnd = () => { - if (state === 'entering' && willBeEnter) { - entered(); - } else if (state === 'exiting' && willBeExit) { - exited(); + if (state === 'entering') { + setState('entered'); + if (onEntered) { + onEntered(); + } + } else if (state === 'exiting') { + setState('exited'); + if (onExited) { + onExited(); + } } }; + const onEnter = useStableCallback(onEnterProp || noop); + const onExit = useStableCallback(onExitProp || noop); + useIsomorphicLayoutEffect( function updateState() { + if (prevState === stateProp) { + return; + } switch (stateProp) { case 'enter': - if (isFirstInitRef.current && state === 'enter') { - entered(); - break; - } - - if (willBeEnter || state === 'entering' || state === 'entered') { + if (state === 'entering' || state === 'entered') { break; } setState('enter'); - setWillBeEnter(true); onEnter(); break; case 'exit': - if (isFirstInitRef.current && state === 'exit') { - exited(); - break; - } - - if (willBeExit || state === 'exiting' || state === 'exited') { + if (state === 'exiting' || state === 'exited') { break; } setState('exit'); - setWillBeExit(true); onExit(); break; } - - isFirstInitRef.current = false; }, - [state, stateProp, willBeEnter, willBeExit, entered, exited, onEnter, onExit], + [state, prevState, stateProp, onEnter, onExit], ); return [state, { onAnimationStart, onAnimationEnd }];