Skip to content

Commit b5fe8ca

Browse files
fix(useCSSTransition): fix fast state changing (#8890)
* fix(useCSSTransition): fix fast state changing * fix(useCSSTransition): fix import * fix: fix code review * fix: add condition when duration <= 0 * fix: delete useStateWithPrev * fix: add timer delay * test: fix test * test: add test * fix: use useIsomorphicLayoutEffect instead of useEffect * fix: use useIsomorphicLayoutEffect instead of useEffect
1 parent 5b189bf commit b5fe8ca

File tree

2 files changed

+153
-18
lines changed

2 files changed

+153
-18
lines changed

packages/vkui/src/lib/animation/useCSSTransition.test.tsx

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, render, renderHook } from '@testing-library/react';
1+
import { act, fireEvent, render, renderHook } from '@testing-library/react';
22
import { useCSSTransition } from './useCSSTransition';
33

44
describe(useCSSTransition, () => {
@@ -415,4 +415,100 @@ describe(useCSSTransition, () => {
415415
},
416416
);
417417
});
418+
419+
it('should not stuck in exiting when transitionend not fired (fast open -> close)', () => {
420+
// Управляем таймерами вручную
421+
jest.useFakeTimers();
422+
423+
// Мокаем getComputedStyle, чтобы хук поставил fallback-таймер (200ms + 0ms)
424+
const getComputedStyleMock = jest.spyOn(window, 'getComputedStyle').mockImplementation(
425+
() =>
426+
({
427+
transitionDuration: '0.2s',
428+
transitionDelay: '0s',
429+
}) as unknown as CSSStyleDeclaration,
430+
);
431+
432+
const { result, rerender } = renderHook(
433+
(inProp) => useCSSTransition<HTMLDivElement>(inProp, { ...callbacks }),
434+
{ initialProps: false },
435+
);
436+
437+
const cmp = render(<div {...result.current[1]} />);
438+
439+
// initial
440+
expect(result.current[0]).toBe('exited');
441+
442+
// open -> entering
443+
rerender(true);
444+
cmp.rerender(<div {...result.current[1]} />);
445+
expect(result.current[0]).toBe('entering');
446+
447+
// очень быстро close -> переход в exiting (но transitionend НЕ придёт)
448+
rerender(false);
449+
cmp.rerender(<div {...result.current[1]} />);
450+
expect(result.current[0]).toBe('exiting');
451+
452+
// колбэки, которые должны были сработать к этому моменту
453+
expect(callbacks.onEnter).toHaveBeenCalledTimes(1);
454+
expect(callbacks.onEntering).toHaveBeenCalledTimes(1);
455+
expect(callbacks.onExiting).toHaveBeenCalledTimes(1);
456+
expect(callbacks.onExited).toHaveBeenCalledTimes(0);
457+
458+
// Прокручиваем таймеры дальше, чтобы сработал fallback (200ms + safety offset (≈50ms))
459+
act(() => {
460+
jest.advanceTimersByTime(300);
461+
});
462+
463+
// После fallback хук должен перевести состояние в exited и вызвать onExited
464+
expect(result.current[0]).toBe('exited');
465+
expect(callbacks.onExited).toHaveBeenCalledTimes(1);
466+
467+
// Восстанавливаем мок
468+
getComputedStyleMock.mockRestore();
469+
jest.useRealTimers();
470+
});
471+
472+
it('should not stuck in exiting when transitionend not fired (transition duration < 0 )', () => {
473+
// Управляем таймерами вручную
474+
jest.useFakeTimers();
475+
476+
// Мокаем getComputedStyle, чтобы хук поставил fallback-таймер (200ms + 0ms)
477+
const getComputedStyleMock = jest.spyOn(window, 'getComputedStyle').mockImplementation(
478+
() =>
479+
({
480+
transitionDuration: '0s',
481+
transitionDelay: '0s',
482+
}) as unknown as CSSStyleDeclaration,
483+
);
484+
485+
const { result, rerender } = renderHook(
486+
(inProp) => useCSSTransition<HTMLDivElement>(inProp, { ...callbacks }),
487+
{ initialProps: false },
488+
);
489+
490+
const cmp = render(<div {...result.current[1]} />);
491+
492+
// initial
493+
expect(result.current[0]).toBe('exited');
494+
495+
// open -> entering
496+
rerender(true);
497+
cmp.rerender(<div {...result.current[1]} />);
498+
expect(result.current[0]).toBe('entered');
499+
500+
rerender(false);
501+
cmp.rerender(<div {...result.current[1]} />);
502+
expect(result.current[0]).toBe('exited');
503+
504+
// колбэки, которые должны были сработать к этому моменту
505+
expect(callbacks.onEnter).toHaveBeenCalledTimes(1);
506+
expect(callbacks.onEntering).toHaveBeenCalledTimes(1);
507+
expect(callbacks.onExiting).toHaveBeenCalledTimes(1);
508+
expect(callbacks.onExited).toHaveBeenCalledTimes(1);
509+
510+
// Восстанавливаем мок
511+
getComputedStyleMock.mockRestore();
512+
jest.useRealTimers();
513+
});
418514
});

packages/vkui/src/lib/animation/useCSSTransition.ts

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { type TransitionEvent, type TransitionEventHandler, useEffect, useRef } from 'react';
1+
import { type TransitionEvent, type TransitionEventHandler, useRef, useState } from 'react';
22
import { noop } from '@vkontakte/vkjs';
33
import { useStableCallback } from '../../hooks/useStableCallback';
4-
import { useStateWithPrev } from '../../hooks/useStateWithPrev';
4+
import { millisecondsInSecond } from '../date';
5+
import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect';
56

67
/* istanbul ignore next: особенность рендера в браузере когда меняется className, в Jest не воспроизвести */
78
const forceReflowForFixNewMountedElement = (node: Element | null) => void node?.scrollTop;
@@ -37,6 +38,8 @@ export type UseCSSTransition<Ref extends Element = Element> = [
3738
},
3839
];
3940

41+
const TRANSITION_FALLBACK_DELAY = 100;
42+
4043
/**
4144
* Хук основан на компоненте `CSSTransition` из библиотеки `react-transition-group`.
4245
*
@@ -64,9 +67,10 @@ export const useCSSTransition = <Ref extends Element = Element>(
6467
const onExit = useStableCallback(onExitProp || noop);
6568
const onExiting = useStableCallback(onExitingProp || noop);
6669
const onExited = useStableCallback(onExitedProp || noop);
70+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
6771

6872
const ref = useRef<Ref | null>(null);
69-
const [[state, prevState], setState] = useStateWithPrev<UseCSSTransitionState>(() => {
73+
const [state, setState] = useState<UseCSSTransitionState>(() => {
7074
if (!inProp) {
7175
return 'exited';
7276
}
@@ -79,7 +83,14 @@ export const useCSSTransition = <Ref extends Element = Element>(
7983
return 'entered';
8084
});
8185

82-
useEffect(
86+
const clearTimer = () => {
87+
if (timerRef.current) {
88+
clearTimeout(timerRef.current);
89+
timerRef.current = null;
90+
}
91+
};
92+
93+
useIsomorphicLayoutEffect(
8394
function updateState() {
8495
if (inProp) {
8596
switch (state) {
@@ -150,9 +161,6 @@ export const useCSSTransition = <Ref extends Element = Element>(
150161
inProp,
151162

152163
state,
153-
prevState,
154-
setState,
155-
156164
enableAppear,
157165
enableEnter,
158166
onEnter,
@@ -166,35 +174,66 @@ export const useCSSTransition = <Ref extends Element = Element>(
166174
],
167175
);
168176

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

175180
switch (state) {
176181
case 'appearing':
177182
setState('appeared');
178-
onEntered(event.propertyName, true);
183+
onEntered(event?.propertyName, true);
179184
break;
180185
case 'entering':
181186
setState('entered');
182-
onEntered(event.propertyName);
187+
onEntered(event?.propertyName);
183188
break;
184189
case 'exiting':
185190
setState('exited');
186-
onExited(event.propertyName);
191+
onExited(event?.propertyName);
187192
break;
188193
}
189-
};
194+
});
195+
196+
useIsomorphicLayoutEffect(
197+
function scheduleTransitionCompletionFallback() {
198+
const el = ref.current;
199+
if (!el) {
200+
return;
201+
}
202+
203+
if (state === 'appearing' || state === 'entering' || state === 'exiting') {
204+
const style = getComputedStyle(el);
205+
206+
const parseTime = (s: string) =>
207+
s.includes('ms') ? parseFloat(s) : parseFloat(s) * millisecondsInSecond;
208+
209+
const duration =
210+
Math.max(...style.transitionDuration.split(',').map(parseTime)) +
211+
Math.max(...style.transitionDelay.split(',').map(parseTime));
212+
213+
if (duration <= 0) {
214+
completeTransition();
215+
return;
216+
}
217+
218+
// fallback если onTransitionEnd не пришёл
219+
// TRANSITION_FALLBACK_DELAY, чтобы минимизировать вероятность,
220+
// что setTimeout сработает раньше onTransitionEnd
221+
timerRef.current = setTimeout(completeTransition, duration + TRANSITION_FALLBACK_DELAY);
222+
223+
return clearTimer;
224+
}
225+
return;
226+
},
227+
[completeTransition, state],
228+
);
190229

191230
return [
192231
state,
193232
{
194233
ref,
195234
onTransitionEnd:
196235
state !== 'appeared' && state !== 'entered' && state !== 'exited'
197-
? onTransitionEnd
236+
? completeTransition
198237
: undefined,
199238
},
200239
];

0 commit comments

Comments
 (0)