Merge pull request #18 from ragusa-it/perf/navbar-debounce-resize-859095585893287738

 Optimize Navbar resize handler with debounce
This commit was merged in pull request #18.
This commit is contained in:
Melvin Ragusa
2026-01-24 11:20:04 +01:00
committed by GitHub
4 changed files with 142 additions and 2 deletions

View 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).`);
});
});

View File

@@ -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 = () => {

View 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
View 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>;
}