Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion packages/vkui/src/lib/animation/useCSSTransition.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, renderHook } from '@testing-library/react';
import { act, fireEvent, render, renderHook } from '@testing-library/react';
import { useCSSTransition } from './useCSSTransition';

describe(useCSSTransition, () => {
Expand Down Expand Up @@ -415,4 +415,57 @@ describe(useCSSTransition, () => {
},
);
});

it('should not stuck in exiting when transitionend not fired (fast open -> close)', () => {
// Управляем таймерами вручную
jest.useFakeTimers();

// Мокаем getComputedStyle, чтобы хук поставил fallback-таймер (200ms + 0ms)
const getComputedStyleMock = jest.spyOn(window, 'getComputedStyle').mockImplementation(
() =>
({
transitionDuration: '0.2s',
transitionDelay: '0s',
}) as unknown as CSSStyleDeclaration,
);

const { result, rerender } = renderHook(
(inProp) => useCSSTransition<HTMLDivElement>(inProp, { ...callbacks }),
{ initialProps: false },
);

const cmp = render(<div {...result.current[1]} />);

// initial
expect(result.current[0]).toBe('exited');

// open -> entering
rerender(true);
cmp.rerender(<div {...result.current[1]} />);
expect(result.current[0]).toBe('entering');

// очень быстро close -> переход в exiting (но transitionend НЕ придёт)
rerender(false);
cmp.rerender(<div {...result.current[1]} />);
expect(result.current[0]).toBe('exiting');

// колбэки, которые должны были сработать к этому моменту
expect(callbacks.onEnter).toHaveBeenCalledTimes(1);
expect(callbacks.onEntering).toHaveBeenCalledTimes(1);
expect(callbacks.onExiting).toHaveBeenCalledTimes(1);
expect(callbacks.onExited).toHaveBeenCalledTimes(0);

// Прокручиваем таймеры дальше, чтобы сработал fallback (200ms + safety offset (≈50ms))
act(() => {
jest.advanceTimersByTime(250);
});

// После fallback хук должен перевести состояние в exited и вызвать onExited
expect(result.current[0]).toBe('exited');
expect(callbacks.onExited).toHaveBeenCalledTimes(1);

// Восстанавливаем мок
getComputedStyleMock.mockRestore();
jest.useRealTimers();
});
});
58 changes: 48 additions & 10 deletions packages/vkui/src/lib/animation/useCSSTransition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type TransitionEvent, type TransitionEventHandler, useEffect, useRef }
import { noop } from '@vkontakte/vkjs';
import { useStableCallback } from '../../hooks/useStableCallback';
import { useStateWithPrev } from '../../hooks/useStateWithPrev';
import { millisecondsInSecond } from '../date';

/* istanbul ignore next: особенность рендера в браузере когда меняется className, в Jest не воспроизвести */
const forceReflowForFixNewMountedElement = (node: Element | null) => void node?.scrollTop;
Expand Down Expand Up @@ -64,6 +65,7 @@ export const useCSSTransition = <Ref extends Element = Element>(
const onExit = useStableCallback(onExitProp || noop);
const onExiting = useStableCallback(onExitingProp || noop);
const onExited = useStableCallback(onExitedProp || noop);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const ref = useRef<Ref | null>(null);
const [[state, prevState], setState] = useStateWithPrev<UseCSSTransitionState>(() => {
Expand All @@ -79,6 +81,13 @@ export const useCSSTransition = <Ref extends Element = Element>(
return 'entered';
});

const clearTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};

useEffect(
function updateState() {
if (inProp) {
Expand Down Expand Up @@ -166,35 +175,64 @@ export const useCSSTransition = <Ref extends Element = Element>(
],
);

const onTransitionEnd = (event: TransitionEvent) => {
/* istanbul ignore if: на всякий случай предупреждаем всплытие, нет смысла проверять условие */
if (event.target !== ref.current) {
return;
}
const completeTransition = useStableCallback((event?: TransitionEvent) => {
clearTimer();

switch (state) {
case 'appearing':
setState('appeared');
onEntered(event.propertyName, true);
onEntered(event?.propertyName, true);
break;
case 'entering':
setState('entered');
onEntered(event.propertyName);
onEntered(event?.propertyName);
break;
case 'exiting':
setState('exited');
onExited(event.propertyName);
onExited(event?.propertyName);
break;
}
};
});

useEffect(
function scheduleTransitionCompletionFallback() {
const el = ref.current;
if (!el) {
return;
}

if (state === 'appearing' || state === 'entering' || state === 'exiting') {
const style = getComputedStyle(el);

const parseTime = (s: string) =>
s.includes('ms') ? parseFloat(s) : parseFloat(s) * millisecondsInSecond;

const duration =
Math.max(...style.transitionDuration.split(',').map(parseTime)) +
Math.max(...style.transitionDelay.split(',').map(parseTime));

if (duration <= 0) {
completeTransition();
return;
}

// fallback если onTransitionEnd не пришёл
timerRef.current = setTimeout(completeTransition, duration);

return clearTimer;
}
return;
},
[completeTransition, state],
);

return [
state,
{
ref,
onTransitionEnd:
state !== 'appeared' && state !== 'entered' && state !== 'exited'
? onTransitionEnd
? completeTransition
: undefined,
},
];
Expand Down