
এই টিউটোরিয়ালে কী বানাবেন
এই প্রজেক্টে আমরা একটি ছোট কিন্তু বাস্তবসম্মত product catalog বানাব। Browser-এর fetch() ব্যবহার করে একই local server থেকে products.json পড়বে, তারপর পণ্যের নাম ও বর্ণনায় search করবে, category filter দেবে এবং ফলাফল card আকারে দেখাবে। এখানে কোনো external API, API key বা secret লাগবে না। Local fixture থাকার কারণে tutorial-এর output reproducible: একই ফাইল, একই request, একই sample data।
শুধু “fetch করে HTML-এ বসানো” যথেষ্ট নয়। Network request ধীর হতে পারে, HTTP 404 হতে পারে, server 200 status পাঠিয়েও ভাঙা JSON দিতে পারে, অথবা JSON-এর shape ভুল হতে পারে। তাই app-এ loading, empty, error এবং success—এই চার ধরনের state আলাদা করে দেখাব। response.ok পরীক্ষা করব, কারণ fetch() সাধারণ HTTP 404 বা 500-এ নিজে reject করে না। Request বেশি সময় নিলে AbortController দিয়ে cancellation করব এবং finally-তে controller reference পরিষ্কার করব, যাতে পরের load-এর সঙ্গে আগের request-এর state মিশে না যায়। প্রতিটি success ও error branch-এও current-controller guard থাকবে; ফলে আগের aborted request দেরিতে settle করলেও নতুন request-এর UI বদলাতে পারবে না।
এই উদাহরণের লক্ষ্য framework শেখানো নয়; browser platform API-র মৌলিক reasoning শেখানো। MDN-এর Using Fetch গাইডটি fetch() request, response এবং error handling-এর primary reference হিসেবে দেখা যেতে পারে।
শুরু করার আগে: HTTP server কেন দরকার
products.json-কে সরাসরি file:// URL দিয়ে খুললে অনেক browser origin policy-র কারণে fetch আটকাতে পারে। তাই একই directory-তে একটি HTTP server চালাতে হবে। Python থাকলে project folder-এ terminal খুলে এই command দিন:
python3 -m http.server 8000 --bind 0.0.0.0
তারপর http://localhost:8000/index.html খুলুন। এই tutorial-এ তিনটি ফাইল একই folder-এ থাকবে: index.html, products.json, এবং এই markdown tutorial। index.html-এর PRODUCTS_URL হলো ./products.json, তাই server root বদলালে path-ও সহজে বুঝতে পারবেন।
Data contract: JSON-কে বিশ্বাস না করে যাচাই
Fixture-টির top-level value একটি array। প্রতিটি item-এ id, title, description, category, price এবং image আছে। বাস্তবে server ভুল data পাঠাতে পারে, তাই শুধু JSON.parse() সফল হলেই data valid ধরে নেওয়া যাবে না। validateProducts() প্রথমে array কিনা দেখে, পরে প্রতিটি item object কিনা পরীক্ষা করে। id non-empty string, title ও description string, category অনুমোদিত string এবং price finite, non-negative number হতে হবে। Number.isFinite() ব্যবহার গুরুত্বপূর্ণ: NaN, Infinity বা -Infinity বৈধ দাম নয়।
Validation ব্যর্থ হলে UI-তে error state আসবে; আংশিক ভাঙা data silently বাদ দিয়ে user-কে ভুল catalog দেখানো হবে না। Production app-এ schema library ব্যবহার করা যেতে পারে, কিন্তু এই ছোট উদাহরণে plain JavaScript validation-ই যথেষ্ট স্বচ্ছ। Validation function-এর return shape-ও নির্দিষ্ট: সফল হলে normalized array, ব্যর্থ হলে Error। ফলে loadProducts() এবং rendering code আলাদা দায়িত্ব পালন করে।
Complete index.html
নিচের ফাইলটি সম্পূর্ণ। HTML-এর semantic structure-এ search input, category select, result count, status message এবং product grid আছে। CSS ছোট রাখা হয়েছে, যাতে মূল শেখার বিষয়—request lifecycle—চোখে থাকে। JavaScript অংশে event listener, state, validation, filtering এবং safe rendering একসঙ্গে দেখা যাবে।
<!doctype html>
<html lang="bn">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Local Product Catalog</title>
<style>
:root { font-family: system-ui, sans-serif; color: #172033; background: #f5f7fb; }
body { max-width: 980px; margin: 0 auto; padding: 2rem 1rem; }
.toolbar { display: grid; grid-template-columns: 1fr 180px; gap: .75rem; margin: 1rem 0; }
input, select { border: 1px solid #b8c2d4; border-radius: .5rem; padding: .7rem; font: inherit; }
#status { min-height: 1.5rem; margin: .75rem 0; }
.status-error { color: #a51d2d; }
.status-loading { color: #795000; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; }
.card { background: white; border: 1px solid #d9dfeb; border-radius: .75rem; padding: 1rem; box-shadow: 0 2px 8px #17203312; }
.card img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: .5rem; background: #e9edf5; }
.price { font-weight: 700; color: #075e54; }
.muted { color: #5e6a7e; }
@media (max-width: 600px) { .toolbar { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header>
<h1>Local Product Catalog</h1>
<p class="muted">Fetch API দিয়ে local JSON থেকে পণ্য খুঁজুন।</p>
</header>
<main>
<div class="toolbar" aria-label="Catalog filters">
<label>
Search
<input id="searchInput" type="search" placeholder="পণ্যের নাম বা বর্ণনা" autocomplete="off">
</label>
<label>
Category
<select id="categorySelect">
<option value="all">সব category</option>
</select>
</label>
</div>
<p id="status" role="status" aria-live="polite"></p>
<p id="resultCount" class="muted"></p>
<section id="productGrid" class="grid" aria-label="Product results"></section>
</main>
<script>
'use strict';
const PRODUCTS_URL = './products.json';
const REQUEST_TIMEOUT_MS = 5000;
const state = { products: [], controller: null, loadState: 'idle' };
const elements = {
search: document.querySelector('#searchInput'),
category: document.querySelector('#categorySelect'),
status: document.querySelector('#status'),
count: document.querySelector('#resultCount'),
grid: document.querySelector('#productGrid')
};
function setStatus(message = '', kind = '') {
elements.status.textContent = message;
elements.status.className = kind ? `status-${kind}` : '';
}
function validateProducts(value) {
if (!Array.isArray(value)) throw new Error('JSON-এর root একটি array নয়।');
const validCategories = new Set(['ল্যাপটপ', 'অডিও', 'অ্যাক্সেসরি']);
return value.map((item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw new Error(`Item ${index + 1} object নয়।`);
}
const textFields = ['id', 'title', 'description', 'image'];
for (const field of textFields) {
if (typeof item[field] !== 'string' || item[field].trim() === '') {
throw new Error(`Item ${index + 1}-এর ${field} বৈধ নয়।`);
}
}
if (!validCategories.has(item.category)) {
throw new Error(`Item ${index + 1}-এর category অজানা।`);
}
if (typeof item.price !== 'number' || !Number.isFinite(item.price) || item.price < 0) {
throw new Error(`Item ${index + 1}-এর price finite non-negative number নয়।`);
}
return {
id: item.id.trim(), title: item.title.trim(), description: item.description.trim(),
category: item.category, price: item.price, image: item.image.trim()
};
});
}
async function loadProducts() {
if (state.controller) state.controller.abort();
const controller = new AbortController();
state.controller = controller;
state.loadState = 'loading';
state.products = [];
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
setStatus('পণ্য লোড হচ্ছে…', 'loading');
elements.grid.replaceChildren();
elements.count.textContent = '';
try {
const response = await fetch(PRODUCTS_URL, {
headers: { Accept: 'application/json' },
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: products.json পাওয়া যায়নি।`);
}
const payload = await response.json();
if (state.controller !== controller) return;
state.products = validateProducts(payload);
state.loadState = 'success';
populateCategories(state.products);
render();
setStatus('');
} catch (error) {
if (state.controller !== controller) return;
state.loadState = 'error';
state.products = [];
if (error.name === 'AbortError') {
setStatus('Request বাতিল হয়েছে বা সময়সীমা পেরিয়েছে।', 'error');
} else {
setStatus(`লোড ব্যর্থ: ${error.message}`, 'error');
}
elements.grid.replaceChildren();
elements.count.textContent = '';
} finally {
clearTimeout(timeoutId);
if (state.controller === controller) state.controller = null;
}
}
function populateCategories(products) {
const categories = [...new Set(products.map(product => product.category))].sort();
elements.category.replaceChildren(new Option('সব category', 'all'));
for (const category of categories) {
elements.category.append(new Option(category, category));
}
}
function getFilteredProducts() {
const query = elements.search.value.trim().toLocaleLowerCase();
const category = elements.category.value;
return state.products.filter(product => {
const searchable = `${product.title} ${product.description}`.toLocaleLowerCase();
return (!query || searchable.includes(query)) &&
(category === 'all' || product.category === category);
});
}
function render() {
if (state.loadState !== 'success') {
elements.grid.replaceChildren();
elements.count.textContent = '';
return;
}
const products = getFilteredProducts();
elements.grid.replaceChildren();
elements.count.textContent = `${products.length}টি পণ্য দেখানো হচ্ছে`;
if (products.length === 0) {
setStatus('এই filter-এ কোনো পণ্য পাওয়া যায়নি।', 'loading');
return;
}
setStatus('');
for (const product of products) {
const card = document.createElement('article');
card.className = 'card';
const image = document.createElement('img');
image.src = product.image;
image.alt = product.title;
const title = document.createElement('h2');
title.textContent = product.title;
const description = document.createElement('p');
description.textContent = product.description;
const category = document.createElement('p');
category.className = 'muted';
category.textContent = `Category: ${product.category}`;
const price = document.createElement('p');
price.className = 'price';
price.textContent = `৳${product.price.toLocaleString('bn-BD')}`;
card.append(image, title, description, category, price);
elements.grid.append(card);
}
}
elements.search.addEventListener('input', render);
elements.category.addEventListener('change', render);
loadProducts();
</script>
</body>
</html>
Request lifecycle বুঝে নেওয়া
loadProducts() শুরুতেই আগের controller থাকলে abort() করে। এই আচরণ ভবিষ্যতে reload button বা route change যোগ করলে কাজে লাগবে। নতুন AbortController-এর signal fetch-এ পাঠানো হয়েছে। নতুন load শুরু হওয়ার সঙ্গে সঙ্গে loadState-কে loading এবং products-কে খালি করা হয়; grid ও count-ও পরিষ্কার হয়। ফলে loading চলার সময় পুরনো card দেখা যায় না। setTimeout() নির্দিষ্ট সময় পরে controller abort করে; ফলে server উত্তর না দিলে UI অনন্ত loading-এ আটকে থাকে না। Timeout id-টি finally-তে clear করা হয়। সফলতা, HTTP error, JSON parse error বা abort—যে পথই হোক cleanup চলবে।
সবচেয়ে গুরুত্বপূর্ণ race guard হলো state.controller !== controller পরীক্ষা। await fetch() বা await response.json()-এর সময় নতুন loadProducts() শুরু হতে পারে। তখন পুরনো request-এর promise পরে settle করলেও তার success branch state.products, category options, grid বা status বদলাতে পারবে না। Error branch-এও একই guard আছে; শুধু finally-তে cleanup করার জন্য current-controller check যথেষ্ট নয়। Success-এর আগে guard রাখার ফলে late response নতুন request-এর data overwrite করে না, আর error-এর আগে guard রাখার ফলে late abort বা network error নতুন loading/success message মুছে দেয় না।
Failure হলে loadState-কে error এবং products-কে আবার খালি করা হয়। তাই failed reload-এর পরে search input-এ typing করলে render() পুরনো array থেকে card বানাতে পারে না। render() কেবল loadState === 'success' হলে filter চালায়; loading বা error অবস্থায় grid ও count খালি রেখেই বর্তমান status message অক্ষুণ্ণ রাখে। Controller-টি এখনও বর্তমান request-এর controller কিনা যাচাই করে তবেই finally-তে state থেকে null করা হয়েছে; এটি cleanup race কমায়, কিন্তু success/error guard-ই UI correctness-এর মূল সুরক্ষা।
response.ok হলো status code 200–299 হলে true। 404 বা 500 response network-level exception নয়, তাই আলাদা করে throw new Error() করেছি। এরপর response.json() parse failure হলে সেই exception error branch-এ যাবে। এই দুই স্তর আলাদা রাখলে debugging সহজ: status সমস্যা নাকি payload সমস্যা, message-এ বোঝা যায়।
Safe DOM rendering কেন জরুরি
Server বা fixture-এর text কখনও innerHTML-এ string interpolation করে বসানো হয়নি। Title, description, category এবং price-এর জন্য textContent ব্যবহার করেছি। Image-এর src এবং alt-ও DOM property দিয়ে বসানো হয়েছে। textContent HTML tag হিসেবে text interpret করে না, তাই product title-এ <script>-এর মতো input থাকলেও তা markup হয়ে execute হবে না। বাস্তব untrusted image URL-এর ক্ষেত্রে allowlist বা trusted proxy আরও ভালো প্রতিরক্ষা; এই fixture-এ static placeholder URL দেওয়া আছে।
replaceChildren() পুরনো card সরিয়ে নতুন card বসায়, ফলে stale result থেকে যায় না। প্রতিটি render-এ নতুন DOM node তৈরি হওয়ায় search filter-এর output আগের output-এর সঙ্গে মিশে যায় না। User-এর query-কে lowercase করে title এবং description-এর সঙ্গে তুলনা করা হয়েছে; category filter exact match রাখে।
Empty state এবং error state-এর পার্থক্য
দুটি অবস্থা এক নয়। Network বা validation ব্যর্থ হলে error message দেখাই; request সফল হলেও filter-এর সঙ্গে কোনো পণ্য না মিললে empty message দেখাই। state.products empty array হলে initial fixture-ও বৈধ হতে পারে—তখন load সফল এবং result count শূন্য। এই উদাহরণে filter-empty message একই status area-তে আসে, কিন্তু চাইলে আলাদা data-state attribute দিয়ে ডিজাইন আরও পরিষ্কার করা যায়। Loading-এর সময় grid খালি করা হয়েছে, যাতে user পুরনো data-কে নতুন data ভেবে না নেয়। Failure-এর সময়ও products খালি করা এবং render gating জরুরি: নইলে failed reload-এর পরে search বা category event পুরনো সফল data আবার দেখিয়ে error state আড়াল করতে পারে।
Test cases: ইচ্ছাকৃতভাবে failure ঘটান
শুধু happy path পরীক্ষা করলে network code-এর আসল দুর্বলতা ধরা পড়ে না। নিচের test matrix হাতে বা DevTools Network panel দিয়ে চালান। প্রতিবার পরিবর্তনের পর page refresh করুন।
| Test | কীভাবে simulate করবেন | প্রত্যাশিত ফল |
|---|---|---|
| HTTP 404 | PRODUCTS_URL-কে ./missing-products.json করুন |
HTTP 404 error, grid ফাঁকা |
| Invalid JSON | products.json-এর content সাময়িকভাবে { broken করুন |
JSON parse error branch, success render নয় |
| Slow response | আলাদা test server-এ response 5 সেকেন্ডের বেশি delay করুন, অথবা DevTools throttling ব্যবহার করুন | AbortError-সদৃশ timeout message, loading চিরস্থায়ী নয় |
| Empty data | fixture-এর content [] করুন |
request সফল, result count 0; error নয় |
| Invalid shape | price-কে -5, null, "99" বা Infinity-সদৃশ invalid value করুন |
validation error, unsafe card render নয় |
| Search no match | zzzz লিখুন |
empty/filter message এবং 0 result |
| Category filter | অডিও নির্বাচন করুন |
শুধু audio category-র item |
Slow-response test-এর জন্য production code বদলে timeout বাড়াবেন না; বরং test server-এ delay দিন। Browser-এর Network throttling response ধীর করার ভালো উপায়। AbortController cancellation ঘটার পর finally-র cleanup হয়েছে কিনা দেখতে console-এ breakpoint বসাতে পারেন। Invalid JSON test শেষে আসল fixture ফিরিয়ে রাখুন, নইলে পরের reload-এ error দেখা স্বাভাবিক।
উন্নত করার সম্ভাব্য দিক
Catalog বড় হলে প্রতিটি keypress-এ render না করে debounce ব্যবহার করা যায়। Pagination, sort এবং price range যোগ করার আগে state model স্পষ্ট রাখা ভালো। একই request বারবার না পাঠাতে cache করা যায়, তবে cache invalidation-এর নিয়ম লিখে রাখতে হবে। Accessible status-এর জন্য aria-live রাখা হয়েছে; আরও ভালো UX-এ loading skeleton, retry button এবং focus management যোগ করা যায়। Image failure-এর জন্য fallback বা lazy loading ব্যবহার করা যায়।
Security-এর দিক থেকে local fixture-এ secret না রাখাই নিয়ম। কোনো API token frontend source code-এ রাখা নিরাপদ নয়, কারণ browser user তা দেখতে পারে। External API প্রয়োজন হলে server-side proxy, authentication policy এবং CORS configuration আলাদা বিষয় হবে। এই tutorial ইচ্ছাকৃতভাবে সেই dependency বাদ দিয়েছে, যাতে Fetch API-র request lifecycle পরিষ্কার থাকে।
শেষবার যাচাই হিসেবে তিনটি জিনিস দেখুন: (১) server HTTP-তে চলছে, (২) successful fixture array validation পাস করছে, (৩) error ও timeout-এর পরে নতুন successful load করলে পুরনো error message এবং timer আটকে নেই। এই তিনটি ঠিক থাকলে ছোট catalog app-টি নির্ভরযোগ্য ভিত্তি হিসেবে কাজ করবে।
সম্পূর্ণ products.json
নিচের fixture-এ চারটি coherent sample product আছে। সব price finite এবং non-negative number, category validator-এর allowlist-এর মধ্যে, এবং image URL-গুলো public placeholder—কোনো external product API call নয়।
[
{
"id": "p-101",
"title": "নদী ল্যাপটপ স্ট্যান্ড",
"description": "অ্যালুমিনিয়াম stand, দীর্ঘ coding session-এ ergonomic উচ্চতা দেয়।",
"category": "অ্যাক্সেসরি",
"price": 1490,
"image": "data:image/svg+xml,%3Csvg xmlns=http://www.w3.org/2000/svg width=640 height=360%3E%3Crect width=100% height=100% fill=%23dbeafe/%3E%3Ctext x=50%25 y=50%25 text-anchor=middle dominant-baseline=middle font-size=32%3ELaptop Stand%3C/text%3E%3C/svg%3E"
},
{
"id": "p-102",
"title": "মেঘ Mechanical Keyboard",
"description": "Compact wireless keyboard, নরম tactile switch এবং USB-C charging।",
"category": "অ্যাক্সেসরি",
"price": 4250,
"image": "data:image/svg+xml,%3Csvg xmlns=http://www.w3.org/2000/svg width=640 height=360%3E%3Crect width=100% height=100% fill=%23dcfce7/%3E%3Ctext x=50%25 y=50%25 text-anchor=middle dominant-baseline=middle font-size=32%3EKeyboard%3C/text%3E%3C/svg%3E"
},
{
"id": "p-103",
"title": "বন Noise-Cancel Headphones",
"description": "Focus mode-এর জন্য over-ear audio এবং 30 ঘণ্টার battery।",
"category": "অডিও",
"price": 6890,
"image": "data:image/svg+xml,%3Csvg xmlns=http://www.w3.org/2000/svg width=640 height=360%3E%3Crect width=100%25 height=100%25 fill=%23fef3c7/%3E%3Ctext x=50%25 y=50%25 text-anchor=middle dominant-baseline=middle font-size=32%3EHeadphones%3C/text%3E%3C/svg%3E"
},
{
"id": "p-104",
"title": "প্রভাত Ultrabook 14",
"description": "হালকা 14-inch laptop, documentation ও frontend কাজের জন্য উপযোগী।",
"category": "ল্যাপটপ",
"price": 78500,
"image": "data:image/svg+xml,%3Csvg xmlns=http://www.w3.org/2000/svg width=640 height=360%3E%3Crect width=100%25 height=100%25 fill=%23fce7f3/%3E%3Ctext x=50%25 y=50%25 text-anchor=middle dominant-baseline=middle font-size=32%3EUltrabook%3C/text%3E%3C/svg%3E"
}
]
State design আরও একটু গভীরভাবে
এই app-এর state ইচ্ছাকৃতভাবে ছোট: products-এ সর্বশেষ বৈধ array থাকে, controller-এ চলমান request-এর cancellation handle থাকে এবং loadState-এ idle, loading, success বা error থাকে। UI state-এর কিছু অংশ DOM-এর status text, grid এবং count-এ দেখা যায়, কিন্তু render gating-এর জন্য load state-টি explicit রাখা হয়েছে। UI state আলাদা variable হিসেবে না রেখে DOM-এর status text, grid এবং count থেকে বোঝানো হয়েছে। ছোট demo-তে এই সরলতা সুবিধাজনক, কারণ data পরিবর্তনের একমাত্র জায়গা loadProducts() এবং view update-এর একমাত্র entry point render()। বড় app-এ অবশ্য status-এর মান idle, loading, success, empty, error হিসেবে model করে reducer বা state machine ব্যবহার করা যেতে পারে। তখন একই সঙ্গে দুইটি request শুরু হলেও কোন response সর্বশেষ, সেটি request id দিয়ে যাচাই করা সহজ হবে।
Search input-এ প্রতিটি input event-এ synchronous filter চলছে। এখানে data মাত্র চারটি item, তাই debounce না থাকলেও অনুভূত performance ভালো। হাজার item হলে query বদলানোর 150–300 মিলিসেকেন্ড পরে filter চালানো যায়। তবে debounce যোগ করার আগে correctness পরীক্ষা করুন: user দ্রুত টাইপ করলে timer clear হচ্ছে কি না, component সরলে listener সরছে কি না, এবং empty query-তে সব item ফিরছে কি না। Search result server-side হলে query encode করে URL বানাতে হবে এবং response-এর validation একইভাবে রাখতে হবে; client-side filter হওয়ায় এই sample-এ extra request লাগে না।
toLocaleLowerCase() এবং toLocaleString('bn-BD') ব্যবহার করে বাংলা interface-এর সঙ্গে display মানানসই করা হয়েছে। Locale formatting কেবল presentation; price-এর numeric validation তার আগেই হয়েছে। Currency symbol যোগ করার সময় numeric value-কে string হিসেবে parse করে আবার গণনা করা হয়নি। এই separation গুরুত্বপূর্ণ: raw data model numeric থাকবে, formatted text শুধু rendering layer-এ তৈরি হবে। ভবিষ্যতে sorting যোগ করলে formatted ৳৭,৮৫০ string নয়, raw price দিয়ে sort করবেন।
Debugging checklist
প্রথমে DevTools-এর Network tab খুলে products.json request-এর URL, status code, response body এবং timing দেখুন। Status 200 হলেও যদি validation error আসে, response-টি array কিনা এবং field-এর type ঠিক কিনা দেখুন। Status 304 বা cached response সাধারণত response.ok-এর জন্য সফল; cache আচরণ বদলাতে চাইলে server header বা request option নিয়ে আলাদা সিদ্ধান্ত দরকার। Console-এ raw payload ছাপার আগে মনে রাখুন, production response-এ ব্যক্তিগত তথ্য থাকতে পারে। এই fixture public sample, কিন্তু logging policy বাস্তব data-র ক্ষেত্রে কঠোর হওয়া উচিত।
404 দেখলে path-এর আপেক্ষিক ভিত্তি যাচাই করুন। ./products.json মানে current document-এর directory; server root-এ অন্য directory serve করলে browser-এর address bar-এ file-টি সরাসরি খুলে দেখা যায়। CORS error হলে প্রথমে HTTP server দিয়ে page এবং JSON একই origin-এ চলছে কি না নিশ্চিত করুন। file:// page, ভিন্ন port, বা third-party endpoint মেশালে CORS policy প্রাসঙ্গিক হয়ে যায়। Error message-এ শুধু generic “failed” না রেখে status এবং validation context রাখা debugging-এর সময় অনেক সময় বাঁচায়।
Abort error-কে সবসময় server failure বলা ঠিক নয়। User নিজে নতুন load শুরু করলে আগের request বাতিল হতে পারে; timeout-ও intentional cancellation। এই sample-এ উভয়কেই একই friendly message দেওয়া হয়েছে। Production analytics-এ timeout, user cancellation এবং navigation cancellation আলাদা event হিসেবে track করা যেতে পারে। finally না থাকলে timeout timer পরে আগের controller abort করতে পারে, অথবা memory-তে stale reference পড়ে থাকতে পারে। তাই cleanup request lifecycle-এর optional polish নয়, মূল correctness-এর অংশ।
Accessibility ও content quality
<label>-এর ভেতরে input এবং select রাখা হয়েছে, তাই visible label ও control-এর সম্পর্ক স্বয়ংক্রিয়ভাবে তৈরি হয়েছে। Status element-এ role="status" এবং aria-live="polite" আছে; result বদলালে screen reader ব্যবহারকারীকে update জানানো সম্ভব। Card-এ heading ব্যবহার করায় document structure অর্থপূর্ণ থাকে। Empty state শুধু রঙ দিয়ে বোঝানো হয়নি—স্পষ্ট text-ও আছে। Error text-এর সঙ্গে retry button যোগ করলে keyboard user-কে আবার address bar-এ যেতে হবে না; button-টি loadProducts call করবে এবং focus প্রয়োজনে status-এ নেওয়া যেতে পারে।
Image-এর alt product title থেকে তৈরি, কারণ placeholder image নিজে content নয়। যদি কোনো product-এর image optional হয়, validation-এ image না থাকলে card-এ image বাদ দিতে হবে অথবা নির্ভরযোগ্য local fallback দিতে হবে। User-generated title দীর্ঘ হলে CSS wrapping ও visual overflow পরীক্ষা করুন। Text content নিরাপদে বসালেও malicious URL-এর ক্ষেত্রে protocol allowlist আলাদা করে দরকার; javascript: URL image বা link context-এ গ্রহণ করা উচিত নয়।
Fixture বদলে test করার নিয়ম
একটি test run শেষে ভালো fixture ফিরিয়ে আনার অভ্যাস রাখুন। Empty-data test-এ [] লিখে page refresh করুন এবং নিশ্চিত করুন status error নয়। Invalid-shape test-এ price-কে string করলে schema validation থামবে; -1 দিলে non-negative rule ধরা পড়বে; null দিলে number check ব্যর্থ হবে। Root object লিখলে array check ব্যর্থ হবে। এই বিভিন্ন failure আলাদা validation message তৈরি করছে, তাই bug report-এ কোন contract ভাঙল তা বলা সহজ।
Slow response-এর জন্য Python-এর ছোট test server বা DevTools throttling যথেষ্ট। বাস্তব timeout test-এ পাঁচ সেকেন্ড অপেক্ষা করতে হবে; সেই সময় loading message দেখা উচিত এবং abort হওয়ার পর grid ফাঁকা থাকা উচিত। এরপর মূল products.json রেখে reload করলে successful state ফিরে আসতে হবে। একই সময়ে দ্রুত পরপর দুইবার reload করে দেখুন: প্রথম request দেরিতে settle করলেও দ্বিতীয় request-এর loading, success বা error status বদলানো যাবে না। এই recovery test খুব গুরুত্বপূর্ণ, কারণ error দেখানোর পরে app যেন permanently dead না হয়ে যায়। Retry button যোগ করলে একই cleanup rules বজায় রাখবেন এবং পুরনো timer কখনও পুনর্ব্যবহার করবেন না।
Deploy করার আগে শেষ যাচাই
Deploy-এর আগে সব resource path case-sensitive server-এ পরীক্ষা করুন; local macOS বা Windows-এ Products.json আর products.json একই মনে হলেও Linux hosting-এ আলাদা। JSON-এ trailing comma রাখবেন না। HTML validator, keyboard-only navigation এবং ছোট viewport-এ card layout দেখুন। Network offline করলে friendly error দেখা যায় কিনা যাচাই করুন। Browser support target অনুযায়ী AbortController, replaceChildren() এবং toLocaleString()-এর compatibility দেখে প্রয়োজন হলে fallback লিখুন।
এই project-এর সবচেয়ে বড় শিক্ষা হলো request সফল হওয়া মানে শুধু একটি promise resolve হওয়া নয়। Status, payload shape, timeout, cancellation, cleanup এবং rendering safety—সবগুলো মিলে reliable UI তৈরি করে। Local fixture দিয়ে শুরু করলে এই প্রতিটি concern নিয়ন্ত্রিতভাবে পরীক্ষা করা যায়; পরে backend যুক্ত হলেও একই contract ও state discipline ধরে রাখা সম্ভব।
No comments:
Post a Comment