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:
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 { Link, useLocation } from 'react-router-dom';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { debounce } from '../../utils/debounce';
|
||||||
import styles from './Navbar.module.css';
|
import styles from './Navbar.module.css';
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
@@ -57,8 +58,14 @@ export function Navbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
updateIndicatorPosition();
|
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
|
}, [activeIndex, language]); // Recalculate when active link or language changes
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
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