لماذا يكسر النشر كل تبويب مفتوح: ملاحظات ميدانية حول ChunkLoadError وانحراف الإصدارات
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗خطأ ChunkLoadError بعد النشر هو انحراف إصدارات لا خلل في أداة التجميع: تبويب متصفح ما زال يشغّل البناء الأول يطلب ملف جافاسكريبت باسم مبني على بصمة المحتوى أو معرّف Server Action لم يكن موجوداً إلا في ذلك البناء، بينما صارت شبكة التوزيع تقدّم البناء الثاني فقط. الحل هو إبقاء البناءات القديمة قابلة للوصول عبر خيار deploymentId في Next.js أو خاصية Skew Protection في Vercel، وجعل المتصفح يعيد تحميل نفسه مرة واحدة عند وقوع عدم التطابق.
نشرنا تحديثاً عادياً فامتلأ سجل الأخطاء برسالة ChunkLoadError: Loading chunk 4821 failed. لم يمس التغيير التوجيه ولا التجميع ولا الاستيراد الديناميكي. كل خطأ جاء من جلسة فتحت الصفحة قبل انتهاء النشر. هذه هي قصة انحراف الإصدارات كاملة: الخادم تقدّم، والمتصفحات التي كانت في منتصف الجلسة لم تتقدم معه.
أهم النقاط
يعني ChunkLoadError أن ملف جافاسكريبت مطلوباً أعاد الرمز 404، وغالباً لأن نشراً جديداً استبدل أسماء الملفات المبنية على بصمة المحتوى التي ما زال التبويب المفتوح يشير إليها.
انحراف الإصدارات هو عدم تطابق بين نسخة العميل ونسخة الخادم، ويكسر أربع طبقات مختلفة: ملفات جافاسكريبت الثابتة، وحمولة RSC، ومعرّفات Server Action، وشكل طلبات الواجهة البرمجية.
يدعم Next.js خيار deploymentId داخل ملف next.config.ts، وهو يضيف معامل ?dpl= إلى طلبات الأصول وطلبات RSC حتى تستطيع البنية التحتية توجيه العميل القديم إلى البناء الخاص به.
تُبقي خاصية Skew Protection في Vercel النشرات السابقة قابلة للخدمة وتثبّت كل جلسة متصفح على البناء الذي بدأت معه، فتزيل هذا النوع من الأخطاء بدل إخفائه.
اشحن دائماً مسار تعافٍ في المتصفح: التقط أخطاء تحميل الملفات داخل حدود خطأ وأجبر إعادة تحميل واحدة فقط عبر window.location.reload()، محمية بعلامة في sessionStorage حتى لا يتسبب بناء معطوب فعلاً في حلقة إعادة تحميل لا تنتهي.
ما السبب الحقيقي لخطأ ChunkLoadError بعد النشر؟
يُرمى خطأ ChunkLoadError حين يطلب المتصفح حزمة جافاسكريبت محمّلة بشكل كسول فيتلقى الرمز 404 أو صفحة خطأ HTML بدل الجافاسكريبت. تسمّي أدوات Next.js وVite وwebpack هذه الحزم باسم يتضمن بصمة المحتوى مثل 4821-a91f3c2e.js تحديداً كي يمكن تخزينها مؤقتاً بصفة دائمة. وعند كل نشر يحصل كل ملف تغيّر على بصمة جديدة وتختفي الأسماء السابقة من الخادم.
صفحة HTML التي وصلت إلى التبويب المفتوح تحمل البصمات القديمة بشكل ثابت. وفي اللحظة التي ينقر فيها المستخدم رابطاً يستدعي next/dynamic أو React.lazy أو تقسيماً على مستوى المسار، يطلب المتصفح ملفاً لم يعد موجوداً. ترويسة Cache-Control التي تجعل هذه الأصول سريعة هي نفسها ما يجعل الوضع هشاً، لأن الدوام يضمن ألا يعيد المتصفح التحقق من الاسم الذي أُعطي له.
ثلاث إشارات تؤكد أن المشكلة انحراف إصدارات لا بناء معطوب: تتجمع الأخطاء خلال دقائق من وقت النشر، ولا تصيب إلا الجلسات التي بدأت قبل ذلك النشر، وتعيد روابط الأصول الفاشلة الرمز 404 بينما التطبيق نفسه سليم تماماً لأي زائر جديد.
لماذا لا يُعد طلب إعادة التحميل من المستخدم حلاً؟
إعادة التحميل القسرية تحل المشكلة لمن يشتكي، وهنا الفخ. ولأن الحل البديل تافه، يُصنَّف العطل على أنه شكلي بينما يواصل كلفته الحقيقية من الجلسات. لا أحد يفتح تذكرة عن عملية شراء فشلت بصمت في فتح خطوة الدفع.
أكثر المتضررين هم من لا ترغب في خسارتهم: مستخدم لوحة تحكم بقيت مفتوحة ساعات، ومستخدم نموذج متعدد الخطوات نصف ممتلئ، ومستخدم هاتف بقي تبويبه في الخلفية ساعة ثم استُعيد من الذاكرة. كلهم يفقدون حالتهم الجارية حين يكون التعافي الوحيد إعادة تحميل يدوية. وتزيد وتيرة النشر الأمر سوءاً: كلما نشرت أكثر اتسعت النافذة التي يعمل فيها تبويب ما على بناء قديم.
ما الذي ينكسر أثناء انحراف الإصدارات، طبقة بطبقة؟
انحراف الإصدارات ليس عطلاً واحداً بل أربعة، ولكل منها ظهور مختلف في السجلات.
ملفات جافاسكريبت الثابتة: العَرَض هو ChunkLoadError أو Failed to fetch dynamically imported module في Vite، والسبب أن أسماء الملفات المبنية على البصمة حُذفت من شبكة التوزيع.
حمولة RSC في موجّه التطبيق: التنقل داخل المتصفح يتحول إلى تحميل كامل للصفحة بدل انتقال سلس، لأن استجابة Flight لا تطابق البناء الذي يتوقعه الموجّه فيعود Next.js إلى تنقل مستندي كامل.
Server Actions: تظهر في سجلات الخادم رسالة Failed to find Server Action، لأن معرّفات هذه الإجراءات بصمات مرتبطة بالبناء والعميل القديم يرسل معرّفاً لم يره البناء الجديد قط.
معالجات المسارات وواجهات REST: تحصل على استجابات 400 أو 422 أو بيانات خاطئة بصمت، لأن العميل القديم يرسل الشكل القديم للطلب إلى عقد تغيّر.
عامل الخدمة أو صفحات HTML المخزنة: يظل الهيكل القديم يطلب أصولاً ميتة، لأن HTML المخزن يعمّر أطول من النشر ولا يعيد حل روابط الأصول أبداً.
حالة Server Actions تفاجئ أغلب الفرق. معرّف الإجراء بصمة تُولَّد وقت البناء، فهو مرتبط بالإصدار بطبيعته. وتبويب لوحة تحكم بقي مفتوحاً عبر نشرتين سيرسل معرّفاً لم يعد له وجود على الخادم.
كيف نوقف الانحراف من مصدره عبر deploymentId وSkew Protection؟
يضع خيار deploymentId في Next.js وسماً على كل طلب أصل وكل طلب RSC يحدد البناء الذي أنتجه. تضبطه في ملف next.config.ts بإسناد process.env.NEXT_DEPLOYMENT_ID إلى مفتاح deploymentId في كائن الإعدادات المُصدَّر.
عند ضبط deploymentId يضيف Next.js معامل ?dpl= إلى طلبات السكربتات وطلبات RSC. هذا المعامل مفتاح توجيه لا حل بذاته، فهو لا ينفع إلا إذا كانت بنيتك التحتية قادرة فعلاً على تقديم البناء الأقدم الذي يسميه.
على Vercel، خاصية Skew Protection هي ما يجعل هذا المفتاح مفيداً: تُبقي النشرات السابقة قابلة للخدمة وتثبّت جلسة المتصفح على النشرة التي بدأت معها، فيظل العميل القديم يتحدث إلى الخادم الذي يفهمه. تُفعّلها لكل مشروع وتختار مدة نافذة الحماية، والأفضل أن تطابق أطول جلسة واقعية عندك لا وتيرة النشر.
في الاستضافة الذاتية، المكافئ هو الاحتفاظ المتعمد بالبناءات. احتفظ بمجلد .next/static من آخر بناءات وقدّم اتحادها من خادم الأصول، أو شغّل نشراً أزرق/أخضر مع إبقاء الحاوية السابقة حية مدة كافية لتصريف الجلسات الجارية. الطريقتان تكلفان تخزيناً أو حوسبة، وكلتاهما أرخص من الجلسات التي تخسرها الآن.
كيف نتعافى بسلاسة حين يقع الانحراف رغم ذلك؟
الحماية من جهة الخادم تقلل الانحراف ولا تصفّره، لذا يحتاج المتصفح إلى مسار تعافٍ. النمط الذي استقرينا عليه هو حدود خطأ عامة تتحقق من كون error.name يساوي ChunkLoadError أو أن الرسالة تطابق نمط فشل تحميل الحزم، ثم تطلق إعادة تحميل واحدة فقط عبر window.location.reload() داخل useEffect، محمية بعلامة في sessionStorage.
امسح تلك العلامة عند نجاح إقلاع التطبيق، وإلا فإن من واجه خطأ تحميل واحداً لن يحصل على تعافٍ تلقائي مرة أخرى في ذلك التبويب. وإعادة التحميل بلا حماية هي أشهر طريقة يفشل بها هذا النمط: إذا كان الملف مفقوداً لسبب حقيقي فستعيد الحدود التحميل بلا نهاية.
النسخة الألطف هي إخبار المستخدم قبل أن ينكسر شيء. اخبز معرّف البناء في العميل وقت البناء باسم NEXT_PUBLIC_BUILD_ID، واطلب نقطة نهاية صغيرة على /api/version مع تعطيل التخزين المؤقت، واعرض شريط إعادة تحميل حين يختلف buildId العائد عن المخبوز.
استعلم على فترات وعند حدث visibilitychange، حتى يتحقق التبويب الذي بقي ساعة في الخلفية فور عودة المستخدم إليه. هذا التوقيت أهم من طول الفترة، لأن التبويبات الخلفية هي موطن أطول نوافذ الانحراف.
كيف نحافظ على عقود الواجهة البرمجية آمنة من الانحراف؟
تعامل مع كل تغيير في الواجهة البرمجية على أنه شيء سيستدعيه عميل قديم، لأنه سيفعل طوال نافذة الانحراف. القاعدة هي التوسيع ثم التقليص: أضف الحقل أو نقطة النهاية الجديدة في نشرة، وانقل العميل في النشرة التالية، ولا تحذف الشكل القديم إلا في نشرة ثالثة. ولا تعيد تسمية حقل ومستهلكه الوحيد في الإصدار نفسه أبداً.
للعملاء الذين يبقون مفتوحين ساعات، معالج المسار الثابت أكثر تحملاً للانحراف من Server Action، لأن رابط المعالج جزء من عقد واجهتك بينما معرّف الإجراء ناتج بناء. ما زلنا نستخدم Server Actions لإرسال النماذج في الصفحات قصيرة العمر، أما الكتابات الخلفية في لوحة تحكم طويلة العمر فمعالج مسار صريح ينجو من النشر دون أي بنية تحتية خاصة.
عادة قياس واحدة تجعل كل هذا مرئياً: أرسل معرّف بناء العميل كترويسة في كل طلب وسجّله بجانب معرّف بناء الخادم. عندها يتحول الانحراف من حكاية إلى رقم تراقبه بعد كل إصدار.
أسئلة شائعة
س: هل ينتج ChunkLoadError عن بناء معطوب؟
ج: نادراً. إذا كانت الصفحات الجديدة تعمل ولا تفشل إلا الجلسات السابقة للنشر، فالبناء سليم وما تراه هو انحراف إصدارات. البناء المعطوب فعلاً يفشل للزوار الجدد أيضاً.
س: هل يحل ضبط deploymentId في next.config.ts مشكلة الانحراف وحده؟
ج: لا. يضيف deploymentId معامل ?dpl= يحدد البناء الذي ينتمي إليه الطلب، وهو لا يمنع الأخطاء إلا إذا كانت استضافتك ما زالت قادرة على تقديم ذلك البناء الأقدم، وهو ما توفره Skew Protection في Vercel أو الاحتفاظ بالبناءات في الاستضافة الذاتية.
س: لماذا تظهر رسالة Failed to find Server Action بعد النشر فقط؟
ج: معرّفات Server Action بصمات تُولَّد وقت البناء. التبويب المحمَّل على البناء السابق يرسل معرّفاً لم يولّده البناء الجديد، فيفشل البحث عنه. الحماية من الانحراف أو معالج مسار ثابت يتجنبان ذلك.
س: هل نعطّل التخزين المؤقت الدائم لملفات جافاسكريبت؟
ج: لا. التخزين الدائم للملفات المبنية على بصمة المحتوى سلوك صحيح وهو ما يجعل الزيارات المتكررة سريعة. المشكلة في حذف البصمات القديمة مبكراً لا في التخزين الجريء.
س: ما طول نافذة الحماية من الانحراف المناسب؟
ج: اضبطها على أطول جلسة واقعية لديك لا على وتيرة النشر. للوحة تحكم يتركها الناس مفتوحة طوال اليوم، الساعات هي الترتيب الصحيح؛ ولموقع تسويقي تكفي الدقائق عادة.
قراءات إضافية
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.
هندسة الواجهات
صور Open Graph الديناميكية في Next.js 16: كيف نبني opengraph-image.tsx ونتحقق من إصدار Satori الذي تضمّنه next/og ونحافظ على صحة معاينات الروابط
واجهت بطاقات المشاركة المولّدة لدينا ثلاث مفاجآت: خط WOFF2 لم يستطع المحرّك قراءته، وملف README لـ Satori يصف محرّكًا أحدث من المضمّن في Next.js، وصفحة ظلّت تعرض صورة الـ layout. هذه ملاحظاتنا الميدانية حول opengraph-image.tsx في Next.js 16 وأولوية البيانات الوصفية وفحص og:image قبل مشاركة أي رابط.