⚡ Optimize FancyCursor by replacing elementFromPoint with event delegation #6
111
src/components/layout/FancyCursor.test.tsx
Normal file
111
src/components/layout/FancyCursor.test.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { render, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { FancyCursor } from './FancyCursor';
|
||||||
|
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
describe('FancyCursor', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
// Mock elementFromPoint for JSDOM
|
||||||
|
// @ts-ignore
|
||||||
|
document.elementFromPoint = vi.fn((x, y) => null);
|
||||||
|
|
||||||
|
// Mock requestAnimationFrame
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
// @ts-ignore
|
||||||
|
delete document.elementFromPoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Ensure we are not seen as a touch device
|
||||||
|
Object.defineProperty(navigator, 'maxTouchPoints', { value: 0, writable: true });
|
||||||
|
// @ts-ignore
|
||||||
|
delete window.ontouchstart;
|
||||||
|
// console.log('Test Setup:', { ontouchstart: 'ontouchstart' in window, maxTouchPoints: navigator.maxTouchPoints });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders and updates position on mousemove', () => {
|
||||||
|
const { container } = render(<FancyCursor />);
|
||||||
|
// Using firstElementChild because querySelector seems flaky in this test environment
|
||||||
|
const cursor = container.firstElementChild as HTMLElement;
|
||||||
|
expect(cursor).toBeTruthy();
|
||||||
|
expect(cursor.id).toBe('fancy-cursor');
|
||||||
|
|
||||||
|
fireEvent.mouseMove(window, { clientX: 100, clientY: 100 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check transform
|
||||||
|
expect(cursor.style.transform).toContain('translate3d(100px, 100px, 0)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles link-hover class when hovering a link', () => {
|
||||||
|
// We need to attach the link to the document body because FancyCursor uses global event listeners
|
||||||
|
// and checks the document structure via events or elementFromPoint.
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = '#';
|
||||||
|
link.id = 'test-link';
|
||||||
|
link.textContent = 'Link';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
|
||||||
|
const { container } = render(<FancyCursor />);
|
||||||
|
const cursor = container.firstElementChild as HTMLElement;
|
||||||
|
expect(cursor).toBeTruthy();
|
||||||
|
|
||||||
|
// Simulate mouse over the link.
|
||||||
|
// Important: in our optimized code, we rely on 'mouseover' event to set 'hoveredElement'.
|
||||||
|
fireEvent.mouseOver(link);
|
||||||
|
|
||||||
|
// Then mouseMove triggers the update loop
|
||||||
|
fireEvent.mouseMove(window, { clientX: 50, clientY: 50 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cursor.classList.contains('link-hover')).toBe(true);
|
||||||
|
|
||||||
|
// Move away to body
|
||||||
|
fireEvent.mouseOver(document.body);
|
||||||
|
fireEvent.mouseMove(window, { clientX: 200, clientY: 200 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cursor.classList.contains('link-hover')).toBe(false);
|
||||||
|
|
||||||
|
document.body.removeChild(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses fallback elementFromPoint if hoveredElement is null', () => {
|
||||||
|
// 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(<FancyCursor />);
|
||||||
|
|
||||||
|
// Trigger mousemove WITHOUT prior mouseover.
|
||||||
|
// This should trigger the fallback.
|
||||||
|
fireEvent.mouseMove(window, { clientX: 10, clientY: 10 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockElementFromPoint).toHaveBeenCalledWith(10, 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,11 @@ 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) => {
|
||||||
|
hoveredElement = e.target as Element;
|
||||||
|
};
|
||||||
|
|
||||||
// The mouse move handler is throttled with requestAnimationFrame to ensure
|
// The mouse move handler is throttled with requestAnimationFrame to ensure
|
||||||
// the animation is smooth and doesn't cause performance issues.
|
// the animation is smooth and doesn't cause performance issues.
|
||||||
@@ -31,7 +36,12 @@ export const FancyCursor = memo(() => {
|
|||||||
// 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%)`;
|
||||||
|
|
||||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
// 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.
|
||||||
@@ -49,11 +59,13 @@ export const FancyCursor = memo(() => {
|
|||||||
|
|
||||||
// Using a passive event listener can improve scrolling performance.
|
// Using a passive event listener can improve scrolling performance.
|
||||||
window.addEventListener('mousemove', handleMouseMove, { passive: true });
|
window.addEventListener('mousemove', handleMouseMove, { passive: true });
|
||||||
|
window.addEventListener('mouseover', handleMouseOver, { passive: true });
|
||||||
|
|
||||||
// The cleanup function removes the event listener when the component unmounts
|
// The cleanup function removes the event listener when the component unmounts
|
||||||
// to prevent memory leaks.
|
// to prevent memory leaks.
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', handleMouseMove);
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseover', handleMouseOver);
|
||||||
cancelAnimationFrame(animationFrameId);
|
cancelAnimationFrame(animationFrameId);
|
||||||
};
|
};
|
||||||
}, []); // The empty dependency array ensures this effect runs only once.
|
}, []); // The empty dependency array ensures this effect runs only once.
|
||||||
|
|||||||
Reference in New Issue
Block a user