Merge branch 'main' and resolve conflict in Hero.test.tsx

This commit is contained in:
Melvin Ragusa
2026-01-23 11:03:04 +01:00
5 changed files with 79 additions and 57 deletions

View File

@@ -66,8 +66,17 @@ describe('GradientBlinds', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('starts animation loop on mount', () => { it('does not start animation loop on mount until visible', () => {
const { unmount } = render(<GradientBlinds />); const { unmount } = render(<GradientBlinds />);
expect(rafSpy).not.toHaveBeenCalled();
// Simulate on-screen
act(() => {
if (ioCallback) {
ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]);
}
});
expect(rafSpy).toHaveBeenCalled(); expect(rafSpy).toHaveBeenCalled();
unmount(); unmount();
expect(cancelRafSpy).toHaveBeenCalled(); expect(cancelRafSpy).toHaveBeenCalled();
@@ -76,7 +85,15 @@ describe('GradientBlinds', () => {
it('pauses animation loop when off-screen and resumes when on-screen', () => { it('pauses animation loop when off-screen and resumes when on-screen', () => {
const { unmount } = render(<GradientBlinds />); const { unmount } = render(<GradientBlinds />);
// 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); expect(rafSpy).toHaveBeenCalledTimes(1);
// Reset spies to check for subsequent calls // Reset spies to check for subsequent calls

View File

@@ -349,8 +349,6 @@ void main() {
}); });
observer.observe(container); observer.observe(container);
rafRef.current = requestAnimationFrame(loop);
return () => { return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current); if (rafRef.current) cancelAnimationFrame(rafRef.current);
canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointermove', onPointerMove);

View File

@@ -86,26 +86,22 @@ describe('FancyCursor', () => {
document.body.removeChild(link); document.body.removeChild(link);
}); });
it('uses fallback elementFromPoint if hoveredElement is null', () => { it('does not call elementFromPoint on mousemove', () => {
// Reset elementFromPoint mock // Reset elementFromPoint mock
const mockElementFromPoint = vi.fn(); const mockElementFromPoint = vi.fn();
// @ts-ignore // @ts-ignore
document.elementFromPoint = mockElementFromPoint; 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(<FancyCursor />); render(<FancyCursor />);
// Trigger mousemove WITHOUT prior mouseover. // Trigger mousemove
// This should trigger the fallback.
fireEvent.mouseMove(window, { clientX: 10, clientY: 10 }); fireEvent.mouseMove(window, { clientX: 10, clientY: 10 });
act(() => { act(() => {
vi.runAllTimers(); vi.runAllTimers();
}); });
expect(mockElementFromPoint).toHaveBeenCalledWith(10, 10); // Should NOT be called in the optimized version
expect(mockElementFromPoint).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -19,29 +19,8 @@ export const FancyCursor = memo(() => {
if (!cursor) return; if (!cursor) return;
let animationFrameId: number; let animationFrameId: number;
let hoveredElement: Element | null = null;
const handleMouseOver = (e: MouseEvent) => { const updateCursorState = (el: Element | null) => {
hoveredElement = e.target as Element;
};
// The mouse move handler is throttled with requestAnimationFrame to ensure
// the animation is smooth and doesn't cause performance issues.
const handleMouseMove = (e: MouseEvent) => {
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(() => {
// The offset issue was caused by positioning the cursor's top-left corner
// at the mouse coordinates. To fix this, `translate(-50%, -50%)` is added.
// 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; const classList = cursor.classList;
// Coercing to boolean with `!!` is a micro-optimization. // Coercing to boolean with `!!` is a micro-optimization.
@@ -54,6 +33,22 @@ export const FancyCursor = memo(() => {
classList.toggle('resize-hover', isResize); classList.toggle('resize-hover', isResize);
classList.toggle('text-hover', isText && !isResize); classList.toggle('text-hover', isText && !isResize);
classList.toggle('link-hover', isLink && !isText && !isResize); classList.toggle('link-hover', isLink && !isText && !isResize);
};
const handleMouseOver = (e: MouseEvent) => {
updateCursorState(e.target as Element);
};
// The mouse move handler is throttled with requestAnimationFrame to ensure
// the animation is smooth and doesn't cause performance issues.
const handleMouseMove = (e: MouseEvent) => {
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(() => {
// The offset issue was caused by positioning the cursor's top-left corner
// at the mouse coordinates. To fix this, `translate(-50%, -50%)` is added.
// 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%)`;
}); });
}; };

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
interface UseTypingEffectOptions { interface UseTypingEffectOptions {
words: string[]; words: string[];
@@ -23,44 +23,60 @@ export function useTypingEffect({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const stableWords = useMemo(() => words, [JSON.stringify(words)]); 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(() => { useEffect(() => {
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
const currentWord = stableWords[currentWordIndex]; const currentWord = stableWords[currentWordIndex];
const isWordDeleted = isDeleting && currentText === '';
// If word is fully deleted, move to next word immediately (no timer) const tick = () => {
if (!isPaused && isWordDeleted) { const { currentText, isDeleting, isPaused } = stateRef.current;
setIsDeleting(false);
setCurrentWordIndex((prev) => (prev + 1) % stableWords.length);
return;
}
// Determine speed based on state
const speed = isPaused ? pauseDuration : (isDeleting ? deletingSpeed : typingSpeed);
timer = setTimeout(() => {
if (isPaused) { if (isPaused) {
setIsPaused(false); setIsPaused(false);
setIsDeleting(true); setIsDeleting(true);
} else { return;
// Typing logic }
if (isDeleting) { if (isDeleting) {
if (currentText === '') {
// Handled by the other effect
return;
}
setCurrentText((prev) => prev.substring(0, prev.length - 1)); setCurrentText((prev) => prev.substring(0, prev.length - 1));
timer = setTimeout(tick, deletingSpeed);
} else { } else {
const nextText = currentWord.substring(0, currentText.length + 1); const nextText = currentWord.substring(0, currentText.length + 1);
setCurrentText(nextText); setCurrentText(nextText);
if (nextText === currentWord) { if (nextText === currentWord) {
setIsPaused(true); 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); return () => clearTimeout(timer);
}, [ }, [
currentText,
isDeleting, isDeleting,
isPaused, isPaused,
currentWordIndex, currentWordIndex,