உங்கள் ஆப் பத்து நிமிடங்கள் நன்றாக இயங்கும். பிறகு ஸ்க்ரோல் (scroll) சிக்கிக்கொள்ளத் தொடங்கும். அரை மணி நேரத்திற்குப் பிறகு டேப் (tab) ஒரு ஜிகாபைட் அளவை எட்டும். இறுதியில், 'out-of-memory' பிழையுடன் பக்கம் செயலிழந்துவிடும், மேலும் உங்களிடம் அதற்கான ஸ்டேக் ட்ரேஸ் (stack trace) இருக்காது.

இது ஒரு ரெண்டர் பெர்பார்மன்ஸ் (render performance) பிரச்சனை அல்ல. காம்போனென்ட்கள் (components) எவ்வளவு அடிக்கடி மறுவரைவு (redraw) செய்யப்படுகின்றன என்பது இங்கு முக்கியமல்ல, எனவே React DevTools Profiler அமைதியாகத் தெரியலாம். பிரச்சனை என்னவென்றால், அவை அன்மவுண்ட் (unmount) செய்யப்பட்ட பிறகும் எது உயிர்ப்புடன் இருக்கிறது என்பதாகும். JavaScript heap-ல் எங்கோ இருக்கும் ஒரு stray reference, முழுமையான DOM nodes, closures மற்றும் state மரத்தையே பிடித்துக் கொள்கிறது. பிரவுசரால் எதையும் மீட்டெடுக்க முடியாது, எனவே மெமரி அளவு அதிகரித்து இறுதியில் செயல்முறை (process) முடங்குகிறது.

உங்கள் சோர்ஸ் கோடை (source code) படிப்பதன் மூலம் இந்த லீக் (leak) தெரியாது. நீங்கள் எது அன்மவுண்ட் ஆகிவிட்டது என்று நினைக்கிறீர்களோ, அதற்கும் garbage collector உண்மையில் காண்பதற்கும் இடையிலான இடைவெளியில்தான் இந்த பிழை உள்ளது. V8 எந்த ஆப்ஜெக்ட்களுக்கு (objects) 'retaining paths' இல்லையோ அவற்றை மட்டுமே விடுவிக்கும். ஒரு stray event listener, சுத்தம் செய்யப்படாத observer, அல்லது நீண்ட காலம் நீடிக்கும் closure ஆகியவை ஒரு fiber அல்லது DOM node-க்கு ஒரு போயண்டரைக் (pointer) கொண்டிருந்தால் கூட, முழு காம்போனென்ட் சப்-ட்ரீயும் (component subtree) அப்படியே இருக்கும். நீங்கள் ஒரு modal-ஐ அன்மவுண்ட் செய்கிறீர்கள், ஆனால் window-ல் உள்ள ஒரு listener இன்னும் அந்த modal-க்குள் வரையறுக்கப்பட்ட ஒரு handler-ஐக் குறிப்பதால், அதன் detached nodes மெமரியில் அப்படியே இருக்கும்.

லீக் எங்கே இருக்கிறது என்பதை நிரூபிக்க, நீங்கள் எடிட்டரைப் பார்க்கத் தேவையில்லை, heap-ஐப் பார்க்க வேண்டும்.

ஏன் Heap உண்மையைச் சொல்கிறது

Chrome DevTools, garbage collector காண்பதை நேரடியாகப் பார்க்க உங்களுக்கு ஒரு வாய்ப்பை வழங்குகிறது. Memory tab மூலம் heap snapshots-களைப் பதிவு செய்யலாம்: அதாவது JavaScript மெமரியில் தற்போது இருக்கும் ஒவ்வொரு object, DOM node மற்றும் closure ஆகியவற்றின் முழுமையான பட்டியல். ஒரு லீக் நடப்பதற்கு முன்னும் பின்னும் உள்ள இரண்டு snapshots-களை ஒப்பிடுவதன் மூலம், எந்த ஆப்ஜெக்ட்கள் நீக்கப்படாமல் இருந்தன என்பதைத் துல்லியமாகத் தனிமைப்படுத்தலாம்.

இது வெறும் தியரி மட்டுமல்ல. ஒரு லீக் ஆன React component ஆயிரக்கணக்கான detached HTMLElement ஆப்ஜெக்ட்களைத் தக்கவைத்துக் கொள்ள முடியும். அந்த ஆப்ஜெக்ட்கள் இப்போது கண்ணுக்குத் தெரியும் டாக்குமெண்ட்டுடன் இணைக்கப்படவில்லை, ஆனால் JavaScript references அவற்றைச் சேகரிக்க விடாமல் தடுக்கின்றன. அவை comparison view-வில் Detached HTMLElement என்ற constructor பெயருடன் தோன்றும். அவை பெருகிக்கொண்டிருப்பதை நீங்கள் காணும்போது, உங்கள் லீக்-ஐக் கண்டுபிடித்துவிட்டீர்கள் என்று அர்த்தம்.

Chrome DevTools பணிப்பாய்வு (Workflow)

சுத்தமாகத் தொடங்குங்கள். தேவையற்ற பிரவுசர் டேப்களை மூடிவிட்டு, தேவையற்ற extensions-களை முடக்கிவிட்டு, உங்கள் அப்ளிகேஷன் ஒரு நிலையான நிலைக்கு (steady state) வரவிடுங்கள். Chrome DevTools-ஐத் திறந்து, Memory tab-க்குச் சென்று, Heap snapshot என்பதைத் தேர்ந்தெடுக்கவும். Take snapshot என்பதைக் கிளிக் செய்யவும். இது உங்கள் ஆரம்ப மெமரி அளவை (baseline memory footprint) பதிவு செய்யும்.

இப்போது நீங்கள் சந்தேகப்படும் அதே பயனர் செயலைச் செய்யுங்கள். அந்த கனமான modal-ஐத் திறந்து மூடவும். widget-ஐ mount மற்றும் unmount செய்யவும். ஒரு route-க்குச் சென்று மீண்டும் பழைய இடத்திற்கு வரவும். UI அதன் அசல் காட்சி நிலைக்குத் திரும்பியதும், Memory tab-ல் உள்ள குப்பைத் தொட்டி (trash can) ஐகானைக் கிளிக் செய்யவும். இது ஒரு உலகளாவிய garbage collection செயல்பாட்டைத் தூண்டும். render cycle-லிருந்து வந்த தற்காலிக ஆப்ஜெக்ட்கள் நீக்கப்பட வேண்டும். எஞ்சியிருக்கும் அனைத்தும் லீக்கிற்கான உண்மையான காரணிகளாக இருக்கலாம்.

மீண்டும் Take snapshot என்பதைக் கிளிக் செய்யவும். இப்போது உங்களிடம் மெமரியின் இரண்டு புகைப்படங்கள் உள்ளன. View-வை Summary என்பதிலிருந்து Comparison என்று மாற்றவும். Comparison scope-ஐ முதல் snapshot-க்கு அமைக்கவும். runtime-ன் தேவையற்ற விஷயங்களைத் தவிர்த்து, அந்த இரண்டு snapshots-களுக்கும் இடையில் மாறியவற்றை மட்டும் இந்தத் கருவி உங்களுக்குக் காட்டும்.

Delta மூலம் வரிசைப்படுத்தவும் (Sort). அதிகரித்துள்ள ஆப்ஜெக்ட் எண்ணிக்கையைக் கவனியுங்கள். Detached HTMLElement, Array, Function அல்லது உங்கள் சொந்த codebase-லிருந்து வரும் named class instances போன்ற constructors-களுக்குச் சிறப்பு கவனம் செலுத்துங்கள். அதிகரித்த delta என்பது, உங்கள் செயலின் போது ஆப்ஜெக்ட்கள் உருவாக்கப்பட்டன, ஆனால் அவை பின்னர் சேகரிக்கப்படவில்லை என்பதைக் குறிக்கிறது.

Retaining Path-ஐக் கண்டறிதல்

நீங்கள் ஒரு லீக் ஆன எலிமெண்ட்டைக் கண்டறியும்போது, அதைத் தேர்ந்தெடுக்கவும். கீழ்ப்பக்க பேனல் (bottom panel) 'retaining path'-ஐக் காட்டும்: அதாவது இந்த ஆப்ஜெக்ட் ஏன் இன்னும் உயிர்ப்புடன் இருக்கிறது என்பதை விளக்கும் குறிப்புகளின் சங்கிலி (chain of references). அந்தச் சங்கிலி ஒரு detached div-லிருந்து தொடங்கி, React-ன் உள் பண்புகள் (internal properties) வழியாக, ஒரு closure-க்குள் சென்று, இறுதியில் உங்கள் காம்போனென்ட்களில் ஒன்றிற்குள் பதிவு செய்யப்பட்ட ஒரு event listener-இல் முடியலாம். அந்தச் சங்கிலியின் கடைசி இணைப்புதான் உங்கள் கோட் வரி எண் (line number).

இங்கிருந்துதான் நீங்கள் கண்டறிதலில் (diagnosis) இருந்து மூலக் காரணத்தை (root cause) நோக்கி நகர்கிறீர்கள். Retaining path window.addEventListener-இல் முடிவடைந்தால், ஒரு global listener உங்கள் காம்போனென்ட்டைப் பிடித்து வைத்திருக்கிறது என்று அர்த்தம். அது ஒரு IntersectionObserver instance-இல் முடிந்தால், garbage collect செய்யப்பட்டிருக்க வேண்டிய ஒரு node-ஐ ஒரு observer இன்னும் கவனித்துக் கொண்டிருக்கிறது என்று அர்த்தம்.

React-ல் உள்ள பொதுவான காரணிகள்

React-ல் மெமரி லீக்குகள் பொதுவாக மூன்று முறைகளில் நிகழ்கின்றன.

தனித்து விடப்பட்ட global listeners. ஒரு useEffect, scroll position, key presses அல்லது resize events-களைக் கண்காணிக்க window அல்லது document-உடன் இணைகிறது. அந்த effect, removeEventListener-ஐ அழைக்கும் ஒரு cleanup function-ஐத் திருப்பித் தரவில்லை என்றால், அந்த listener பக்கத்தின் ஆயுட்காலம் முழுவதும் இருக்கும். அந்த listener ஒரு closure என்பதால், React காம்போனென்ட்டை அன்மவுண்ட் செய்த நீண்ட காலத்திற்குப் பிறகும், அது முழு காம்போனென்ட் ஸ்கோப்பையும் (component scope) உயிர்ப்புடன் வைத்திருக்கும்.

சுத்திகரிக்கப்படாத observers. IntersectionObserver மற்றும் ResizeObserver ஆகியவை சக்திவாய்ந்தவை, ஆனால் அவை React-ன் கட்டுப்பாட்டிற்கு வெளியே native references-களை உருவாக்குகின்றன. நீங்கள் ஒரு component-க்குள் ஒரு observer-ஐ உருவாக்கிவிட்டு, cleanup phase-ல் disconnect()-ஐ அழைக்க மறந்தால், அந்த observer இலக்கு DOM node-ஐத் தக்கவைத்துக் கொள்ளும், மேலும் அந்த DOM node React fibers, props மற்றும் state-களைத் தக்கவைத்துக் கொள்ளும்.

Closure traps. நீங்கள் ஒரு component-க்குள் ஒரு function-ஐ வரையறுத்து, அதை ஒரு third-party library, global cache அல்லது setTimeout-க்கு அனுப்பும்போது, அந்த function அதன் lexical scope-ல் உள்ள ஒவ்வொரு variable-ஐயும் உள்ளடக்கிவிடும். அந்தப் புற உரிமையாளர் (external owner) அந்த function-ஐத் தக்கவைத்துக் கொண்டால், அது உங்கள் முழு component scope-ஐயும் தன்னுடன் சேர்த்துத் தக்கவைத்துக் கொள்ளும்.

உண்மையில் செயல்படும் Cleanup முறைகள்

ஒரு leak-ஐச் சரிசெய்வது என்பது, snapshot-ல் நீங்கள் கண்டறிந்த ஒவ்வொரு retaining path-ஐயும் துண்டிப்பதைக் குறிக்கும்.

useEffect-லிருந்து எப்போதும் ஒரு cleanup function-ஐத் திருப்பி அனுப்பவும். effect-ல் நீங்கள் ஒரு listener-ஐச் சேர்த்தால், அங்கேயே அதை நீக்கவும்.

DOM அல்லது window-வுடன் நீங்கள் இணைக்கும் எந்தவொரு handler-க்கும் useCallback-ஐப் பயன்படுத்தவும். அது இல்லையென்றால், ஒவ்வொரு render-மும் ஒரு புதிய function reference-ஐ உருவாக்கும். நீங்கள் ஒரு reference-ஐக் கொண்டு addEventListener-ஐ அழைத்துவிட்டு, பின்னர் வேறொரு reference-ஐக் கொண்டு removeEventListener-ஐ அழைத்தால், அந்த நீக்கம் அமைதியாகத் தோல்வியடையும். அசல் listener window-ல் என்றென்றும் இருக்கும். useCallback reference-ஐ நிலையாக வைத்திருப்பதால், add மற்றும் remove ஆகியவை சரியாகப் பொருந்தும்.

அதே ஒழுக்கத்துடன் observers-ஐக் கையாளவும். observer instance-ஐ effect-க்குள் ஒரு ref அல்லது local variable-ல் சேமிக்கவும். cleanup function-ல், observer.disconnect()-ஐ அழைக்கவும். component unmount ஆவது observer-ஐ அழித்துவிடும் என்று assumptions வைத்துக்கொள்ள வேண்டாம். அது அவ்வாறு செய்யாது.

உங்கள் component ஏதேனும் ஒன்றைப் global namespace அல்லது singleton service-க்கு வெளியிடுகிறதா என்றால், unmount ஆகும்போது அந்த references-களை நீக்கவும். ஒரு object உண்மையிலேயே சென்றடைய முடியாத நிலையில் (unreachable) இருந்தால் மட்டுமே V8 engine நினைவகத்தை (memory) மீட்டெடுக்க முடியும். window-ல் ஒரு hook-ஐ அல்லது module-level Map-ல் ஒரு entry-யை அப்படியே விடுவிப்பது, heap வளர்ந்து கொண்டே இருக்கக்கூடிய ஒரு கண்ணுக்குத் தெரியாத பாலத்தை உருவாக்கும்.

உண்மையான முடிவு

Memory leaks உங்கள் app-ஐ உடனடியாக முடக்கிவிடாது. நீண்ட பயனர் அமர்வுகளின் (user sessions) போது அவை ஒவ்வொன்றாகத் தனித்தனியாகத் தேக்கமடையும். இதற்கான தீர்வு ஒரு library upgrade அல்லது compiler flag அல்ல. அது heap snapshots மூலம் உங்கள் cleanup logic-ஐ நிரூபிக்கும் பழக்கமாகும்.

ஒரு baseline-ஐ எடுக்கவும், சந்தேகத்திற்குரிய flow-வை இயக்கவும், garbage collection-ஐத் தூண்டவும், பின்னர் ஒப்பிடவும். delta வளர்ச்சியைக் காட்டினால், retaining path-ஐ ஆய்வு செய்து, இருக்கக் கூடாத listener அல்லது observer-ஐக் கண்டறிந்து, அந்த reference-ஐத் துண்டிக்கவும். சோதனையை மீண்டும் இயக்கவும். delta சமமாக இருக்கும்போது, நீங்கள் அதை உண்மையில் சரிசெய்துவிட்டீர்கள் என்று அர்த்தம். உங்கள் application பதிலளிக்கக்கூடியதாக (responsive) இருக்கும், மேலும் உறைந்து போன browser tab காரணமாக உங்கள் பயனர்கள் தங்கள் வேலையை இழக்க மாட்டார்கள்.