Skip to content

Commit

Permalink
feat(useCSSKeyframesAnimationController): exclude first rerender (#7266)
Browse files Browse the repository at this point in the history
Состояние `entered` выставлялось с ре-рендером при инициализации.
  • Loading branch information
inomdzhon authored Jul 30, 2024
1 parent 1c1a72e commit 905bbbe
Show file tree
Hide file tree
Showing 2 changed files with 133 additions and 122 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
108 changes: 46 additions & 62 deletions packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.ts
Original file line number Diff line number Diff line change
@@ -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<AnimationState>(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<AnimationState>(() =>
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 }];
Expand Down

0 comments on commit 905bbbe

Please sign in to comment.