तुमचे ॲप दहा मिनिटे व्यवस्थित चालते. त्यानंतर स्क्रोलिंगमध्ये अडथळा (sticky) येऊ लागतो. अर्ध्या तासानंतर टॅबचा वापर एक गिगाबाइटपर्यंत पोहोचतो. अखेरीस, 'out-of-memory' एररमुळे पेज बंद पडते आणि तुमच्याकडे दाखवण्यासाठी कोणताही 'stack trace' नसतो.

ही रेंडर परफॉर्मन्सची (render performance) समस्या नाही. React DevTools Profiler मध्ये काहीही विशेष दिसत नाही, कारण समस्या कंपोनंट्स किती वेळा रिड्रॉ (redraw) होतात यामध्ये नाही. समस्या अशी आहे की, कंपोनंट्स अनमाउंट (unmount) झाल्यानंतरही काय जिवंत राहते. JavaScript heap मधील कुठेतरी असलेला एखादा विखुरलेला संदर्भ (stray reference) DOM नोड्स, closures आणि state चा संपूर्ण वृक्ष (tree) अडकवून ठेवतो. ब्राउझर त्यातील काहीही रिकामे करू शकत नाही, त्यामुळे प्रोसेस कोलमडण्यापर्यंत मेमरी वाढतच जाते.

तुमचा सोर्स कोड वाचून ही लीक (leak) समजणार नाही. तुम्ही काय अनमाउंट झाले असे समजता आणि garbage collector ला प्रत्यक्षात काय दिसते, यामधील फरकात हा बग दडलेला असतो. V8 फक्त अशाच ऑब्जेक्ट्सना मुक्त करते ज्यांचे 'retaining paths' शून्य असतात. जर एखादा अनियंत्रित event listener, न cleared केलेला observer, किंवा दीर्घकाळ टिकणारा closure एखाद्या fiber किंवा DOM node कडे एक पॉइंटर देखील धरून ठेवत असेल, तर संपूर्ण component subtree जिवंत राहते. तुम्ही एखादा modal अनमाउंट करता, पण त्याचे detached nodes मेमरीमध्ये राहतात कारण window वरील एक listener अजूनही त्या modal मध्ये परिभाषित केलेल्या handler कडे निर्देश करते.

ही लीक कुठे आहे हे सिद्ध करण्यासाठी तुम्हाला एडिटरकडे नाही, तर heap कडे पाहण्याची गरज आहे.

Heap सत्य का सांगते

Chrome DevTools तुम्हाला garbage collector ला काय दिसते याचे थेट दर्शन घडवते. Memory टॅब 'heap snapshots' रेकॉर्ड करू शकतो: म्हणजेच JavaScript मेमरीमध्ये सध्या असलेल्या प्रत्येक ऑब्जेक्ट, DOM नोड आणि closure ची संपूर्ण यादी. दोन snapshots ची तुलना करून—एक संशयित लीक होण्यापूर्वीचा आणि एक नंतरचा—तुम्ही नेमके कोणते ऑब्जेक्ट्स नष्ट होण्यात अपयशी ठरले हे शोधू शकता.

हे केवळ अमूर्त सिद्धांत नाही. एक लीक झालेला React component हजारो detached HTMLElement ऑब्जेक्ट्स धरून ठेवू शकतो. हे ऑब्जेक्ट्स आता दृश्य दस्तऐवजाला (visible document) जोडलेले नसले तरी, JavaScript references त्यांना गोळा (collect) होण्यापासून रोखतात. ते comparison view मध्ये Detached HTMLElement या constructor नावामुळे दिसतात. जेव्हा तुम्ही त्यांना वाढत असल्याचे पाहता, तेव्हा तुम्हाला तुमची लीक सापडलेली असते.

Chrome DevTools वर्कफ्लो (Workflow)

स्वच्छ सुरुवात करा. अनावश्यक ब्राउझर टॅब्स बंद करा, अनावश्यक extensions अक्षम (disable) करा आणि तुमच्या ॲप्लिकेशनला स्थिर स्थितीत (steady state) येऊ द्या. Chrome DevTools उघडा, Memory टॅबवर जा आणि Heap snapshot निवडा. 'Take snapshot' वर क्लिक करा. हा बेसलाइन तुमचा सुरुवातीचा मेमरी फूटप्रिंट (memory footprint) टिपतो.

आता तुम्हाला ज्या युजर ॲक्शनचा संशय आहे तीच कृती करा. तो जड (heavy) modal उघडा आणि बंद करा. widget mount आणि unmount करा. एखाद्या route वर जा आणि परत या. एकदा का UI त्याच्या मूळ दृश्य स्थितीत परतले की, Memory टॅबमधील कचरापेटीच्या (trash can) आयकॉनवर क्लिक करा. यामुळे जागतिक garbage collection प्रक्रिया (global garbage collection pass) अनिवार्य होते. render cycle मधील तात्पुरते ऑब्जेक्ट्स निघून गेले पाहिजेत. जे काही उरते, ते लीक होण्यासाठी खरा उमेदवार आहे.

पुन्हा 'Take snapshot' वर क्लिक करा. आता तुमच्याकडे मेमरीचे दोन फोटो आहेत. View 'Summary' कडून 'Comparison' कडे बदला. Comparison scope पहिल्या snapshot वर सेट करा. हे टूल तुम्हाला फक्त दोन कॅप्चरमधील बदल दाखवेल, ज्यामुळे runtime मधील अनावश्यक माहिती (noise) निघून जाईल.

Delta नुसार सॉर्ट करा. वाढलेली ऑब्जेक्ट संख्या शोधा. Detached HTMLElement, Array, Function, किंवा तुमच्या स्वतःच्या कोडबेसमधील नावांकित class instances सारख्या constructors कडे विशेष लक्ष द्या. वाढता delta म्हणजे तुमच्या कृतीदरम्यान ऑब्जेक्ट्स तयार झाले होते आणि नंतर ते गोळा (collect) केले गेले नाहीत.

Retaining Path शोधणे

जेव्हा तुम्हाला लीक झालेला घटक (element) सापडतो, तेव्हा तो निवडा. खालचा पॅनेल 'retaining path' प्रदर्शित करतो: संदर्भांची एक साखळी (chain of references) जी स्पष्ट करते की हा ऑब्जेक्ट अजूनही जिवंत का आहे. ही साखळी एका detached div पासून सुरू होऊन React च्या अंतर्गत प्रॉपर्टीजमधून, एका closure मध्ये आणि शेवटी तुमच्या कंपोनंट्सपैकी एकाच्या आत नोंदणीकृत (registered) केलेल्या event listener वर संपू शकते. साखळीतील तो शेवटचा दुवा म्हणजे तुमचा लाईन नंबर (line number) आहे.

येथून तुम्ही निदानातून (diagnosis) मूळ कारणाकडे (root cause) वळता. जर retaining path window.addEventListener वर संपत असेल, तर तुम्हाला समजते की एक ग्लोबल listener तुमच्या कंपोनंटला अडकवून ठेवत आहे. जर ते IntersectionObserver instance वर संपत असेल, तर तुम्हाला समजते की एखादा observer अजूनही अशा नोडवर लक्ष ठेवून आहे ज्याला garbage collect झाले पाहिजे होते.

React मधील सामान्य कारणे

React मधील मेमरी लीक्स सहसा तीन प्रकारांत मोडतात.

अनाधारे (Orphaned) ग्लोबल लिसनर्स. एखादा useEffect स्क्रोल पोझिशन, की प्रेस किंवा रिसाईज इव्हेंट्स ट्रॅक करण्यासाठी window किंवा document ला हुक करतो. जर त्या effect ने removeEventListener कॉल करणारे cleanup function परत केले नाही, तर तो listener पेजच्या संपूर्ण आयुष्यासाठी जिवंत राहतो. कारण तो listener एक closure आहे, React ने कंपोनंट अनमाउंट केल्यानंतरही तो संपूर्ण component scope जिवंत ठेवतो.

अनक्लियर ऑब्झर्व्हर्स (Uncleared observers). IntersectionObserver आणि ResizeObserver शक्तिशाली आहेत, परंतु ते React च्या नियंत्रणाबाहेर नेटिव्ह संदर्भ (native references) तयार करतात. जर तुम्ही एखाद्या कंपोनंटमध्ये ऑब्झर्व्हर इन्स्टँशिएट (instantiate) केला आणि क्लीनअप फेजमध्ये disconnect() कॉल करायला विसरलात, तर ऑब्झर्व्हर टार्गेट DOM नोड पकडून ठेवतो, आणि तो DOM नोड React fibers, props आणि state पकडून ठेवतो.

क्लोजर ट्रॅप्स (Closure traps). जेव्हा तुम्ही एखाद्या कंपोनंटमध्ये फंक्शन डिफाइन करता आणि ते थर्ड-पार्टी लायब्ररी, ग्लोबल कॅशे किंवा अगदी setTimeout ला पास करता, तेव्हा ते फंक्शन त्याच्या लेक्सिकल स्कोपमधील (lexical scope) प्रत्येक व्हेरिएबलवर क्लोजर (close over) तयार करते. जर बाह्य मालकाने (external owner) ते फंक्शन टिकवून ठेवले, तर ते तुमच्या संपूर्ण कंपोनंट स्कोपलाही सोबत ठेवते.

खरोखर काम करणारे क्लीनअप पॅटर्न (Cleanup Patterns)

मेमरी लीक फिक्स करणे म्हणजे स्नॅपशॉटमध्ये सापडलेला प्रत्येक रिटेनिंग पाथ (retaining path) तोडणे होय.

useEffect मधून नेहमी क्लीनअप फंक्शन रिटर्न करा. जर तुम्ही इफेक्टमध्ये एखादा लिसनर (listener) जोडला असेल, तर तो तिथेच काढून टाका.

DOM किंवा window ला तुम्ही जोडत असलेल्या कोणत्याही हँडलरसाठी useCallback वापरा. याशिवाय, प्रत्येक रेंडर एक नवीन फंक्शन रेफरन्स तयार करते. जर तुम्ही एका रेफरन्ससह addEventListener कॉल केला आणि नंतर वेगळ्या रेफरन्ससह removeEventListener कॉल केला, तर ते काढणे (removal) शांतपणे अयशस्वी होते. मूळ लिसनर window वर कायमचा राहतो. useCallback रेफरन्स स्थिर ठेवते जेणेकरून 'add' आणि 'remove' अगदी जुळतील.

ऑब्झर्व्हर्सना देखील त्याच शिस्तीने हाताळा. ऑब्झर्व्हर इन्स्टन्स इफेक्टच्या आत ref किंवा लोकल व्हेरिएबलमध्ये स्टोअर करा. क्लीनअप फंक्शनमध्ये observer.disconnect() कॉल करा. कंपोनंट अनमाउंट (unmount) केल्यामुळे ऑब्झर्व्हर नष्ट होतो असे समजू नका. तसे होत नाही.

जर तुमचा कंपोनंट ग्लोबल नेमस्पेस किंवा सिंगलटन सर्व्हिसमध्ये काहीही पब्लिश करत असेल, तर अनमाउंट करताना ते रेफरन्स डिलीट करा. V8 इंजिन केवळ तेव्हाच मेमरी रिक्लेम (reclaim) करू शकते जेव्हा एखादा ऑब्जेक्ट खरोखरच अनरीचेबल (unreachable) असतो. window वर एखादा हुक किंवा मॉड्यूल-लेव्हल Map मध्ये एखादी एन्ट्री सोडल्यामुळे एक अदृश्य पूल तयार होतो जो हीप (heap) वाढत ठेवतो.

मुख्य निष्कर्ष (The Real Takeaway)

मेमरी लीक्समुळे तुमचे ॲप लगेच क्रॅश होत नाही. वापरकर्त्याच्या दीर्घ सत्रादरम्यान (long user sessions) ते एका वेळी एक डिटॅच्ड नोड जमा करत राहतात. याचे निराकरण लायब्ररी अपग्रेड करणे किंवा कंपायलर फ्लॅग वापरणे हे नाही. तर हीप स्नॅपशॉट्सद्वारे (heap snapshots) तुमच्या क्लीनअप लॉजिकची पडताळणी करण्याची सवय लावणे हे आहे.

एक बेसलाइन घ्या, संशयास्पद फ्लो ट्रिगर करा, फोर्स गार्बेज कलेक्शन (garbage collection) करा आणि तुलना करा. जर डेल्टा (delta) वाढताना दिसत असेल, तर रिटेनिंग पाथ तपासा, जो लिसनर किंवा ऑब्झर्व्हर अस्तित्वात नसावा तो शोधा आणि तो रेफरन्स तोडा. चाचणी पुन्हा करा. जेव्हा डेल्टा स्थिर राहतो, तेव्हा तुम्ही खरोखरच तो प्रश्न सोडवला आहे. तुमचे ॲप्लिकेशन रिस्पॉन्सिव्ह राहील आणि वापरकर्त्यांचे काम गोठलेल्या (frozen) ब्राउझर टॅबमुळे वाया जाणार नाही.