⚡ Optimize Navbar resize handler with debounce
* 💡 **What:** Implemented a generic `debounce` utility and applied it to the `Navbar` component's window resize event listener (150ms delay). Added a `.cancel()` method to the debounce utility to prevent memory leaks/errors on unmount. * 🎯 **Why:** The `resize` event fires rapidly, causing `getBoundingClientRect` (a layout-thrashing operation) to run excessively, impacting performance. * 📊 **Measured Improvement:** In a benchmark test simulating 100 rapid resize events: * **Baseline:** 200 calls to `getBoundingClientRect`. * **Optimized:** 2 calls to `getBoundingClientRect`. * **Result:** ~99% reduction in layout calculations during rapid resizing. * Added `src/components/layout/Navbar.perf.test.tsx` to prevent regression.
This commit is contained in:
73
src/components/layout/Navbar.perf.test.tsx
Normal file
73
src/components/layout/Navbar.perf.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup, act } from '@testing-library/react';
|
||||
import { Navbar } from './Navbar';
|
||||
import { LanguageProvider } from '../../i18n';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
describe('Navbar Performance Optimization', () => {
|
||||
let getBoundingClientRectSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Spy on getBoundingClientRect
|
||||
getBoundingClientRectSpy = vi.fn(() => ({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 100,
|
||||
height: 20,
|
||||
bottom: 20,
|
||||
right: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {}
|
||||
}));
|
||||
Element.prototype.getBoundingClientRect = getBoundingClientRectSpy as any;
|
||||
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('debounces layout calculation on resize events', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LanguageProvider>
|
||||
<Navbar />
|
||||
</LanguageProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Initial render calls it twice (container + active link)
|
||||
expect(getBoundingClientRectSpy).toHaveBeenCalled();
|
||||
const initialCallCount = getBoundingClientRectSpy.mock.calls.length;
|
||||
|
||||
// Simulate 100 resize events rapidly
|
||||
const resizeEvents = 100;
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < resizeEvents; i++) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
});
|
||||
|
||||
// With debounce, the logic should NOT have run yet
|
||||
const callsAfterEvents = getBoundingClientRectSpy.mock.calls.length - initialCallCount;
|
||||
expect(callsAfterEvents).toBe(0);
|
||||
|
||||
// Advance timers to trigger the debounce
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
const callsAfterTimer = getBoundingClientRectSpy.mock.calls.length - initialCallCount;
|
||||
|
||||
// Should run exactly once (so 2 calls to getBoundingClientRect)
|
||||
expect(callsAfterTimer).toBe(2);
|
||||
|
||||
console.log(`Optimization: ${callsAfterTimer} calls to getBoundingClientRect for ${resizeEvents} resize events (expected 2).`);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { motion } from 'motion/react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { debounce } from '../../utils/debounce';
|
||||
import styles from './Navbar.module.css';
|
||||
|
||||
export function Navbar() {
|
||||
@@ -57,8 +58,14 @@ export function Navbar() {
|
||||
};
|
||||
|
||||
updateIndicatorPosition();
|
||||
window.addEventListener('resize', updateIndicatorPosition);
|
||||
return () => window.removeEventListener('resize', updateIndicatorPosition);
|
||||
|
||||
const debouncedUpdate = debounce(updateIndicatorPosition, 150);
|
||||
window.addEventListener('resize', debouncedUpdate);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', debouncedUpdate);
|
||||
debouncedUpdate.cancel();
|
||||
};
|
||||
}, [activeIndex, language]); // Recalculate when active link or language changes
|
||||
|
||||
const toggleLanguage = () => {
|
||||
|
||||
30
src/utils/debounce.test.ts
Normal file
30
src/utils/debounce.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { debounce } from './debounce';
|
||||
|
||||
describe('debounce', () => {
|
||||
it('calls the function after the wait time', () => {
|
||||
vi.useFakeTimers();
|
||||
const func = vi.fn();
|
||||
const debounced = debounce(func, 100);
|
||||
|
||||
debounced();
|
||||
expect(func).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels the pending execution', () => {
|
||||
vi.useFakeTimers();
|
||||
const func = vi.fn();
|
||||
const debounced = debounce(func, 100);
|
||||
|
||||
debounced();
|
||||
expect(func).not.toHaveBeenCalled();
|
||||
|
||||
debounced.cancel();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(func).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
30
src/utils/debounce.ts
Normal file
30
src/utils/debounce.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface DebouncedFunction<F extends (...args: any[]) => any> {
|
||||
(...args: Parameters<F>): void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function debounce<F extends (...args: any[]) => any>(
|
||||
func: F,
|
||||
wait: number
|
||||
): DebouncedFunction<F> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const debounced = function (...args: Parameters<F>) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
func(...args);
|
||||
timeout = null;
|
||||
}, wait);
|
||||
};
|
||||
|
||||
debounced.cancel = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced as DebouncedFunction<F>;
|
||||
}
|
||||
Reference in New Issue
Block a user