diff --git a/src/components/effects/GradientBlinds.test.tsx b/src/components/effects/GradientBlinds.test.tsx index 4a2789c..e29a176 100644 --- a/src/components/effects/GradientBlinds.test.tsx +++ b/src/components/effects/GradientBlinds.test.tsx @@ -66,8 +66,17 @@ describe('GradientBlinds', () => { vi.clearAllMocks(); }); - it('starts animation loop on mount', () => { + it('does not start animation loop on mount until visible', () => { const { unmount } = render(); + expect(rafSpy).not.toHaveBeenCalled(); + + // Simulate on-screen + act(() => { + if (ioCallback) { + ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]); + } + }); + expect(rafSpy).toHaveBeenCalled(); unmount(); expect(cancelRafSpy).toHaveBeenCalled(); @@ -76,7 +85,15 @@ describe('GradientBlinds', () => { it('pauses animation loop when off-screen and resumes when on-screen', () => { const { unmount } = render(); - // Initial start + // Should not start initially + expect(rafSpy).not.toHaveBeenCalled(); + + // Simulate on-screen (start) + act(() => { + if (ioCallback) { + ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]); + } + }); expect(rafSpy).toHaveBeenCalledTimes(1); // Reset spies to check for subsequent calls diff --git a/src/components/effects/GradientBlinds.tsx b/src/components/effects/GradientBlinds.tsx index 735c48e..7a1595e 100644 --- a/src/components/effects/GradientBlinds.tsx +++ b/src/components/effects/GradientBlinds.tsx @@ -349,8 +349,6 @@ void main() { }); observer.observe(container); - rafRef.current = requestAnimationFrame(loop); - return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); canvas.removeEventListener('pointermove', onPointerMove); diff --git a/src/components/layout/FancyCursor.test.tsx b/src/components/layout/FancyCursor.test.tsx index 6b433d6..da5d2d3 100644 --- a/src/components/layout/FancyCursor.test.tsx +++ b/src/components/layout/FancyCursor.test.tsx @@ -86,26 +86,22 @@ describe('FancyCursor', () => { document.body.removeChild(link); }); - it('uses fallback elementFromPoint if hoveredElement is null', () => { + it('does not call elementFromPoint on mousemove', () => { // Reset elementFromPoint mock const mockElementFromPoint = vi.fn(); // @ts-ignore document.elementFromPoint = mockElementFromPoint; - // We need to remount to reset internal state if any. - // However, 'hoveredElement' variable was defined INSIDE the component in my edit. - // Let's double check that. - render(); - // Trigger mousemove WITHOUT prior mouseover. - // This should trigger the fallback. + // Trigger mousemove fireEvent.mouseMove(window, { clientX: 10, clientY: 10 }); act(() => { vi.runAllTimers(); }); - expect(mockElementFromPoint).toHaveBeenCalledWith(10, 10); + // Should NOT be called in the optimized version + expect(mockElementFromPoint).not.toHaveBeenCalled(); }); }); diff --git a/src/components/layout/FancyCursor.tsx b/src/components/layout/FancyCursor.tsx index 27d6c5c..501d293 100644 --- a/src/components/layout/FancyCursor.tsx +++ b/src/components/layout/FancyCursor.tsx @@ -19,10 +19,24 @@ export const FancyCursor = memo(() => { if (!cursor) return; let animationFrameId: number; - let hoveredElement: Element | null = null; + + const updateCursorState = (el: Element | null) => { + const classList = cursor.classList; + + // Coercing to boolean with `!!` is a micro-optimization. + const isResize = !!el?.closest('#custom-resize-handle'); + const isText = !!el?.closest('input[type="text"], input[type="email"], textarea, [contenteditable="true"]'); + const isLink = !!el?.closest('a, button, [role="button"], input[type="submit"], input[type="button"]'); + + // These classes are toggled based on the hovered element. + // The actual visual styles are defined in your CSS files. + classList.toggle('resize-hover', isResize); + classList.toggle('text-hover', isText && !isResize); + classList.toggle('link-hover', isLink && !isText && !isResize); + }; const handleMouseOver = (e: MouseEvent) => { - hoveredElement = e.target as Element; + updateCursorState(e.target as Element); }; // The mouse move handler is throttled with requestAnimationFrame to ensure @@ -35,25 +49,6 @@ export const FancyCursor = memo(() => { // This shifts the cursor element by half its own width and height, // which effectively centers it on the pointer without affecting the visuals. cursor.style.transform = `translate3d(${e.clientX}px, ${e.clientY}px, 0) translate(-50%, -50%)`; - - // Use the cached hovered element if available, otherwise fallback to elementFromPoint - // This fallback is mostly for the initial state before any mouseover events - if (!hoveredElement) { - hoveredElement = document.elementFromPoint(e.clientX, e.clientY); - } - const el = hoveredElement; - const classList = cursor.classList; - - // Coercing to boolean with `!!` is a micro-optimization. - const isResize = !!el?.closest('#custom-resize-handle'); - const isText = !!el?.closest('input[type="text"], input[type="email"], textarea, [contenteditable="true"]'); - const isLink = !!el?.closest('a, button, [role="button"], input[type="submit"], input[type="button"]'); - - // These classes are toggled based on the hovered element. - // The actual visual styles are defined in your CSS files. - classList.toggle('resize-hover', isResize); - classList.toggle('text-hover', isText && !isResize); - classList.toggle('link-hover', isLink && !isText && !isResize); }); }; diff --git a/src/hooks/useTypingEffect.ts b/src/hooks/useTypingEffect.ts index 2866685..4f1878f 100644 --- a/src/hooks/useTypingEffect.ts +++ b/src/hooks/useTypingEffect.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; interface UseTypingEffectOptions { words: string[]; @@ -23,44 +23,60 @@ export function useTypingEffect({ // eslint-disable-next-line react-hooks/exhaustive-deps const stableWords = useMemo(() => words, [JSON.stringify(words)]); + // Ref to hold the latest state for the timer loop + const stateRef = useRef({ currentText, isDeleting, isPaused }); + useEffect(() => { + stateRef.current = { currentText, isDeleting, isPaused }; + }); + + // Effect 1: Handle word switching when deleted + useEffect(() => { + if (isDeleting && currentText === '' && !isPaused) { + setIsDeleting(false); + setCurrentWordIndex((prev) => (prev + 1) % stableWords.length); + } + }, [currentText, isDeleting, isPaused, stableWords]); + + // Effect 2: Timer Loop useEffect(() => { let timer: ReturnType; const currentWord = stableWords[currentWordIndex]; - const isWordDeleted = isDeleting && currentText === ''; - // If word is fully deleted, move to next word immediately (no timer) - if (!isPaused && isWordDeleted) { - setIsDeleting(false); - setCurrentWordIndex((prev) => (prev + 1) % stableWords.length); - return; - } + const tick = () => { + const { currentText, isDeleting, isPaused } = stateRef.current; - // Determine speed based on state - const speed = isPaused ? pauseDuration : (isDeleting ? deletingSpeed : typingSpeed); - - timer = setTimeout(() => { if (isPaused) { setIsPaused(false); setIsDeleting(true); - } else { - // Typing logic - if (isDeleting) { - setCurrentText((prev) => prev.substring(0, prev.length - 1)); - } else { - const nextText = currentWord.substring(0, currentText.length + 1); - setCurrentText(nextText); + return; + } - if (nextText === currentWord) { - setIsPaused(true); - } + if (isDeleting) { + if (currentText === '') { + // Handled by the other effect + return; + } + setCurrentText((prev) => prev.substring(0, prev.length - 1)); + timer = setTimeout(tick, deletingSpeed); + } else { + const nextText = currentWord.substring(0, currentText.length + 1); + setCurrentText(nextText); + + if (nextText === currentWord) { + setIsPaused(true); + } else { + timer = setTimeout(tick, typingSpeed); } } - }, speed); + }; + + // Determine initial speed + const speed = isPaused ? pauseDuration : (isDeleting ? deletingSpeed : typingSpeed); + timer = setTimeout(tick, speed); return () => clearTimeout(timer); }, [ - currentText, isDeleting, isPaused, currentWordIndex,