आपका ऐप दस मिनट तक ठीक चलता है। फिर स्क्रॉलिंग अटकने लगती है। आधे घंटे के बाद टैब एक गीगाबाइट तक पहुँच जाता है। अंततः, पेज 'out-of-memory' एरर के साथ क्रैश हो जाता है और आपके पास दिखाने के लिए कोई स्टैक ट्रेस (stack trace) नहीं होता।
यह रेंडर परफॉरमेंस (render performance) की समस्या नहीं है। React DevTools Profiler शांत दिखेगा क्योंकि समस्या यह नहीं है कि कंपोनेंट्स कितनी बार रिड्रॉ (redraw) होते हैं। समस्या यह है कि अनमाउंट (unmount) होने के बाद क्या जीवित रहता है। JavaScript heap में कहीं कोई भटकता हुआ रेफरेंस (stray reference) DOM नोड्स, क्लोजर (closures) और स्टेट (state) के पूरे ट्री को रोक देता है। ब्राउज़र इनमें से किसी को भी वापस नहीं ले सकता, इसलिए मेमोरी तब तक बढ़ती रहती है जब तक कि प्रोसेस क्रैश न हो जाए।
आपका सोर्स कोड पढ़ने से लीक का पता नहीं चलेगा। यह बग उस अंतर में छिपा होता है जो आपके द्वारा सोचे गए 'unmounted' और गार्बेज कलेक्टर (garbage collector) द्वारा वास्तव में देखे जाने वाले हिस्से के बीच होता है। V8 केवल उन्हीं ऑब्जेक्ट्स को मुक्त करता है जिनका 'retaining path' शून्य होता है। यदि कोई अनियंत्रित इवेंट लिसनर (event listener), कोई अनक्लियर ऑब्जर्वर (uncleared observer), या कोई लंबे समय तक चलने वाला क्लोजर किसी फाइबर (fiber) या DOM नोड के एक पॉइंटर को भी पकड़े रखता है, तो पूरा कंपोनेंट सबट्री (subtree) जीवित रहता है। आप एक मोडल (modal) को अनमाउंट करते हैं, लेकिन उसके डिटैच्ड नोड्स (detached nodes) मेमोरी में बने रहते हैं क्योंकि window पर लगा एक लिसनर अभी भी उस मोडल के अंदर परिभाषित हैंडलर की ओर इशारा करता है।
यह साबित करने के लिए कि लीक कहाँ है, आपको एडिटर के बजाय हीप (heap) को देखने की आवश्यकता है।
हीप (Heap) सच्चाई क्यों बताता है
Chrome DevTools आपको सीधे तौर पर वह दिखाता है जो गार्बेज कलेक्टर देखता है। Memory टैब हीप स्नैपशॉट (heap snapshots) रिकॉर्ड कर सकता है: JavaScript मेमोरी में वर्तमान में रखे गए प्रत्येक ऑब्जेक्ट, DOM नोड और क्लोजर की पूरी सूची। दो स्नैपशॉट की तुलना करके—एक संदिग्ध लीक से पहले और एक बाद में—आप सटीक रूप से पहचान सकते हैं कि कौन से ऑब्जेक्ट्स नष्ट होने में विफल रहे।
यह कोई अमूर्त सिद्धांत (abstract theory) नहीं है। एक अकेला लीक हुआ React कंपोनेंट हजारों डिटैच्ड HTMLElement ऑब्जेक्ट्स को रोके रख सकता है। वे ऑब्जेक्ट्स अब दृश्य दस्तावेज़ (visible document) से जुड़े नहीं हैं, लेकिन JavaScript रेफरेंस उन्हें एकत्र (collect) होने से रोकते हैं। वे तुलना दृश्य (comparison view) में Detached HTMLElement कंस्ट्रक्टर नाम के साथ दिखाई देते हैं। जब आप उन्हें बढ़ते हुए देखते हैं, तो आपको अपना लीक मिल गया है।
Chrome DevTools वर्कफ़्लो
शुरुआत साफ-सुथरे तरीके से करें। असंबंधित ब्राउज़र टैब बंद करें, असंबंधित एक्सटेंशन को अक्षम (disable) करें, और अपने एप्लिकेशन को एक स्थिर अवस्था (steady state) में आने दें। Chrome DevTools खोलें, Memory टैब पर स्विच करें, और Heap snapshot चुनें। Take snapshot पर क्लिक करें। यह बेसलाइन आपके शुरुआती मेमोरी फुटप्रिंट को कैप्चर करती है।
अब वही उपयोगकर्ता क्रिया (user action) करें जिस पर आपको संदेह है। उस भारी मोडल को खोलें और बंद करें। विजेट को माउंट और अनमाउंट करें। रूट पर जाएँ और वापस आएँ। एक बार जब UI अपनी मूल दृश्य स्थिति में वापस आ जाए, तो Memory टैब में ट्रैश कैन (trash can) आइकन पर क्लिक करें। यह एक ग्लोबल गार्बेज कलेक्शन पास को मजबूर करता है। रेंडर साइकिल के अस्थायी ऑब्जेक्ट्स हट जाने चाहिए। जो कुछ भी बचता है, वह लीक का वास्तविक संदिग्ध है।
फिर से Take snapshot पर क्लिक करें। अब आपके पास मेमोरी की दो तस्वीरें हैं। व्यू को Summary से बदलकर Comparison कर दें। तुलना का दायरा (comparison scope) पहले स्नैपशॉट पर सेट करें। टूल आपको केवल वही दिखाएगा जो दोनों कैप्चर के बीच बदला है, जिससे रनटाइम का शोर (noise) हट जाएगा।
Delta के आधार पर सॉर्ट करें। उन ऑब्जेक्ट काउंट्स को देखें जो बढ़े हैं। Detached HTMLElement, Array, Function, या अपने स्वयं के कोडबेस से नामित क्लास इंस्टेंस जैसे कंस्ट्रक्टर्स पर विशेष ध्यान दें। बढ़ता हुआ डेल्टा (delta) बताता है कि आपकी क्रिया के दौरान ऑब्जेक्ट्स बनाए गए थे और बाद में उन्हें एकत्र नहीं किया गया।
रिटेनिंग पाथ (Retaining Path) को ट्रैक करना
जब आप किसी लीक हुए एलिमेंट को पहचान लेते हैं, तो उसे चुनें। निचला पैनल रिटेनिंग पाथ (retaining path) दिखाता है: रेफरेंस की एक श्रृंखला जो बताती है कि यह ऑब्जेक्ट अभी भी जीवित क्यों है। यह श्रृंखला एक डिटैच्ड div से शुरू होकर React की आंतरिक प्रॉपर्टीज के माध्यम से, एक क्लोजर में जा सकती है, और अंततः आपके कंपोनेंट्स में से किसी एक के अंदर पंजीकृत इवेंट लिसनर पर समाप्त हो सकती है। श्रृंखला की वह अंतिम कड़ी आपका लाइन नंबर है।
यहीं से आप निदान (diagnosis) से मूल कारण (root cause) की ओर बढ़ते हैं। यदि रिटेनिंग पाथ window.addEventListener पर समाप्त होता है, तो आप जानते हैं कि एक ग्लोबल लिसनर आपके कंपोनेंट को बंधक बनाए हुए है। यदि यह IntersectionObserver इंस्टेंस पर समाप्त होता है, तो आप जानते हैं कि एक ऑब्जर्वर अभी भी उस नोड को देख रहा है जिसे गार्बेज कलेक्ट किया जाना चाहिए था।
React में सामान्य अपराधी
React में मेमोरी लीक आमतौर पर तीन पैटर्न में आते हैं।
अनाथ ग्लोबल लिसनर्स (Orphaned global listeners)। एक useEffect स्क्रॉल पोजीशन, की प्रेस (key presses), या रिसाइज इवेंट को ट्रैक करने के लिए window या document से जुड़ता है। यदि इफेक्ट एक क्लीनअप फंक्शन (cleanup function) वापस नहीं करता है जो removeEventListener को कॉल करता है, तो लिसनर पेज के जीवनकाल तक जीवित रहता है। क्योंकि लिसनर एक क्लोजर है, यह React द्वारा कंपोनेंट को अनमाउंट करने के बहुत बाद तक पूरे कंपोनेंट स्कोप को जीवित रखता है।
अनक्लियर्ड ऑब्जर्वर्स। IntersectionObserver और ResizeObserver शक्तिशाली हैं, लेकिन वे React के नियंत्रण से बाहर नेटिव रेफरेंस बनाते हैं। यदि आप किसी कंपोनेंट के अंदर एक ऑब्जर्वर को इंस्टेंटिएट करते हैं और क्लीनअप फेज में disconnect() कॉल करना भूल जाते हैं, तो ऑब्जर्वर टारगेट DOM नोड को होल्ड करके रखता है, और DOM नोड React fibers, props और state को होल्ड करके रखता है।
क्लोजर ट्रैप्स। जब आप किसी कंपोनेंट के अंदर एक फंक्शन डिफाइन करते हैं और उसे किसी थर्ड-पार्टी लाइब्रेरी, ग्लोबल कैश, या यहाँ तक कि setTimeout में पास करते हैं, तो वह फंक्शन अपने लेक्सिकल स्कोप के हर वेरिएबल को क्लोज कर लेता है। यदि बाहरी ओनर उस फंक्शन को बनाए रखता है, तो वह अपने साथ आपके पूरे कंपोनेंट स्कोप को भी बनाए रखता है।
क्लीनअप पैटर्न जो वास्तव में काम करते हैं
लीक को ठीक करने का अर्थ है स्नैपशॉट में पाए गए हर रिटेनिंग पाथ को काटना।
useEffect से हमेशा एक क्लीनअप फंक्शन रिटर्न करें। यदि आप इफेक्ट में कोई लिसनर जोड़ते हैं, तो उसे वहीं हटा दें।
DOM या window से जोड़े जाने वाले किसी भी हैंडलर के लिए useCallback का उपयोग करें। इसके बिना, हर रेंडर एक नया फंक्शन रेफरेंस बनाता है। यदि आप एक रेफरेंस के साथ addEventListener कॉल करते हैं और बाद में दूसरे रेफरेंस के साथ removeEventListener कॉल करते हैं, तो रिमूवल साइलेंटली फेल हो जाता है। ओरिजिनल लिसनर हमेशा window पर बना रहता है। useCallback रेफरेंस को स्टेबल रखता है ताकि add और remove बिल्कुल मैच करें।
ऑब्जर्वर्स को भी उसी अनुशासन के साथ हैंडल करें। ऑब्जर्वर इंस्टेंस को इफेक्ट के अंदर एक ref या लोकल वेरिएबल में स्टोर करें। क्लीनअप फंक्शन में, observer.disconnect() कॉल करें। यह न मानें कि कंपोनेंट को अनमाउंट करने से ऑब्जर्वर खत्म हो जाता है। ऐसा नहीं होता है।
यदि आपका कंपोनेंट किसी ग्लोबल नेमस्पेस या सिंगलटन सर्विस में कुछ भी पब्लिश करता है, तो अनमाउंट होने पर उन रेफरेंस को डिलीट कर दें। V8 इंजन मेमोरी को तभी रिक्लेम कर सकता है जब कोई ऑब्जेक्ट वास्तव में अनरीचेबल हो। window पर कोई हुक छोड़ना या मॉड्यूल-लेवल Map में कोई एंट्री छोड़ना एक अदृश्य पुल बना देता है जो हीप को बढ़ता रहता है।
असली निष्कर्ष
मेमोरी लीक आपके ऐप को तुरंत क्रैश नहीं करते हैं। लंबे यूजर सेशन के दौरान वे एक बार में एक डिटैच्ड नोड के रूप में जमा होते रहते हैं। इसका समाधान कोई लाइब्रेरी अपग्रेड या कंपाइलर फ्लैग नहीं है। यह हीप स्नैपशॉट के साथ अपने क्लीनअप लॉजिक को साबित करने की आदत है।
एक बेसलाइन लें, संदिग्ध फ्लो को ट्रिगर करें, गारबेज कलेक्शन को फोर्स करें, और तुलना करें। यदि डेल्टा में वृद्धि दिखती है, तो रिटेनिंग पाथ का निरीक्षण करें, उस लिसनर या ऑब्जर्वर को खोजें जो मौजूद नहीं होना चाहिए, और रेफरेंस को काट दें। टेस्ट को फिर से चलाएं। जब डेल्टा स्थिर रहे, तो समझें कि आपने वास्तव में इसे हल कर लिया है। आपका एप्लिकेशन रिस्पॉन्सिव बना रहेगा, और आपके यूजर्स का काम किसी फ्रीज हुए ब्राउज़र टैब के कारण बर्बाद नहीं होगा।
