আপনার ব্যবহারকারীর ব্রাউজার ট্যাব ত্রিশ মিনিট পর ফ্রিজ হয়ে যায়। UI আটকে যেতে থাকে। তারপর ব্রাউজারটি 'out-of-memory' এরর দিয়ে ক্র্যাশ করে।

আপনি কম্পোনেন্ট কোড লাইন বাই লাইন রিভিউ করেন এবং কোনো ভুল খুঁজে পান না। React-এ মেমরি লিকের এই বিষয়টি অত্যন্ত বিরক্তিকর। বাগটি আপনার JSX সিনট্যাক্স বা আপনার hook লজিকে নেই। এটি আপনার কম্পোনেন্ট এবং ব্রাউজারের garbage collector-এর মধ্যবর্তী ফাঁকা জায়গায় অবস্থান করে। একটি অবাধ্য (stray) event listener বা একটি দীর্ঘস্থায়ী closure কালেক্টরকে মেমরি রিক্লেম করতে বাধা দেয়। আপনি একটি কম্পোনেন্ট আনমাউন্ট করেন, কিন্তু একটি মাত্র অবশিষ্টাংশ রেফারেন্স (lingering reference) পুরো ট্রি-টিকে heap-এ জীবিত রাখে। শুধু কোড পড়ে আপনি এই লিকগুলো খুঁজে পাবেন না। সমস্যাটি উপরিভাগের নিচে লুকিয়ে থাকে, যা চোখে পড়ে না এবং ইউনিট টেস্টেও ধরা পড়ে না।

কেন লিকগুলো চোখের সামনে থেকেও লুকিয়ে থাকে

V8-এর মতো JavaScript ইঞ্জিনগুলো স্বয়ংক্রিয়ভাবে মেমরি ম্যানেজ করে। যখন রুট থেকে কোনো অবজেক্টের দিকে কোনো রেফারেন্স পাথ থাকে না, তখন ইঞ্জিন সেই অবজেক্টটিকে 'garbage' হিসেবে চিহ্নিত করে এবং সেই জায়গাটি রিক্লেম করে। এই প্রক্রিয়াটি ততক্ষণ পর্যন্ত ভালো কাজ করে যতক্ষণ না একটি লুকানো রেফারেন্স আপনার প্রত্যাশার চেয়ে বেশি সময় টিকে থাকে।

React-এ বিপদটি প্রায়ই কম্পোনেন্ট এবং DOM-এর সীমানায় দেখা দেয়। আপনি হয়তো একটি মোডালের (modal) ভেতরে window-তে একটি resize লিসেনার যুক্ত করেছেন, অথবা একটি ড্যাশবোর্ড উইজেটে WebSocket-এ সাবস্ক্রাইব করেছেন। যখন ব্যবহারকারী মোডালটি বন্ধ করেন বা অন্য পেজে চলে যান, তখন কম্পোনেন্টটি আনমাউন্ট হয়। যদি সাবস্ক্রিপশনটি টিকে থাকে, তবে ইঞ্জিন গ্লোবাল window অবজেক্ট থেকে আপনার হ্যান্ডলার পর্যন্ত এবং আপনার হ্যান্ডলার থেকে কম্পোনেন্টের closure-এর মধ্যে একটি বৈধ রেফারেন্স দেখতে পায়। কম্পোনেন্ট, এর props, এর state এবং এর পুরো DOM নোড সাবট্রি মেমরিতে আটকে (pinned) থাকে। শত শত ইন্টারঅ্যাকশনের মাধ্যমে এই আটকে থাকা অবজেক্টগুলো জমা হতে থাকে। মেমরি ব্যবহার একটি sawtooth pattern-এ বাড়তে থাকে যা কখনোই পুরোপুরি নিচে নামে না।

এন্টারপ্রাইজ ড্যাশবোর্ড সমস্যা

এটি সবচেয়ে বেশি ঘটে এন্টারপ্রাইজ ড্যাশবোর্ডগুলোতে যেখানে ব্যবহারকারীরা ঘণ্টার পর ঘণ্টা একটি পেজেই থাকেন। মনিটরিং প্যানেল, অ্যানালিটিক্স ভিউ বা টিকেটিং সিস্টেমের কথা ভাবুন। একজন ব্যবহারকারী একটি ডিটেইল মোডাল খুলছেন, একটি বড় ডেটাসেট ফিল্টার করছেন অথবা একটি single-page app-এর মধ্যে ট্যাব পরিবর্তন করছেন। প্রতিটি আলাদা ইন্টারঅ্যাকশন ঠিকই মনে হয়। তবে সময়ের সাথে সাথে, অনাথ (orphaned) নোড এবং বিচ্ছিন্ন (detached) লিসেনার জমা হতে থাকে। অ্যাপ্লিকেশনটি ধীর হয়ে যায় কোনো একটি ব্যয়বহুল রেন্ডারের কারণে নয়, বরং heap এত বড় হয়ে যায় যে এটি ঘন ঘন এবং ব্যয়বহুল garbage collection পজ (pause) ট্রিগার করে।

আপনার useEffect হুক নিয়ে অনুমান করা বন্ধ করুন। লিক আছে কি না তা জানার একমাত্র উপায় হলো সরাসরি heap পরিমাপ করা। Chrome DevTools আপনাকে সেই ভিজিবিলিটি প্রদান করে।

Chrome DevTools দিয়ে লিক খোঁজা

আপনার একটি পুনরায় তৈরিযোগ্য (reproducible) সিকোয়েন্স এবং কয়েক মিনিট মনোযোগ প্রয়োজন। Chrome-এ আপনার অ্যাপ্লিকেশনটি খুলুন, DevTools চালু করুন এবং Memory ট্যাবে যান।

Record a baseline. Heap snapshot সিলেক্ট করুন এবং Take snapshot-এ ক্লিক করুন। এটি বর্তমানে JavaScript heap-এ থাকা প্রতিটি অবজেক্ট ক্যাপচার করে এবং আপনাকে শুরুর মেমরি দেখায়। এটি পেজটি তার প্রাথমিক আইডল (idle) অবস্থায় আসার পর করুন, প্রাথমিক লোড চলাকালীন নয়; যাতে আপনি শুধুমাত্র ব্যবহারকারীর কাজের কারণে হওয়া বৃদ্ধি পরিমাপ করতে পারেন।

Trigger the action. আপনি যে UI ইন্টারঅ্যাকশনটির কারণে লিক হচ্ছে বলে সন্দেহ করছেন, সেটি হুবহু করুন। একটি মোডাল খোলা এবং বন্ধ করা, একটি জটিল চার্ট টগল করা, অথবা একটি রুট পরিবর্তন করে আবার ফিরে আসা। শেষ করার পর, অ্যাপটিকে তার আসল ভিজ্যুয়াল অবস্থায় ফিরিয়ে আনুন। এই ধাপটি অত্যন্ত গুরুত্বপূর্ণ। আপনি চান UI-টি দেখতে ঠিক তেমন হোক যেমনটি baseline-এর সময় ছিল। যদি UI খালি দেখায় সত্ত্বেও heap বৃদ্ধি পায়, তবে আপনার কাছে লিকের শক্তিশালী প্রমাণ রয়েছে।

Force garbage collection. Memory ট্যাবে থাকা ট্র্যাশ ক্যান (trash can) আইকনে ক্লিক করুন। এটি একটি পূর্ণ GC সাইকেল ট্রিগার করে এবং সেই সাময়িক অবজেক্টগুলোকে পরিষ্কার করে যা পরবর্তী সংগ্রহের আগ পর্যন্ত বৈধভাবে টিকে ছিল। যা অবশিষ্ট থাকে তা হলো আসল লিক—এমন সব অবজেক্ট যা সংগ্রহ করা উচিত ছিল কিন্তু ভুলবশত রেফারেন্সের কারণে জীবিত ছিল।

Take a second snapshot. আবার Take snapshot-এ ক্লিক করুন। এখন আপনার কাছে একই UI অবস্থায় নেওয়া heap-এর দুটি ছবি বা স্ন্যাপশট রয়েছে।

Compare results. ভিউটি Summary থেকে Comparison-এ পরিবর্তন করুন। baseline হিসেবে আপনার প্রথম স্ন্যাপশট এবং compared snapshot হিসেবে দ্বিতীয়টি সেট করুন। Comparison ভিউ প্রতিটি অবজেক্ট ক্যাটাগরি তালিকাভুক্ত করে এবং আপনাকে delta দেখায়, যা হলো দুটি ক্যাপচারের মধ্যে অবজেক্ট সংখ্যার নিট পরিবর্তন।

Sort by Delta. যে ক্যাটাগরিগুলো উল্লেখযোগ্যভাবে বৃদ্ধি পেয়েছে সেগুলো খুঁজুন। বিশেষ করে Detached HTMLElement এবং React fiber নোডগুলোর দিকে নজর দিন। একটি detached HTML element হলো এমন একটি DOM নোড যা আর সক্রিয় ডকুমেন্ট ট্রির সাথে যুক্ত নেই, তবুও কোনো JavaScript রেফারেন্স এটিকে ধরে রেখেছে। এগুলো হলো নিশ্চিত প্রমাণ (smoking guns)। একটি মোডাল বন্ধ করার বা একটি কম্পোনেন্ট আনমাউন্ট করার পরে এগুলো থাকা উচিত নয়।

Reading the Retaining Path

আপনি যখন স্ন্যাপশটে একটি detached element নির্বাচন করেন, Chrome নিচের প্যানেলে retaining path দেখায়। এই পাথটি হলো রুট থেকে নির্বাচিত অবজেক্ট পর্যন্ত রেফারেন্সের একটি চেইন। এটি সাবধানে অনুসরণ করুন। আপনি প্রায়শই একটি event listener, একটি IntersectionObserver, একটি setInterval ID, অথবা আপনার কম্পোনেন্টের একটি নির্দিষ্ট লাইনের দিকে নির্দেশকারী একটি closure দেখতে পাবেন।

পরিচিত নামগুলো খুঁজুন। আপনি যদি আপনার codebase থেকে আসা কোনো ফাংশনের নামে window-এর সাথে সংযুক্ত একটি listener দেখতে পান, তবে আপনি anchorটি খুঁজে পেয়েছেন। যে অবজেক্টটি ওই listener-টি ধরে রেখেছে, সেটিই আপনার পুরো কম্পোনেন্টটিকে সচল রাখছে। মাঝে মাঝে এই চেইনটি কোনো third-party library-এর মধ্য দিয়ে যায়। সেক্ষেত্রে, লাইব্রেরিটি কি কোনো explicit teardown call আশা করছে যা আপনি cleanup function-এ কল করতে ভুলে গেছেন, তা পরীক্ষা করে দেখুন।

মূল কারণগুলো সমাধান করা

একবার আপনি retaining path শনাক্ত করতে পারলে, সমাধানটি সাধারণত যান্ত্রিক (mechanical) হয়, তবে এর জন্য টিমের মধ্যে শৃঙ্খলা প্রয়োজন।

cleanup function ব্যবহার করুন। যখনই আপনি window বা document-এ listener যোগ করবেন, তখনই useEffect-এ সর্বদা একটি cleanup function return করুন। যদি আপনার effect কোনো resize event-এর সাবস্ক্রাইব করে, তবে কম্পোনেন্ট unmount হওয়ার আগেই সেই সাবস্ক্রিপশনটি সরিয়ে ফেলুন। React যখন কম্পোনেন্টটি tear down করে, তখন cleanup চলে, যা আপনাকে বাহ্যিক সংযোগগুলো বিচ্ছিন্ন করার জন্য একটি নিশ্চিত hook প্রদান করে।

references স্থিতিশীল করুন। আপনার handler-গুলোকে useCallback-এর মাধ্যমে wrap করুন। এটি নিশ্চিত করে যে আপনি removeEventListener-এ ঠিক সেই একই function reference পাঠাচ্ছেন যা আপনি মূলত addEventListener-এ পাঠিয়েছিলেন। আপনি যদি window.addEventListener('resize', () => { ... })-এর মতো একটি inline function রেজিস্টার করেন এবং পরে অন্য একটি inline function দিয়ে সেটি রিমুভ করার চেষ্টা করেন, তবে রেফারেন্সগুলো মিলবে না। Listener-টি সংযুক্ত থেকে যায়। এর ভেতরের closure আপনার কম্পোনেন্ট state-কে সচল রাখে। একটি stable dependency array সহ useCallback এই identity mismatch রোধ করে।

global link বিচ্ছিন্ন করুন। মনে রাখবেন যে ব্রাউজারের event target অবজেক্টগুলো, যেমন window এবং document, পুরো পেজটি চলাকালীন সচল থাকে। তাদের থেকে আপনার কম্পোনেন্টে থাকা যেকোনো রেফারেন্স একটি global anchor হিসেবে কাজ করে। Listener সরিয়ে ফেললে সেই anchorটি ভেঙে যায় এবং পরবর্তী garbage collection cycle-এর সময় V8 engine-কে কম্পোনেন্ট state এবং DOM nodes মুছে ফেলার সুযোগ দেয়।

একটি মোডালের কথা চিন্তা করুন যা window width ট্র্যাক করে। cleanup ছাড়া, প্রতিবার ব্যবহারকারী যখন মোডালটি খুলবেন, একটি নতুন listener যুক্ত হবে। পুরনোগুলো কখনোই বিচ্ছিন্ন হয় না কারণ যে কম্পোনেন্ট instance-গুলোর সাথে সেগুলো যুক্ত ছিল সেগুলো নেই, তবুও ফাংশনগুলো ছিল anonymous এবং হারিয়ে গেছে। useCallback-এ wrap করা একটি named handler এবং removeEventListener কল করা একটি cleanup function ব্যবহার করলে এই চক্রটি সুন্দরভাবে শেষ হয়।

একটি বাস্তব শিক্ষা

React-এ memory leak খুব কমই কোনো স্পষ্ট error message দিয়ে নিজেকে প্রকাশ করে। এগুলো নিজেকে প্রকাশ করে এমন একটি tab-এর মাধ্যমে যা যত বেশি সময় খোলা থাকে, তত বেশি ভারী হতে থাকে। কোন hook দোষী তা নিয়ে অনুমান করে সময় নষ্ট করবেন না। Memory tab খুলুন, force garbage collection করুন এবং snapshots তুলনা করুন। heap profiler-কে আপনার সঠিক retaining path দেখাতে দিন। তারপর cleanup function লিখুন, callback reference স্থিতিশীল করুন এবং global link বিচ্ছিন্ন করুন। আপনার ব্যবহারকারীরা সরাসরি এই সমাধানটি লক্ষ্য করবেন না, তবে তারা লক্ষ্য করবেন যে দিন শেষে ড্যাশবোর্ডটি এখনও মসৃণভাবে চলছে।