Skip to content

Commit d4c889e

Browse files
authored
Wrap CSS chunk items in a @layer (vercel/turborepo#3542)
In Turbopack, as a consequence of our lazy compilation model, CSS chunks can contain duplicate CSS chunk items. This can cause issues with precedence. Take the following example: Initial CSS chunk: ```css /* ... */ /* chunk item A */ h1 { font-size: 2rem; } /* ... */ /* other chunk item */ h1 { font-size: 4rem; } /* ... */ ``` Dynamic CSS chunk (loaded after the first page load completes) ```css /* ... */ /* chunk item A */ h1 { font-size: 2rem; } /* ... */ ``` In this example, when the page first loads, the following rule will be applied: ```css h1 { font-size: 4rem; } ``` But as soon as the dynamic CSS chunk loads, the following rule will be applied instead: ```css h1 { font-size: 2rem; } ``` However, from the order of rules in the initial load, we know that the former should still apply. We can remedy this particular issue by wrapping each CSS chunk item into its own [`@layer`](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer) (thanks @sokra for the idea!). This ensures that when a CSS chunk item is re-encountered at a later time, it is automatically de-duplicated thanks to the inherent CSS layering algorithm. This is not an issue in Next.js as we can't have duplicated CSS chunk items.
1 parent 031d29f commit d4c889e

10 files changed

+152
-25
lines changed

crates/next-dev-tests/tests/integration/next/font-google/basic/pages/index.js

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ function runTests() {
5555
async function getRuleMatchingClassName(className) {
5656
const selector = `.${CSS.escape(className)}`;
5757

58-
let matchingRule;
5958
for (const stylesheet of document.querySelectorAll("link[rel=stylesheet]")) {
6059
if (stylesheet.sheet == null) {
6160
// Wait for the stylesheet to load completely if it hasn't already
@@ -64,13 +63,30 @@ async function getRuleMatchingClassName(className) {
6463
});
6564
}
6665

67-
for (const rule of stylesheet.sheet.cssRules) {
68-
if (rule.selectorText === selector) {
69-
matchingRule = rule;
70-
break;
66+
const sheet = stylesheet.sheet;
67+
68+
const res = getRuleMatchingClassNameRec(selector, sheet.cssRules);
69+
if (res != null) {
70+
return res;
71+
}
72+
}
73+
74+
return null;
75+
}
76+
77+
function getRuleMatchingClassNameRec(selector, rules) {
78+
for (const rule of rules) {
79+
if (rule instanceof CSSStyleRule && rule.selectorText === selector) {
80+
return rule;
81+
}
82+
83+
if (rule instanceof CSSLayerBlockRule) {
84+
const res = getRuleMatchingClassNameRec(selector, rule.cssRules);
85+
if (res != null) {
86+
return res;
7187
}
7288
}
7389
}
7490

75-
return matchingRule;
91+
return null;
7692
}

crates/turbopack-css/src/chunk/mod.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ pub(crate) mod optimize;
22
pub mod source_map;
33
pub(crate) mod writer;
44

5-
use std::io::Write;
6-
75
use anyhow::{anyhow, Result};
86
use indexmap::IndexSet;
97
use turbo_tasks::{primitives::StringVc, TryJoinIterExt, ValueToString, ValueToStringVc};
@@ -16,6 +14,7 @@ use turbopack_core::{
1614
optimize::{ChunkOptimizerVc, OptimizableChunk, OptimizableChunkVc},
1715
Chunk, ChunkContentResult, ChunkGroupReferenceVc, ChunkGroupVc, ChunkItem, ChunkItemVc,
1816
ChunkReferenceVc, ChunkVc, ChunkableAssetVc, ChunkingContextVc, FromChunkableAsset,
17+
ModuleId, ModuleIdVc,
1918
},
2019
code_builder::{CodeBuilder, CodeVc},
2120
reference::{AssetReferenceVc, AssetReferencesVc},
@@ -113,6 +112,8 @@ impl CssChunkContentVc {
113112

114113
#[turbo_tasks::function]
115114
async fn code(self) -> Result<CodeVc> {
115+
use std::io::Write;
116+
116117
let this = self.await?;
117118
let chunk_name = this.chunk_path.to_string();
118119

@@ -345,6 +346,23 @@ impl CssChunkContextVc {
345346
pub fn of(context: ChunkingContextVc) -> CssChunkContextVc {
346347
CssChunkContext { context }.cell()
347348
}
349+
350+
#[turbo_tasks::function]
351+
pub async fn chunk_item_id(self, chunk_item: CssChunkItemVc) -> Result<ModuleIdVc> {
352+
use std::fmt::Write;
353+
354+
let layer = &*self.await?.context.layer().await?;
355+
let mut s = chunk_item.to_string().await?.clone_value();
356+
if !layer.is_empty() {
357+
if s.ends_with(')') {
358+
s.pop();
359+
write!(s, ", {layer})")?;
360+
} else {
361+
write!(s, " ({layer})")?;
362+
}
363+
}
364+
Ok(ModuleId::String(s).cell())
365+
}
348366
}
349367

350368
#[turbo_tasks::value_trait]
@@ -373,6 +391,9 @@ pub struct CssChunkItemContent {
373391
pub trait CssChunkItem: ChunkItem + ValueToString {
374392
fn content(&self) -> CssChunkItemContentVc;
375393
fn chunking_context(&self) -> ChunkingContextVc;
394+
fn id(&self) -> ModuleIdVc {
395+
CssChunkContextVc::of(self.chunking_context()).chunk_item_id(*self)
396+
}
376397
}
377398

378399
#[async_trait::async_trait]

crates/turbopack-css/src/chunk/writer.rs

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::{collections::VecDeque, io::Write};
22

33
use anyhow::Result;
44
use turbo_tasks::{primitives::StringVc, ValueToString};
5-
use turbopack_core::code_builder::CodeBuilder;
5+
use turbopack_core::{chunk::ModuleId, code_builder::CodeBuilder};
66

77
use super::{CssChunkItemVc, CssImport};
88

@@ -39,14 +39,24 @@ pub async fn expand_imports(
3939
external_imports.push(url_vc);
4040
}
4141
None => {
42-
let id = &*chunk_item.to_string().await?;
43-
writeln!(code, "/* {} */", id)?;
42+
let id = module_id_to_css_ident(&*chunk_item.id().await?);
43+
44+
// CSS chunk items can be duplicated across chunks. This can cause precedence
45+
// issues (WEB-456). We use CSS layers to make sure that the first occurrence of
46+
// a CSS chunk item determines its precedence.
47+
// TODO(alexkirsz) This currently breaks users using @layer. We can fix that by
48+
// moving our @layer into the user layer.
49+
writeln!(code, "@layer {id} {{")?;
4450

4551
let content = chunk_item.content().await?;
4652
code.push_source(
4753
&content.inner_code,
4854
content.source_map.map(|sm| sm.as_generate_source_map()),
4955
);
56+
57+
// Closing @layer.
58+
writeln!(code, "\n}}")?;
59+
5060
writeln!(code, "\n{}", close)?;
5161

5262
stack.pop();
@@ -56,3 +66,67 @@ pub async fn expand_imports(
5666

5767
Ok(external_imports)
5868
}
69+
70+
fn module_id_to_css_ident(id: &ModuleId) -> String {
71+
match id {
72+
ModuleId::Number(n) => format!("n{}", n),
73+
ModuleId::String(s) => format!("s{}", escape_css_ident(s)),
74+
}
75+
}
76+
77+
/// Escapes a string to be a valid CSS identifier, according to the rules
78+
/// defined in https://developer.mozilla.org/en-US/docs/Web/CSS/ident
79+
fn escape_css_ident(s: &str) -> String {
80+
let mut escaped = String::new();
81+
82+
let mut starts_as_a_number = true;
83+
for char in s.chars() {
84+
if starts_as_a_number {
85+
if char.is_ascii_digit() {
86+
escaped.push('_');
87+
starts_as_a_number = false;
88+
} else if char != '-' {
89+
starts_as_a_number = false;
90+
}
91+
}
92+
93+
if char.is_ascii_alphanumeric() || char == '-' || char == '_' {
94+
escaped.push(char);
95+
} else {
96+
escaped.push('\\');
97+
escaped.push(char);
98+
}
99+
}
100+
101+
escaped
102+
}
103+
104+
#[cfg(test)]
105+
mod tests {
106+
//! These cases are taken from https://developer.mozilla.org/en-US/docs/Web/CSS/ident#examples.
107+
108+
use super::*;
109+
110+
#[test]
111+
fn test_escape_css_ident_noop() {
112+
assert_eq!(escape_css_ident("nono79"), "nono79");
113+
assert_eq!(escape_css_ident("ground-level"), "ground-level");
114+
assert_eq!(escape_css_ident("-test"), "-test");
115+
assert_eq!(escape_css_ident("--toto"), "--toto");
116+
assert_eq!(escape_css_ident("_internal"), "_internal");
117+
// TODO(alexkirsz) Support unicode characters?
118+
// assert_eq!(escape_css_ident("\\22 toto"), "\\22 toto");
119+
// TODO(alexkirsz) This CSS identifier is already valid, but we escape
120+
// it anyway.
121+
assert_eq!(escape_css_ident("bili\\.bob"), "bili\\\\\\.bob");
122+
}
123+
124+
#[test]
125+
fn test_escape_css_ident() {
126+
assert_eq!(escape_css_ident("34rem"), "_34rem");
127+
assert_eq!(escape_css_ident("-12rad"), "-_12rad");
128+
assert_eq!(escape_css_ident("bili.bob"), "bili\\.bob");
129+
assert_eq!(escape_css_ident("'bilibob'"), "\\'bilibob\\'");
130+
assert_eq!(escape_css_ident("\"bilibob\""), "\\\"bilibob\\\"");
131+
}
132+
}

crates/turbopack-tests/tests/snapshot/css/absolute-uri-import/output/crates_turbopack-tests_tests_snapshot_css_absolute-uri-import_input_index.css

Lines changed: 9 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/turbopack-tests/tests/snapshot/css/absolute-uri-import/output/crates_turbopack-tests_tests_snapshot_css_absolute-uri-import_input_index.css.map

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/turbopack-tests/tests/snapshot/css/css/output/8697f_foo_style.css

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/turbopack-tests/tests/snapshot/css/css/output/8697f_foo_style.module.css

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/turbopack-tests/tests/snapshot/css/css/output/crates_turbopack-tests_tests_snapshot_css_css_input_style.css

Lines changed: 6 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/turbopack-tests/tests/snapshot/css/css/output/crates_turbopack-tests_tests_snapshot_css_css_input_style.css.map

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/turbopack-tests/tests/snapshot/css/css/output/crates_turbopack-tests_tests_snapshot_css_css_input_style.module.css

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)