From 67fb2e327010ade63db43f7a307b38835383a4b3 Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Wed, 17 Dec 2025 14:49:09 +0530 Subject: [PATCH] fix(memory): prevent memory leaks in IntersectionObserver and event listeners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates memory leaks that caused unbounded memory growth on component re-renders. Changes: 1. projects.js - IntersectionObserver cleanup - Added module-level projectObserver variable - Disconnect previous observer before creating new one - Unobserve elements after animation completes - Added cleanupProjects() export for manual cleanup 2. mobileMenu.js - Event listener cleanup - Store event handler functions for proper removal - Auto-cleanup on reinitialization - Added cleanup() export for manual cleanup - Remove document-level listener to prevent accumulation Memory Leak Impact: - Before: IntersectionObserver accumulated on each populateProjects() call - Before: Document click listener persisted indefinitely - After: Observers properly disconnected and freed - After: All event listeners can be cleanly removed Performance Impact: - Prevents unbounded memory growth - Reduces DOM traversal overhead (.contains() on every click) - Frees memory after animations complete - Enables proper cleanup in SPA scenarios 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/assets/js/components/projects.js | 44 ++++++++++++---- src/assets/js/utils/mobileMenu.js | 75 ++++++++++++++++++++-------- 2 files changed, 86 insertions(+), 33 deletions(-) diff --git a/src/assets/js/components/projects.js b/src/assets/js/components/projects.js index 95be364..f906f8f 100644 --- a/src/assets/js/components/projects.js +++ b/src/assets/js/components/projects.js @@ -2,6 +2,9 @@ * Projects section with modern design */ +// Module-level observer to prevent memory leaks +let projectObserver = null; + export const populateProjects = (projects) => { const projectsContainer = document.getElementById('projects-container'); if (!projectsContainer) return; @@ -29,9 +32,9 @@ export const populateProjects = (projects) => { ${ project.image ? ` - ${project.title} @@ -65,10 +68,10 @@ export const populateProjects = (projects) => { ${ project.link ? ` - @@ -81,8 +84,8 @@ export const populateProjects = (projects) => { ${ project.github ? ` - { // Add intersection observer for scroll animations const animateOnScroll = () => { + // Cleanup previous observer to prevent memory leaks + if (projectObserver) { + projectObserver.disconnect(); + projectObserver = null; + } + const elements = document.querySelectorAll('.fade-in-up'); - const observer = new IntersectionObserver( + projectObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.style.opacity = '1'; entry.target.style.transform = 'translateY(0)'; + // Unobserve after animation to free memory + projectObserver.unobserve(entry.target); } }); }, @@ -127,7 +138,7 @@ export const populateProjects = (projects) => { el.style.opacity = '0'; el.style.transform = 'translateY(20px)'; el.style.transition = 'opacity 0.6s ease-out, transform 0.6s ease-out'; - observer.observe(el); + projectObserver.observe(el); }); }; @@ -138,3 +149,14 @@ export const populateProjects = (projects) => { animateOnScroll(); } }; + +/** + * Cleanup function for SPA scenarios or component unmounting + * Call this to properly clean up the IntersectionObserver + */ +export const cleanupProjects = () => { + if (projectObserver) { + projectObserver.disconnect(); + projectObserver = null; + } +}; diff --git a/src/assets/js/utils/mobileMenu.js b/src/assets/js/utils/mobileMenu.js index f4066f0..04e2265 100644 --- a/src/assets/js/utils/mobileMenu.js +++ b/src/assets/js/utils/mobileMenu.js @@ -2,35 +2,66 @@ * Mobile menu functionality */ +// Store cleanup function to prevent memory leaks +let cleanupMobileMenu = null; + export const initMobileMenu = () => { const mobileMenuButton = document.getElementById('mobile-menu-button'); const mobileMenu = document.getElementById('mobile-menu'); - if (mobileMenuButton && mobileMenu) { - mobileMenuButton.addEventListener('click', () => { - mobileMenu.classList.toggle('hidden'); - }); + if (!mobileMenuButton || !mobileMenu) return; - // Close mobile menu when clicking outside - document.addEventListener('click', (event) => { - const isClickInsideMenu = mobileMenu.contains(event.target); - const isClickOnButton = mobileMenuButton.contains(event.target); - - if ( - !isClickInsideMenu && - !isClickOnButton && - !mobileMenu.classList.contains('hidden') - ) { - mobileMenu.classList.add('hidden'); - } - }); + // Cleanup previous listeners if reinitializing + if (cleanupMobileMenu) { + cleanupMobileMenu(); + } + + // Define event handlers so they can be removed later + const handleMenuToggle = () => { + mobileMenu.classList.toggle('hidden'); + }; + + const handleOutsideClick = (event) => { + const isClickInsideMenu = mobileMenu.contains(event.target); + const isClickOnButton = mobileMenuButton.contains(event.target); + + if (!isClickInsideMenu && !isClickOnButton && !mobileMenu.classList.contains('hidden')) { + mobileMenu.classList.add('hidden'); + } + }; - // Close mobile menu when a navigation link is clicked - const mobileNavLinks = mobileMenu.querySelectorAll('a'); + // Close mobile menu when a navigation link is clicked + const mobileNavLinks = mobileMenu.querySelectorAll('a'); + const handleLinkClick = () => { + mobileMenu.classList.add('hidden'); + }; + + // Attach event listeners + mobileMenuButton.addEventListener('click', handleMenuToggle); + document.addEventListener('click', handleOutsideClick); + mobileNavLinks.forEach((link) => { + link.addEventListener('click', handleLinkClick); + }); + + // Store cleanup function to remove all event listeners + cleanupMobileMenu = () => { + mobileMenuButton.removeEventListener('click', handleMenuToggle); + document.removeEventListener('click', handleOutsideClick); mobileNavLinks.forEach((link) => { - link.addEventListener('click', () => { - mobileMenu.classList.add('hidden'); - }); + link.removeEventListener('click', handleLinkClick); }); + cleanupMobileMenu = null; + }; + + return cleanupMobileMenu; +}; + +/** + * Cleanup function to remove mobile menu event listeners + * Call this when the mobile menu is no longer needed (e.g., SPA navigation) + */ +export const cleanup = () => { + if (cleanupMobileMenu) { + cleanupMobileMenu(); } };