⚡ Improve Hero Scroll Performance #11
@@ -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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { Hero } from "./Hero";
|
import { Hero } from "./Hero";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
@@ -30,13 +31,25 @@ vi.mock("../effects", () => ({
|
|||||||
GradientBlinds: () => <div data-testid="gradient-blinds" />,
|
GradientBlinds: () => <div data-testid="gradient-blinds" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock Link from react-router-dom to avoid needing Router context for just Link,
|
// IntersectionObserver Mock
|
||||||
// OR just use MemoryRouter in the render. MemoryRouter is safer.
|
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", () => {
|
describe("Hero Component", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset scroll position
|
|
||||||
window.scrollY = 0;
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
@@ -57,7 +70,7 @@ describe("Hero Component", () => {
|
|||||||
expect(getByText("Scroll Down")).toBeDefined();
|
expect(getByText("Scroll Down")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides scroll indicator when scrolled down", () => {
|
it("hides scroll indicator when scrolled down (not intersecting)", () => {
|
||||||
const { queryByText } = render(
|
const { queryByText } = render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Hero />
|
<Hero />
|
||||||
@@ -66,17 +79,15 @@ describe("Hero Component", () => {
|
|||||||
|
|
||||||
expect(queryByText("Scroll Down")).toBeDefined();
|
expect(queryByText("Scroll Down")).toBeDefined();
|
||||||
|
|
||||||
// Simulate scroll
|
// Simulate intersection change (scrolled down -> not intersecting)
|
||||||
act(() => {
|
act(() => {
|
||||||
window.scrollY = 100;
|
intersectCallback([{ isIntersecting: false }]);
|
||||||
window.dispatchEvent(new Event("scroll"));
|
|
||||||
vi.runAllTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(queryByText("Scroll Down")).toBeNull();
|
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(
|
const { queryByText } = render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Hero />
|
<Hero />
|
||||||
@@ -85,51 +96,24 @@ describe("Hero Component", () => {
|
|||||||
|
|
||||||
// Initial scroll down
|
// Initial scroll down
|
||||||
act(() => {
|
act(() => {
|
||||||
window.scrollY = 100;
|
intersectCallback([{ isIntersecting: false }]);
|
||||||
window.dispatchEvent(new Event("scroll"));
|
|
||||||
vi.runAllTimers();
|
|
||||||
});
|
});
|
||||||
expect(queryByText("Scroll Down")).toBeNull();
|
expect(queryByText("Scroll Down")).toBeNull();
|
||||||
|
|
||||||
// Scroll back up
|
// Scroll back up
|
||||||
act(() => {
|
act(() => {
|
||||||
window.scrollY = 0;
|
intersectCallback([{ isIntersecting: true }]);
|
||||||
window.dispatchEvent(new Event("scroll"));
|
|
||||||
vi.runAllTimers();
|
|
||||||
});
|
});
|
||||||
expect(queryByText("Scroll Down")).toBeDefined();
|
expect(queryByText("Scroll Down")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throttles scroll events using requestAnimationFrame", () => {
|
it("uses IntersectionObserver to track visibility", () => {
|
||||||
const rafSpy = vi.spyOn(window, "requestAnimationFrame");
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Hero />
|
<Hero />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger multiple scroll events rapidly
|
expect(mockObserve).toHaveBeenCalled();
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
@@ -12,26 +12,21 @@ const GRADIENT_COLORS = ["#26a269", "#8ff0a4"];
|
|||||||
export function Hero() {
|
export function Hero() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showScrollIndicator, setShowScrollIndicator] = useState(true);
|
const [showScrollIndicator, setShowScrollIndicator] = useState(true);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ticking = false;
|
const observer = new IntersectionObserver(
|
||||||
let rafId: number;
|
([entry]) => {
|
||||||
|
setShowScrollIndicator(entry.isIntersecting);
|
||||||
|
},
|
||||||
|
{ threshold: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
const handleScroll = () => {
|
if (sentinelRef.current) {
|
||||||
if (!ticking) {
|
observer.observe(sentinelRef.current);
|
||||||
rafId = window.requestAnimationFrame(() => {
|
|
||||||
setShowScrollIndicator(window.scrollY < 50);
|
|
||||||
ticking = false;
|
|
||||||
});
|
|
||||||
ticking = true;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
return () => observer.disconnect();
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", handleScroll);
|
|
||||||
if (rafId) window.cancelAnimationFrame(rafId);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { text } = useTypingEffect({
|
const { text } = useTypingEffect({
|
||||||
@@ -43,6 +38,17 @@ export function Hero() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.hero}>
|
<section className={styles.hero}>
|
||||||
|
<div
|
||||||
|
ref={sentinelRef}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
height: "50px",
|
||||||
|
width: "1px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className={styles.backgroundContainer}>
|
<div className={styles.backgroundContainer}>
|
||||||
<GradientBlinds
|
<GradientBlinds
|
||||||
gradientColors={GRADIENT_COLORS}
|
gradientColors={GRADIENT_COLORS}
|
||||||
|
|||||||
Reference in New Issue
Block a user