Merge pull request #4 from ragusa-it/perf/hero-scroll-throttle-1683118190839821830

 Perf: Throttle Hero scroll listener
This commit was merged in pull request #4.
This commit is contained in:
Melvin Ragusa
2026-01-23 10:06:16 +01:00
committed by GitHub
2 changed files with 149 additions and 2 deletions

View File

@@ -0,0 +1,135 @@
import { render, fireEvent, act, cleanup } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Hero } from "./Hero";
import { MemoryRouter } from "react-router-dom";
// Mock dependencies
vi.mock("../../i18n", () => ({
useTranslation: () => ({
t: {
hero: {
company: "Test Company",
greeting: "Hello",
tagline: "Building the Future",
rotatingWords: ["Code", "Design"],
cta: "Contact Us",
ctaSecondary: "About Us",
scroll: "Scroll Down",
},
},
}),
}));
vi.mock("../../hooks", () => ({
useTypingEffect: () => ({
text: "Code",
}),
}));
vi.mock("../effects", () => ({
GradientBlinds: () => <div data-testid="gradient-blinds" />,
}));
// Mock Link from react-router-dom to avoid needing Router context for just Link,
// OR just use MemoryRouter in the render. MemoryRouter is safer.
describe("Hero Component", () => {
beforeEach(() => {
// Reset scroll position
window.scrollY = 0;
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders correctly and shows scroll indicator initially", () => {
const { getByText } = render(
<MemoryRouter>
<Hero />
</MemoryRouter>
);
expect(getByText("Test Company")).toBeDefined();
expect(getByText("Scroll Down")).toBeDefined();
});
it("hides scroll indicator when scrolled down", () => {
const { queryByText } = render(
<MemoryRouter>
<Hero />
</MemoryRouter>
);
expect(queryByText("Scroll Down")).toBeDefined();
// Simulate scroll
act(() => {
window.scrollY = 100;
window.dispatchEvent(new Event("scroll"));
vi.runAllTimers();
});
expect(queryByText("Scroll Down")).toBeNull();
});
it("shows scroll indicator again when scrolled back up", () => {
const { queryByText } = render(
<MemoryRouter>
<Hero />
</MemoryRouter>
);
// Initial scroll down
act(() => {
window.scrollY = 100;
window.dispatchEvent(new Event("scroll"));
vi.runAllTimers();
});
expect(queryByText("Scroll Down")).toBeNull();
// Scroll back up
act(() => {
window.scrollY = 0;
window.dispatchEvent(new Event("scroll"));
vi.runAllTimers();
});
expect(queryByText("Scroll Down")).toBeDefined();
});
it("throttles scroll events using requestAnimationFrame", () => {
const rafSpy = vi.spyOn(window, "requestAnimationFrame");
render(
<MemoryRouter>
<Hero />
</MemoryRouter>
);
// Trigger multiple scroll events rapidly
act(() => {
window.scrollY = 10;
window.dispatchEvent(new Event("scroll"));
window.dispatchEvent(new Event("scroll"));
window.dispatchEvent(new Event("scroll"));
});
// Should call rAF only once before timers run (because of ticking flag)
expect(rafSpy).toHaveBeenCalledTimes(1);
// Run timers to reset ticking
act(() => {
vi.runAllTimers();
});
// Trigger again
act(() => {
window.dispatchEvent(new Event("scroll"));
});
// Should call rAF again
expect(rafSpy).toHaveBeenCalledTimes(2);
});
});

View File

@@ -14,12 +14,24 @@ export function Hero() {
const [showScrollIndicator, setShowScrollIndicator] = useState(true);
useEffect(() => {
let ticking = false;
let rafId: number;
const handleScroll = () => {
setShowScrollIndicator(window.scrollY < 50);
if (!ticking) {
rafId = window.requestAnimationFrame(() => {
setShowScrollIndicator(window.scrollY < 50);
ticking = false;
});
ticking = true;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
if (rafId) window.cancelAnimationFrame(rafId);
};
}, []);
const { text } = useTypingEffect({