كيف تخلّصت من 94 ملف CSS حاجب للعرض في Next.js 16 بميزة Turbopack موثَّقة بشكل ضعيف
بعد أيام من تجربة كل نهج — من experimental.inlineCss إلى حيل MutationObserver — اكتشفت Turbopack Import Attributes التي تحل مشكلة CSS الحاجب للعرض في Next.js App Router.
المشكلة
يستخدم تطبيقنا (Promova) إطار Next.js 16 مع Landing Builder — نظام يعتمد على نظام إدارة محتوى (CMS) يجمّع صفحات التسويق من نحو 90 مكوّن قسم مختلفًا (أقسام Hero وأسئلة شائعة وتسعير ومراجعات وغيرها). تعتمد البنية على sectionRegistry.tsx الذي يربط أسماء الأقسام باستدعاءات next/dynamic():
// sectionRegistry.tsx — imports all 90 sections
export const sectionRegistry = {
HeroSection: dynamic(() => import('./HeroSection/HeroSection')),
FAQSection: dynamic(() => import('./FAQSection/FAQSection')),
ReviewsSection: dynamic(() => import('./ReviewsSection/ReviewsSection')),
// ... 87 more
}لا تعرض صفحة الهبوط الواحدة سوى 5-8 أقسام. لكن Lighthouse كان يُظهر ما يلي:
Eliminate render-blocking resources
94 CSS resources (~330 KB)
Potential savings: 5,440 msلماذا؟ يرى Turbopack جميع مسارات import() التسعين قابلةً للوصول، فيولّد <link rel="stylesheet"> لكل وحدة SCSS. حتى الأقسام التي لا تُعرَض أبدًا على الصفحة يُحقَن ملف CSS الخاص بها في <head>. هذا سلوك مؤكَّد ومتوقَّع لموجّه App Router في Next.js. إذ لا يُقسَّم CSS إلى أجزاء لاستيرادات dynamic() القادمة من Server Components. يعدّه المشرفون مقايضةً متعمَّدة لتفادي وميض المحتوى غير المنسَّق (FOUC). ولا يُخطَّط لإصلاحه.
كل ما جرّبته (ولماذا فشل)
قضيت أيامًا أستعرض كل نهج أمكنني إيجاده. إليك القائمة الكاملة:
| Approach | Why it doesn't work |
|---|---|
Split sectionRegistry into per-section files | CSS still loaded — #61066 |
experimental.inlineCss: true | Inlines CSS on every SSR request — crashed our CMS under load |
experimental.optimizeCss (Critters) | Incompatible with streaming in App Router |
| CSS-in-JS rewrite | Rewriting 90 sections from SCSS to CSS-in-JS is not realistic |
media="print" hack | Doesn't work with CSS Modules in Turbopack — <link> tags are managed by the framework |
| Switch back to Webpack | Has experiments.css options, but we're committed to Turbopack |
| Turbopack plugins | Don't exist — no plugin API |
| Turbopack CSS loaders | Not supported — only JS output |
| SWC plugins for CSS | SWC only processes JavaScript |
Client Component wrapper for dynamic() | Registry is a global constant — bundler sees all dependencies |
| Next.js middleware HTML rewrite | Middleware can't modify response body |
| Suspense + streaming with async import | React Float always pulls CSS into initial <head> |
| Suspense + 3s delay | Content streams later, but CSS is in the initial <head> chunk |
experimental.cssChunking | Already default. Merges chunks but doesn't remove irrelevant CSS |
| Post-build Beasties/Critters | Only for static export, not with ISR |
| npm libraries (next-critical, fg-loadcss) | Pages Router only or abandoned (6+ years) |
inlineCss per-route exclusion | Not supported — all-or-nothing global flag |
Turbopack as: 'raw' for SCSS | Returns undefined instead of text |
Turbopack rule for *.inline.module.scss | Turbopack intercepts .module.scss before custom rules |
Turbopack rule for *.inline.scss | Turbopack intercepts .scss before custom rules |
MutationObserver <script> | Tested in dev, risky — CSS still loads as separate files, possible FOUC |
فخّ inlineCss
يمتلك Next.js خيار experimental.inlineCss تستبدل جميع وسوم <link rel="stylesheet"> بوسوم <style> مضمَّنة. يبدو ذلك مثاليًا، أليس كذلك؟
المشكلة أنه إما الكل أو لا شيء. لا يمكنك تفعيله لكل مسار على حدة. إن كانت لديك صفحات SSR (force-dynamic) فإن كل طلب يعيد بناء كل CSS بشكل مضمَّن. جرّبنا ذلك — فلم يحتمل نظام CMS بلا واجهة (headless) لدينا الحِمل وتعطّل. ولكي يعمل بأمان، يجب أن تكون 100% من صفحاتك بنمط force-static أو ISR. ومع أكثر من 20 صفحة SSR (مصادقة، ولوحات تحكم، وصفحات ديناميكية) فتلك عملية ترحيل ضخمة.
الاكتشاف: Turbopack Import Attributes
أثناء تنقيبي في ملاحظات إصدار Next.js 16.2، عثرت على ميزة موثَّقة بشكل ضعيف: Turbopack Import Attributes. تتيح لك تجاوز خط أنابيب المجمِّع المدمَج لاستيراد محدَّد باستخدام صياغة with {} من TC39:
import { cssText, styles } from './hero.module.scss' with {
turbopackLoader: '@promova/scss-to-inline-loader',
turbopackAs: '*.js'
}يقول هذا لـ Turbopack: «لا تعالج هذا الاستيراد بوصفه ورقة أنماط. مرِّره عبر الـ loader المخصَّص لديّ وتعامل مع الخرج بوصفه JavaScript.»
هذه هي الفكرة الجوهرية. بدلًا من أن يولّد Turbopack وسم <link rel="stylesheet"> يحجب العرض، يقوم الـ loader لدينا بترجمة SCSS وتصديره كسلسلة JavaScript. ثم نحقنه بوصفه وسم <style> مضمَّنًا مباشرة داخل المكوّن. النتيجة: لا يصل إلى HTML الصفحة سوى CSS الأقسام التي تُعرَض فعلًا. تحصل الصفحة ذات الأقسام الخمسة على CSS لخمسة أقسام — لا لـ 94.
الحل
1. Turbopack Loader مخصَّص
نص Node.js بنحو 70 سطرًا كحزمة ضمن مساحة عمل yarn (@promova/scss-to-inline-loader):
const { createHash } = require('node:crypto')
const sass = require('sass')
const postcss = require('postcss')
const postcssModules = require('postcss-modules')
const path = require('path')
const SHARED_STYLES = path.resolve(__dirname, '../shared/styles')
// Same prependData as sassOptions.prependData in next.config.ts
const PREPEND_DATA = `
@use "sass:math" as math;
@use "sass:list" as list;
@use "${SHARED_STYLES}/_variables.scss" as *;
@use "${SHARED_STYLES}/_breakpoints.scss" as *;
@use "${SHARED_STYLES}/_mixins.scss" as *;
`
module.exports = function scssToInlineLoader(source) {
const callback = this.async()
processScss(source, this.resourcePath)
.then((js) => callback(null, js))
.catch((err) => callback(err))
}
async function processScss(source, resourcePath) {
// 1. Compile SCSS → CSS (with global variables, mixins, breakpoints)
const sassResult = sass.compileString(PREPEND_DATA + '\n' + source, {
loadPaths: [path.dirname(resourcePath), SHARED_STYLES],
style: 'compressed',
sourceMap: false,
url: new URL('file://' + resourcePath),
})
// 2. Scope class names with postcss-modules (CSS Modules compatible)
let classNames = {}
const result = await postcss([
postcssModules({
generateScopedName(name, filename) {
const file = path.basename(filename, path.extname(filename))
.replace('.module', '')
const hash = createHash('md5')
.update(filename + name)
.digest('base64url')
.slice(0, 6)
return `${file}__${name}__${hash}`
},
getJSON(_, json) { classNames = json },
}),
]).process(sassResult.css, { from: resourcePath })
// 3. Export as JS module — same interface as CSS Modules
return [
`export const cssText = ${JSON.stringify(result.css)};`,
`export const styles = ${JSON.stringify(classNames)};`,
`export default styles;`,
].join('\n')
}ما يفعله: styles — خريطة أسماء الأصناف محدَّدة النطاق (scoped) ذاتها الموجودة في CSS Modules القياسية. cssText — ملف CSS المترجَم كسلسلة نصية.
2. مكوّن InlineStyle
يستخدم واجهة <style href precedence> المدمَجة في React 19 لإزالة التكرار تلقائيًا:
export function InlineStyle({ css, id }: { css: string; id: string }) {
return (
<style href={id} precedence="default">
{css}
</style>
)
}يضمن React 19: نفس قيمة href → وسم <style> واحد فقط في DOM. فإن عُرِض قسم مرتين على الصفحة، يُحقَن CSS مرة واحدة رغم ذلك.
3. ترحيل لكل مكوّن (نحو 6 أسطر لكل قسم)
// BEFORE: Turbopack → <link rel="stylesheet"> (render-blocking)
import styles from './hero_section.module.scss'// AFTER: Custom loader → JS module → inline <style> (non-blocking)
import { cssText, styles } from './hero_section.module.scss' with {
turbopackLoader: '@promova/scss-to-inline-loader',
turbopackAs: '*.js'
}
import { InlineStyle } from '@promova/scss-to-inline-loader/InlineStyle'
export function HeroSection() {
return (
<section className={styles.hero}>
<InlineStyle css={cssText} id="hero-section" />
{/* ... component JSX, className usage is identical */}
</section>
)
}تبقى ملفات .module.scss كما هي تمامًا. لا إعادة كتابة لـ CSS. Stylelint وPrettier ودعم بيئة التطوير (IDE) — كلها محفوظة.
لماذا هذا أفضل من inlineCss: true
هذا هو الفرق الجوهري:
| <code>inlineCss: true</code> | Import Attributes + Loader | |
|---|---|---|
| What gets inlined | ALL CSS on the page (94 files) | Only CSS for rendered sections (5-8 files) |
| SSR overhead | CSS rebuilt on every request | CSS compiled at build time |
| Per-route control | No (all-or-nothing) | Yes (per-import) |
| SSR pages safety | Risky (overloads server) | Safe (component-level) |
عند استخدام inlineCss: true، تحصل الصفحة رغم ذلك على جميع أوراق الأنماط الأربع والتسعين مضمَّنة — لكنها وسوم <style> بدلًا من وسوم <link>. يتضخّم HTML. أما مع نهجنا، فإن ما يصل إلى HTML هو CSS الذي يُعرَض فعلًا لا غير. عُرِضت 5 أقسام → 5 وسوم <style>. هذا كل شيء.
مطبّ Turbopack: لا قواعد عامة لـ .module.scss
أحد المطبّات التي وقعت فيها: قد تظن أن بإمكانك إضافة قاعدة Turbopack في next.config.ts لتطبيق الـ loader عالميًا:
// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
rules: {
'**/components/**/*.module.scss': {
loaders: ['@promova/scss-to-inline-loader'],
as: '*.js'
}
}
}لا تفعل هذا. يعترض خط أنابيب وحدات CSS المدمَج في Turbopack ملفات .module.scss قبل تطبيق القواعد المخصَّصة. فيُنشئ أصلًا من نوع EcmascriptCssModule يتعارض مع خرج as: '*.js'، مسبِّبًا:
FATAL PANIC: inner asset should be CSS processableتعمل سمات with {} لأنها توعز إلى Turbopack عند موضع الاستيراد بتجاوز خط أنابيب وحدات CSS كليًا. ولا سبيل إلى فعل ذلك عالميًا عبر ملف الإعداد.
النتائج
رحّلنا 127 مكوّن قسم عبر Landing Builder. وتم التحقق من بناء الإنتاج.
| Metric | Before | After |
|---|---|---|
| Render-blocking CSS files | 94 | 0 (for landing sections) |
| CSS in HTML per page | ~330 KB (all sections) | ~20-40 KB (rendered sections only) |
| CSS delivery | <link> (network request, blocking) | <style> (inline, non-blocking) |
| Sass features | Full | Full (variables, mixins, nesting, @use) |
| CSS Modules scoping | Built-in | postcss-modules (compatible) |
| Code change per section | — | ~6 lines (import + InlineStyle) |
القيود
with {}لكل استيراد مُسهَب — يحتاج كل استيراد إلى 3 أسطر إضافية. ولا سبيل إلى تطبيقه عالميًا (انظر المطبّ أعلاه).- Turbopack فقط — لا يدعم Webpack سمات
with {}. وإن احتجت إلى بديل Webpack، تعود الاستيرادات إلى CSS Modules القياسية (وتكون قيمةcssTextهيundefined). - تجزئة أسماء الأصناف — يستخدم الـ loader لدينا خوارزمية تجزئة مختلفة عن تلك المدمَجة في Turbopack. تبدو أسماء الأصناف مختلفة في DevTools. لكنها متطابقة وظيفيًا.
- يزداد حجم HTML — يُضمَّن CSS داخل HTML بدلًا من تخزينه مؤقتًا كملفات منفصلة. أما للصفحات الثابتة/ISR المقدَّمة من CDN، فهذه ليست مشكلة.
متى تستخدم هذا
تكون هذه التقنية أكثر فاعلية عندما:
- يكون لديك نمط registry/barrel — ملف واحد يستورد مكوّنات كثيرة، لكن قليلًا منها فقط يُعرَض في كل صفحة
- تكون على Turbopack — سمات Import Attributes خاصة بـ Turbopack
- تريد تحكمًا لكل مكوّن — لا خيار «إما الكل أو لا شيء»
- تكون ملفات SCSS لديك معقّدة — متغيّرات وmixins ونقاط توقّف (breakpoints) وتداخل — كلها مدعومة
- لا يمكنك استخدام
experimental.inlineCss— لأن لديك صفحات SSR أو تريد تحكمًا دقيقًا
مشكلات GitHub ذات الصلة
إن كنت متأثرًا بمشكلة CSS الحاجب للعرض في Next.js App Router، فأنت لست وحدك:
المشكلة الجوهرية
- #62485 — Render blocking CSS (maintainers: "expected behavior")
- #61066 — Dynamic imports from Server Components are not code-split
- #54935 — Server-side dynamic imports don't split client modules
- #61574 — JS/CSS code splitting doesn't work as documented
- #57634 — Add support for critical CSS inlining with App Router
- #50300 — next/dynamic on server component does not build CSS modules
ميزة inlineCss ومشكلاتها
- PR #72195 — experimental: css inlining (the implementation)
- PR #73182 — Don't inline CSS in RSC payload for client navigation
- #75648 — WhatsApp preview broken with inlineCss + Tailwind
- #83612 — Turbopack wrong font URL with inline CSS
المجتمع يطلب حلولًا
- Discussion #82894 — How to prevent render-blocking with CSS Modules? (2025)
- Discussion #70526 — Ideas for reducing render-blocking CSS
- Discussion #59814 — Render blocking styles with Tailwind
- Discussion #49691 — How to deal with render-blocking modular CSS
- Discussion #59989 — Critical CSS inlining with App Router
- Discussion #80486 — Is optimizeCss still in use?
- Discussion #85465 — Turbopack plugin API (doesn't exist)
علل CSS في Turbopack
- #68412 — Incorrect CSS modules load order with external components
- #76464 — Turbopack sometimes strips SCSS imports
- #82497 — SCSS module not loading when not-found.tsx is present
- #88544 — Exporting Sass variables from CSS modules doesn't work with Turbopack
صُنِع في Promova — منصة لتعلّم اللغات تخدم ملايين المستخدمين. إن كنت تصارع المشكلة ذاتها في CSS، فلا تتردد في الإشارة إلى هذا المقال في مشكلات GitHub أعلاه.