refactor(Hero): replace scroll listener with IntersectionObserver

- Replaced window scroll event listener with IntersectionObserver for better performance.
- Added a sentinel element to track scroll position relative to the top.
- Updated tests to mock IntersectionObserver and verify behavior without scroll events.
This commit is contained in:
google-labs-jules[bot]
2026-01-23 09:31:08 +00:00
parent acfbb8571c
commit 196ab41ac6
2 changed files with 49 additions and 59 deletions

View File

@@ -1,4 +1,5 @@
import { render, fireEvent, act, cleanup } from "@testing-library/react";
// @vitest-environment jsdom
import { render, 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";
@@ -30,13 +31,25 @@ 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.
// IntersectionObserver Mock
const mockObserve = vi.fn();
const mockDisconnect = vi.fn();
let intersectCallback: (entries: Partial<IntersectionObserverEntry>[]) => void = () => {};
class MockIntersectionObserver {
constructor(callback: (entries: Partial<IntersectionObserverEntry>[]) => void) {
intersectCallback = callback;
}
observe = mockObserve;
disconnect = mockDisconnect;
unobserve = vi.fn();
takeRecords = vi.fn();
}
window.IntersectionObserver = MockIntersectionObserver as any;
describe("Hero Component", () => {
beforeEach(() => {
// Reset scroll position
window.scrollY = 0;
vi.clearAllMocks();
vi.useFakeTimers();
});
@@ -57,7 +70,7 @@ describe("Hero Component", () => {
expect(getByText("Scroll Down")).toBeDefined();
});
it("hides scroll indicator when scrolled down", () => {
it("hides scroll indicator when scrolled down (not intersecting)", () => {
const { queryByText } = render(
<MemoryRouter>
<Hero />
@@ -66,17 +79,15 @@ describe("Hero Component", () => {
expect(queryByText("Scroll Down")).toBeDefined();
// Simulate scroll
// Simulate intersection change (scrolled down -> not intersecting)
act(() => {
window.scrollY = 100;
window.dispatchEvent(new Event("scroll"));
vi.runAllTimers();
intersectCallback([{ isIntersecting: false }]);
});
expect(queryByText("Scroll Down")).toBeNull();
});
it("shows scroll indicator again when scrolled back up", () => {
it("shows scroll indicator again when scrolled back up (intersecting)", () => {
const { queryByText } = render(
<MemoryRouter>
<Hero />
@@ -85,51 +96,24 @@ describe("Hero Component", () => {
// Initial scroll down
act(() => {
window.scrollY = 100;
window.dispatchEvent(new Event("scroll"));
vi.runAllTimers();
intersectCallback([{ isIntersecting: false }]);
});
expect(queryByText("Scroll Down")).toBeNull();
// Scroll back up
act(() => {
window.scrollY = 0;
window.dispatchEvent(new Event("scroll"));
vi.runAllTimers();
intersectCallback([{ isIntersecting: true }]);
});
expect(queryByText("Scroll Down")).toBeDefined();
});
it("throttles scroll events using requestAnimationFrame", () => {
const rafSpy = vi.spyOn(window, "requestAnimationFrame");
it("uses IntersectionObserver to track visibility", () => {
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);
expect(mockObserve).toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Link } from "react-router-dom";
import { motion } from "motion/react";
import { useTranslation } from "../../i18n";
@@ -12,26 +12,21 @@ const GRADIENT_COLORS = ["#26a269", "#8ff0a4"];
export function Hero() {
const { t } = useTranslation();
const [showScrollIndicator, setShowScrollIndicator] = useState(true);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let ticking = false;
let rafId: number;
const observer = new IntersectionObserver(
([entry]) => {
setShowScrollIndicator(entry.isIntersecting);
},
{ threshold: 0 }
);
const handleScroll = () => {
if (!ticking) {
rafId = window.requestAnimationFrame(() => {
setShowScrollIndicator(window.scrollY < 50);
ticking = false;
});
ticking = true;
}
};
if (sentinelRef.current) {
observer.observe(sentinelRef.current);
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
if (rafId) window.cancelAnimationFrame(rafId);
};
return () => observer.disconnect();
}, []);
const { text } = useTypingEffect({
@@ -43,6 +38,17 @@ export function Hero() {
return (
<section className={styles.hero}>
<div
ref={sentinelRef}
style={{
position: "absolute",
top: 0,
height: "50px",
width: "1px",
pointerEvents: "none",
opacity: 0,
}}
/>
<div className={styles.backgroundContainer}>
<GradientBlinds
gradientColors={GRADIENT_COLORS}