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,