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:
google-labs-jules[bot]
2026-01-22 04:43:55 +00:00
parent eea2e71b03
commit 8f820e262f
4 changed files with 866 additions and 25 deletions

View File

@@ -24,10 +24,14 @@
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"jsdom": "^27.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^4.0.17"
}
}

716
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View 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);
});
});

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
interface UseTypingEffectOptions {
words: string[];
@@ -18,38 +18,50 @@ export function useTypingEffect({
const [isDeleting, setIsDeleting] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const tick = useCallback(() => {
const currentWord = words[currentWordIndex];
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
if (isPaused) {
return;
}
if (isDeleting) {
setCurrentText(currentWord.substring(0, currentText.length - 1));
if (currentText.length === 0) {
setIsDeleting(false);
setCurrentWordIndex((prev) => (prev + 1) % words.length);
}
} else {
setCurrentText(currentWord.substring(0, currentText.length + 1));
if (currentText === currentWord) {
setIsPaused(true);
setTimeout(() => {
timer = setTimeout(() => {
setIsPaused(false);
setIsDeleting(true);
}, pauseDuration);
}
}
}, [currentText, currentWordIndex, isDeleting, isPaused, words, pauseDuration]);
} else {
const currentWord = words[currentWordIndex];
const isWordDeleted = isDeleting && currentText === '';
useEffect(() => {
if (isWordDeleted) {
setIsDeleting(false);
setCurrentWordIndex((prev) => (prev + 1) % words.length);
} else {
const speed = isDeleting ? deletingSpeed : typingSpeed;
const timer = setTimeout(tick, speed);
timer = setTimeout(() => {
if (isDeleting) {
setCurrentText((prev) => prev.substring(0, prev.length - 1));
} else {
const nextText = currentWord.substring(0, currentText.length + 1);
setCurrentText(nextText);
if (nextText === currentWord) {
setIsPaused(true);
}
}
}, speed);
}
}
return () => clearTimeout(timer);
}, [tick, isDeleting, typingSpeed, deletingSpeed]);
}, [
currentText,
isDeleting,
isPaused,
currentWordIndex,
words,
typingSpeed,
deletingSpeed,
pauseDuration,
]);
return {
text: currentText,