Fix uncleaned setTimeout in useTypingEffect hook
Refactor useTypingEffect to use a single useEffect with proper cleanup for all timers, preventing state updates on unmounted components. Add unit tests to verify behavior and ensure no memory leaks on unmount.
This commit is contained in:
@@ -24,10 +24,14 @@
|
|||||||
"react-router-dom": "^7.12.0"
|
"react-router-dom": "^7.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
716
pnpm-lock.yaml
generated
716
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
109
src/hooks/useTypingEffect.test.ts
Normal file
109
src/hooks/useTypingEffect.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useTypingEffect } from './useTypingEffect';
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
describe('useTypingEffect', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should type text, pause, and delete', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTypingEffect({
|
||||||
|
words: ['Hello'],
|
||||||
|
typingSpeed: 10,
|
||||||
|
deletingSpeed: 10,
|
||||||
|
pauseDuration: 100,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
expect(result.current.text).toBe('');
|
||||||
|
|
||||||
|
// Advance time incrementally to ensure state updates and effects run
|
||||||
|
// "Hello" is 5 chars. Should take roughly 50-60ms.
|
||||||
|
// We loop more than enough times to ensure it completes.
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(11);
|
||||||
|
});
|
||||||
|
if (result.current.text === 'Hello') break;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.current.text).toBe('Hello');
|
||||||
|
|
||||||
|
// Should be in pause state (isTyping false, isDeleting false)
|
||||||
|
// Wait for pause duration (100ms)
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
});
|
||||||
|
// Check if paused
|
||||||
|
expect(result.current.isTyping).toBe(false);
|
||||||
|
expect(result.current.isDeleting).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be deleting now
|
||||||
|
expect(result.current.isDeleting).toBe(true);
|
||||||
|
|
||||||
|
// Advance to delete "Hello"
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(11);
|
||||||
|
});
|
||||||
|
if (result.current.text === '') break;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.current.text).toBe('');
|
||||||
|
expect(result.current.isDeleting).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up timers on unmount', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() =>
|
||||||
|
useTypingEffect({
|
||||||
|
words: ['Hi'],
|
||||||
|
typingSpeed: 10,
|
||||||
|
deletingSpeed: 10,
|
||||||
|
pauseDuration: 100,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "Hi"
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(11);
|
||||||
|
});
|
||||||
|
if (result.current.text === 'Hi') break;
|
||||||
|
}
|
||||||
|
expect(result.current.text).toBe('Hi');
|
||||||
|
|
||||||
|
// Trigger the pause logic
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unmount the component
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// Advance time past pause where the timeout would fire
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We verify no console errors regarding unmounted updates
|
||||||
|
const unmountErrors = consoleSpy.mock.calls.filter(args =>
|
||||||
|
args[0] && typeof args[0] === 'string' &&
|
||||||
|
(args[0].includes('unmounted component') || args[0].includes('state update'))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(unmountErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
interface UseTypingEffectOptions {
|
interface UseTypingEffectOptions {
|
||||||
words: string[];
|
words: string[];
|
||||||
@@ -18,38 +18,50 @@ export function useTypingEffect({
|
|||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
|
||||||
const tick = useCallback(() => {
|
useEffect(() => {
|
||||||
const currentWord = words[currentWordIndex];
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
return;
|
timer = setTimeout(() => {
|
||||||
}
|
setIsPaused(false);
|
||||||
|
setIsDeleting(true);
|
||||||
|
}, pauseDuration);
|
||||||
|
} else {
|
||||||
|
const currentWord = words[currentWordIndex];
|
||||||
|
const isWordDeleted = isDeleting && currentText === '';
|
||||||
|
|
||||||
if (isDeleting) {
|
if (isWordDeleted) {
|
||||||
setCurrentText(currentWord.substring(0, currentText.length - 1));
|
|
||||||
|
|
||||||
if (currentText.length === 0) {
|
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
setCurrentWordIndex((prev) => (prev + 1) % words.length);
|
setCurrentWordIndex((prev) => (prev + 1) % words.length);
|
||||||
}
|
} else {
|
||||||
} else {
|
const speed = isDeleting ? deletingSpeed : typingSpeed;
|
||||||
setCurrentText(currentWord.substring(0, currentText.length + 1));
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
if (currentText === currentWord) {
|
if (isDeleting) {
|
||||||
setIsPaused(true);
|
setCurrentText((prev) => prev.substring(0, prev.length - 1));
|
||||||
setTimeout(() => {
|
} else {
|
||||||
setIsPaused(false);
|
const nextText = currentWord.substring(0, currentText.length + 1);
|
||||||
setIsDeleting(true);
|
setCurrentText(nextText);
|
||||||
}, pauseDuration);
|
|
||||||
|
if (nextText === currentWord) {
|
||||||
|
setIsPaused(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, speed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [currentText, currentWordIndex, isDeleting, isPaused, words, pauseDuration]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const speed = isDeleting ? deletingSpeed : typingSpeed;
|
|
||||||
const timer = setTimeout(tick, speed);
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [tick, isDeleting, typingSpeed, deletingSpeed]);
|
}, [
|
||||||
|
currentText,
|
||||||
|
isDeleting,
|
||||||
|
isPaused,
|
||||||
|
currentWordIndex,
|
||||||
|
words,
|
||||||
|
typingSpeed,
|
||||||
|
deletingSpeed,
|
||||||
|
pauseDuration,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: currentText,
|
text: currentText,
|
||||||
|
|||||||
Reference in New Issue
Block a user