⚡ Improve Hero Scroll Performance #11
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,10 +19,24 @@ export const FancyCursor = memo(() => {
|
|||||||
if (!cursor) return;
|
if (!cursor) return;
|
||||||
|
|
||||||
let animationFrameId: number;
|
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) => {
|
const handleMouseOver = (e: MouseEvent) => {
|
||||||
hoveredElement = e.target as Element;
|
updateCursorState(e.target as Element);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The mouse move handler is throttled with requestAnimationFrame to ensure
|
// 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,
|
// This shifts the cursor element by half its own width and height,
|
||||||
// which effectively centers it on the pointer without affecting the visuals.
|
// 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%)`;
|
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);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
setCurrentText((prev) => prev.substring(0, prev.length - 1));
|
|
||||||
} else {
|
|
||||||
const nextText = currentWord.substring(0, currentText.length + 1);
|
|
||||||
setCurrentText(nextText);
|
|
||||||
|
|
||||||
if (nextText === currentWord) {
|
if (isDeleting) {
|
||||||
setIsPaused(true);
|
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);
|
return () => clearTimeout(timer);
|
||||||
}, [
|
}, [
|
||||||
currentText,
|
|
||||||
isDeleting,
|
isDeleting,
|
||||||
isPaused,
|
isPaused,
|
||||||
currentWordIndex,
|
currentWordIndex,
|
||||||
|
|||||||
Reference in New Issue
Block a user