perf: throttle scroll event listener in Navbar

Implemented `requestAnimationFrame` throttling for the scroll event listener in `Navbar` to reduce the frequency of state updates and logic execution.

- Wrapped scroll handler in `requestAnimationFrame`.
- Added performance test `src/components/layout/Navbar.test.tsx` verifying logic runs once per frame instead of per event.
- Verified functional correctness of scroll state updates.
- Fixed `useTypingEffect` test environment.
This commit is contained in:
google-labs-jules[bot]
2026-01-22 08:24:10 +00:00
parent dfd5461485
commit 8592775485
3 changed files with 100 additions and 1 deletions

View File

@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, screen, cleanup, act } from '@testing-library/react';
import { Navbar } from './Navbar';
import styles from './Navbar.module.css';
import { LanguageProvider } from '../../i18n';
import { MemoryRouter } from 'react-router-dom';
describe('Navbar Performance', () => {
let scrollYGetterSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Mock window.scrollY
scrollYGetterSpy = vi.fn(() => 0);
Object.defineProperty(window, 'scrollY', {
configurable: true,
get: scrollYGetterSpy,
});
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
});
it('throttles scroll events using requestAnimationFrame', () => {
render(
<MemoryRouter>
<LanguageProvider>
<Navbar />
</LanguageProvider>
</MemoryRouter>
);
const scrollEvents = 50;
// Simulate 50 scroll events synchronously
for (let i = 0; i < scrollEvents; i++) {
fireEvent.scroll(window);
}
// Because of throttling, the expensive logic (accessing scrollY)
// should not have run immediately for every event.
expect(scrollYGetterSpy).toHaveBeenCalledTimes(0);
// Now advance timers to let RAF callback run
vi.runAllTimers();
// It should have run ONCE (or very few times)
expect(scrollYGetterSpy).toHaveBeenCalledTimes(1);
});
it('correctly updates style when scrolled', () => {
render(
<MemoryRouter>
<LanguageProvider>
<Navbar />
</LanguageProvider>
</MemoryRouter>
);
const header = screen.getByRole('banner');
// Initial state check
expect(header.className).not.toContain(styles.scrolled);
// Simulate scroll > 20
scrollYGetterSpy.mockReturnValue(50);
fireEvent.scroll(window);
// Run timers wrapped in act to flush React updates
act(() => {
vi.runAllTimers();
});
// Now it should be scrolled
expect(header.className).toContain(styles.scrolled);
// Scroll back to top
scrollYGetterSpy.mockReturnValue(0);
fireEvent.scroll(window);
act(() => {
vi.runAllTimers();
});
expect(header.className).not.toContain(styles.scrolled);
});
});

View File

@@ -23,8 +23,15 @@ export function Navbar() {
const activeIndex = navLinks.findIndex((link) => link.path === location.pathname);
useEffect(() => {
let ticking = false;
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
if (!ticking) {
window.requestAnimationFrame(() => {
setIsScrolled(window.scrollY > 20);
ticking = false;
});
ticking = true;
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);

View File

@@ -1,3 +1,4 @@
// @vitest-environment jsdom
import { renderHook, act } from '@testing-library/react';
import { useTypingEffect } from './useTypingEffect';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';