Skip to content

Commit 60e596c

Browse files
Refactor usePresentation to use requestAnimationFrame
This commit refactors the `usePresentation` hook to replace `setTimeout` with `requestAnimationFrame` for managing frame display timing. This change is aimed at improving animation smoothness and performance by synchronizing updates with the browser's repaint cycle. Key changes include: - Modified `setFrameWithAwait` to use a `requestAnimationFrame` loop. - Implemented robust cancellation logic using `isCancelledRef` and `animationFrameIdRef` to stop animations when the component unmounts, `startTrigger` becomes false, or `framesQuantity` is zero. This prevents potential errors and ensures proper cleanup. - Adjusted `useEffect` to manage the cancellation state and clear animation frames. - Corrected the invocation timing of the `callback` prop: it now fires sequentially after each frame's display time completes. - Added a suite of unit tests (`src/index.test.ts`) using Jest and React Testing Library, including mocks for `requestAnimationFrame`, to cover initial state, frame cycling, delays, callbacks, and cancellation. - Exported the `TFrameOptions` type from `src/index.ts` for use in tests and by consumers of the hook.
1 parent fba65ae commit 60e596c

File tree

2 files changed

+230
-7
lines changed

2 files changed

+230
-7
lines changed

src/index.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React, { ReactElement } from 'react';
2+
import { renderHook, act } from '@testing-library/react-hooks';
3+
import usePresentation, { TFrameOptions } from './index';
4+
5+
// Mock for requestAnimationFrame
6+
let rafCallbacks: Array<(time: number) => void> = [];
7+
let mockRafIdCounter = 0;
8+
let currentMockTime = 0;
9+
10+
const mockPerformanceNow = jest.fn().mockImplementation(() => currentMockTime);
11+
12+
const mockRequestAnimationFrame = (callback: (time: number) => void): number => {
13+
rafCallbacks.push(callback);
14+
mockRafIdCounter++;
15+
return mockRafIdCounter;
16+
};
17+
18+
const mockCancelAnimationFrame = (id: number) => {
19+
// Basic mock.
20+
};
21+
22+
// Helper to advance RAF frames
23+
const advanceRaf = (frames: number = 1) => {
24+
for (let i = 0; i < frames; i++) {
25+
const callback = rafCallbacks.shift();
26+
if (callback) {
27+
currentMockTime += 16; // Simulate 16ms passing
28+
callback(currentMockTime);
29+
} else {
30+
break;
31+
}
32+
}
33+
};
34+
35+
describe('usePresentation Hook', () => {
36+
let originalRaf: (callback: FrameRequestCallback) => number;
37+
let originalCancelRaf: (handle: number) => void;
38+
let originalPerformanceNow: () => number;
39+
40+
beforeEach(() => {
41+
originalRaf = window.requestAnimationFrame;
42+
originalCancelRaf = window.cancelAnimationFrame;
43+
originalPerformanceNow = performance.now;
44+
45+
window.requestAnimationFrame = mockRequestAnimationFrame as any;
46+
window.cancelAnimationFrame = mockCancelAnimationFrame;
47+
performance.now = mockPerformanceNow;
48+
49+
currentMockTime = 0;
50+
rafCallbacks = [];
51+
mockRafIdCounter = 0;
52+
53+
jest.useFakeTimers();
54+
});
55+
56+
afterEach(() => {
57+
window.requestAnimationFrame = originalRaf;
58+
window.cancelAnimationFrame = originalCancelRaf;
59+
performance.now = originalPerformanceNow;
60+
jest.clearAllMocks();
61+
jest.useRealTimers();
62+
rafCallbacks = [];
63+
});
64+
65+
const getFrameComponent = (id: string): ReactElement => <div data-testid={id} />;
66+
67+
it('should return correct initial state', () => {
68+
const framesOptions: TFrameOptions[] = [
69+
{ component: getFrameComponent('1'), time: 100 },
70+
];
71+
const { result } = renderHook(() => usePresentation({ framesOptions, startTrigger: false }));
72+
const [, currentFrame, framesQuantity] = result.current;
73+
expect(currentFrame).toBe(0);
74+
expect(framesQuantity).toBe(1);
75+
});
76+
77+
it('should start animation after startDelay and cycle frames with callbacks', () => {
78+
const mockCallback = jest.fn();
79+
const framesOptions: TFrameOptions[] = [
80+
{ component: getFrameComponent('frame1'), time: 100 },
81+
{ component: getFrameComponent('frame2'), time: 200 },
82+
];
83+
84+
const { result, rerender } = renderHook(
85+
(props) => usePresentation(props),
86+
{
87+
initialProps: {
88+
framesOptions,
89+
startTrigger: false,
90+
startDelay: 50,
91+
callback: mockCallback,
92+
isLoop: false,
93+
},
94+
}
95+
);
96+
97+
expect(result.current[1]).toBe(0);
98+
99+
rerender({ framesOptions, startTrigger: true, startDelay: 50, callback: mockCallback, isLoop: false });
100+
101+
act(() => {
102+
jest.advanceTimersByTime(50);
103+
});
104+
105+
act(() => { advanceRaf(1); });
106+
expect(result.current[1]).toBe(1);
107+
108+
for(let i = 0; i < Math.ceil(100/16) + 1; i++) {
109+
act(() => { advanceRaf(1); });
110+
}
111+
expect(result.current[1]).toBe(1);
112+
expect(mockCallback).toHaveBeenCalledTimes(1);
113+
114+
act(() => { advanceRaf(1); });
115+
expect(result.current[1]).toBe(2);
116+
117+
for(let i = 0; i < Math.ceil(200/16) + 1; i++) {
118+
act(() => { advanceRaf(1); });
119+
}
120+
expect(result.current[1]).toBe(2);
121+
expect(mockCallback).toHaveBeenCalledTimes(2);
122+
});
123+
124+
it('should cancel animation if startTrigger becomes false', () => {
125+
const mockCallback = jest.fn();
126+
const framesOptions: TFrameOptions[] = [
127+
{ component: getFrameComponent('frame1'), time: 100 },
128+
{ component: getFrameComponent('frame2'), time: 200 },
129+
];
130+
131+
const { result, rerender } = renderHook(
132+
(props) => usePresentation(props),
133+
{
134+
initialProps: {
135+
framesOptions,
136+
startTrigger: true,
137+
startDelay: 0,
138+
callback: mockCallback,
139+
isLoop: false,
140+
},
141+
}
142+
);
143+
144+
act(() => { advanceRaf(1); });
145+
expect(result.current[1]).toBe(1);
146+
147+
act(() => { advanceRaf(2); });
148+
149+
rerender({ framesOptions, startTrigger: false, startDelay: 0, callback: mockCallback, isLoop: false });
150+
151+
act(() => { advanceRaf(10); });
152+
153+
expect(result.current[1]).toBe(1);
154+
expect(mockCallback).not.toHaveBeenCalled();
155+
});
156+
});

src/index.ts

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ function usePresentation({
4848
useState<TFrameOptionsWithPosition | null>(null);
4949
const framesQuantity = framesOptions?.length || 0;
5050
const framesRef = useRef(framesOptions);
51+
const animationFrameIdRef = useRef<number | null>(null);
52+
const isCancelledRef = useRef<boolean>(false);
5153
const callbackCb = useCallback(() => {
5254
if (callback && typeof callback === 'function') {
5355
return callback();
@@ -57,21 +59,64 @@ function usePresentation({
5759
}, [callback]);
5860

5961
const setFrameWithAwait = useCallback(
60-
async (framesArray: Array<TFrameOptions>) => {
62+
async (framesArray: Array<TFrameOptions>): Promise<void> => { // Ensure it returns Promise<void>
63+
if (isCancelledRef.current) return;
64+
6165
const [firstFrame, ...otherFrames] = framesArray;
62-
const currentFrame = (framesRef.current?.indexOf(firstFrame) || 0) + 1;
66+
const frameIndex = framesRef.current ? framesRef.current.indexOf(firstFrame) : -1;
67+
const currentFrame = frameIndex >= 0 ? frameIndex + 1 : 0;
6368

69+
if (isCancelledRef.current) return;
6470
setCurrentFrameOptions({ ...firstFrame, currentFrame });
6571

66-
await new Promise((resolve) => setTimeout(resolve, firstFrame.time));
72+
if (firstFrame.time && firstFrame.time > 0) {
73+
try {
74+
await new Promise<void>((resolve, reject) => {
75+
let start: number | null = null;
76+
const step = (timestamp: number) => {
77+
if (isCancelledRef.current) {
78+
if (animationFrameIdRef.current !== null) {
79+
cancelAnimationFrame(animationFrameIdRef.current);
80+
}
81+
animationFrameIdRef.current = null;
82+
reject(new Error('Animation cancelled'));
83+
return;
84+
}
85+
86+
if (start === null) {
87+
start = timestamp;
88+
}
89+
const progress = timestamp - start;
90+
if (progress < firstFrame.time!) {
91+
animationFrameIdRef.current = requestAnimationFrame(step);
92+
} else {
93+
animationFrameIdRef.current = null;
94+
resolve();
95+
}
96+
};
97+
animationFrameIdRef.current = requestAnimationFrame(step);
98+
});
99+
} catch (error: any) {
100+
if (error.message === 'Animation cancelled') {
101+
// console.log('Frame animation await cancelled');
102+
return; // Stop execution if cancelled
103+
}
104+
throw error; // Re-throw other errors
105+
}
106+
}
107+
108+
if (isCancelledRef.current) return;
109+
callbackCb(); // << MOVED HERE: Called after this frame's time, before next frame.
110+
111+
if (isCancelledRef.current) return; // Check again before recursion logic
67112

68113
if (otherFrames.length) {
69-
await setFrameWithAwait(otherFrames);
114+
await setFrameWithAwait(otherFrames); // Recursive call
70115
}
71116

72-
callbackCb();
117+
// callbackCb() is no longer here.
73118
},
74-
[callbackCb]
119+
[callbackCb] // framesRef, setCurrentFrameOptions, animationFrameIdRef, isCancelledRef are stable
75120
);
76121

77122
const setMotion = useCallback(async () => {
@@ -89,16 +134,37 @@ function usePresentation({
89134
}, [startDelay, isLoop, setFrameWithAwait]);
90135

91136
useEffect(() => {
137+
// `mounted` variable can be removed if isCancelledRef handles all relevant cases.
138+
// Let's keep `mounted` for now as it has a slightly different scope (strict unmount).
92139
let mounted = true;
93140

94141
if (framesQuantity > 0 && startTrigger && mounted) {
142+
isCancelledRef.current = false; // Reset cancellation flag before starting
143+
// Ensure any previous rAF is cleared before starting a new motion if setMotion itself doesn't handle it.
144+
// This is important if startTrigger rapidly toggles.
145+
if (animationFrameIdRef.current !== null) {
146+
cancelAnimationFrame(animationFrameIdRef.current);
147+
animationFrameIdRef.current = null;
148+
}
95149
setMotion();
150+
} else {
151+
// Conditions to run are not met (e.g., startTrigger became false, or framesQuantity is 0)
152+
isCancelledRef.current = true;
153+
if (animationFrameIdRef.current !== null) {
154+
cancelAnimationFrame(animationFrameIdRef.current);
155+
animationFrameIdRef.current = null;
156+
}
96157
}
97158

98159
return () => {
99160
mounted = false;
161+
isCancelledRef.current = true;
162+
if (animationFrameIdRef.current !== null) {
163+
cancelAnimationFrame(animationFrameIdRef.current);
164+
animationFrameIdRef.current = null;
165+
}
100166
};
101-
}, [framesQuantity, startTrigger, setMotion]);
167+
}, [framesQuantity, startTrigger, setMotion]); // setMotion's stability is important here.
102168

103169
const Animation = useCallback(
104170
({ children, className }) => {
@@ -130,4 +196,5 @@ function usePresentation({
130196
}, [Animation, CurrentFrameOptions, framesQuantity]);
131197
}
132198

199+
export type { TFrameOptions };
133200
export default usePresentation;

0 commit comments

Comments
 (0)