From 1f21b7bcb9e39cb2eaa399d4aa189679757ecb7a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 10:07:28 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Optimize=20Navbar=20resize=20handle?= =?UTF-8?q?r=20with=20debounce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💡 **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. --- src/components/layout/Navbar.perf.test.tsx | 73 ++++++++++++++++++++++ src/components/layout/Navbar.tsx | 11 +++- src/utils/debounce.test.ts | 30 +++++++++ src/utils/debounce.ts | 30 +++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/components/layout/Navbar.perf.test.tsx create mode 100644 src/utils/debounce.test.ts create mode 100644 src/utils/debounce.ts diff --git a/src/components/layout/Navbar.perf.test.tsx b/src/components/layout/Navbar.perf.test.tsx new file mode 100644 index 0000000..a352a04 --- /dev/null +++ b/src/components/layout/Navbar.perf.test.tsx @@ -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; + + 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( + + + + + + ); + + // 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).`); + }); +}); diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 286f0f8..26f9350 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -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 = () => { diff --git a/src/utils/debounce.test.ts b/src/utils/debounce.test.ts new file mode 100644 index 0000000..586c436 --- /dev/null +++ b/src/utils/debounce.test.ts @@ -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(); + }); +}); diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 0000000..6b62149 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,30 @@ +export interface DebouncedFunction any> { + (...args: Parameters): void; + cancel: () => void; +} + +export function debounce any>( + func: F, + wait: number +): DebouncedFunction { + let timeout: ReturnType | null = null; + + const debounced = function (...args: Parameters) { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + func(...args); + timeout = null; + }, wait); + }; + + debounced.cancel = () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + }; + + return debounced as DebouncedFunction; +} -- 2.49.1