Skip to content

Commit 5f69f8b

Browse files
committed
fix(esm-library): handle CSS modules in preserveModules
CSS modules (native `experiments.css` or mini-css-extract) under `output.library.preserveModules` caused either "chunk xxx should have at least one file" or "Multiple assets emit different content to the same filename". preserveModules set the JS filename_template on CSS chunks, left chunks nameless so multiple CSS files collapsed onto `.css`, and the ESM render phase emitted `import "__RSPACK_ESM_CHUNK_<id>"` placeholders pointing at CSS-only chunks with no JS file. Classify modules in preserve_modules by source_types and set css_filename_template for CSS modules (preserving the `.css` extension and source path), skip CSS-only modules/chunks in the ESM render paths that emit JS-side requires / cross-chunk imports, and make CssExtractRspackPlugin honor chunk.css_filename_template so preserveModules can override its per-chunk filename.
1 parent 5c34571 commit 5f69f8b

16 files changed

Lines changed: 346 additions & 134 deletions

File tree

crates/rspack_plugin_esm_library/src/preserve_modules.rs

Lines changed: 175 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
use std::{borrow::Cow, path::Path, sync::LazyLock};
1+
use std::path::{Path, PathBuf};
22

3-
use regex::Regex;
43
use rspack_collections::{IdentifierMap, IdentifierSet};
5-
use rspack_core::Compilation;
4+
use rspack_core::{Compilation, Module, SourceType};
65
use rspack_util::fx_hash::{FxHashMap, FxHashSet};
76
use sugar_path::SugarPath;
87

98
use crate::EsmLibraryPlugin;
109

11-
static EXTENSION_JS: LazyLock<Regex> =
12-
LazyLock::new(|| Regex::new(r".+(\..+)$").expect("failed to compile EXTENSION_REGEXP"));
13-
1410
pub fn entry_modules(compilation: &Compilation) -> FxHashMap<String, IdentifierSet> {
1511
let module_graph = compilation.get_module_graph();
1612
compilation
@@ -50,6 +46,42 @@ pub fn entry_name_for_module(
5046
entry_name_for_module
5147
}
5248

49+
/// Returns whether a module should be processed by `preserve_modules`.
50+
/// Only JS and CSS modules are relevant — asset, wasm and other types are
51+
/// handled by their own output pipelines.
52+
fn should_preserve_module(module: &dyn Module, module_graph: &rspack_core::ModuleGraph) -> bool {
53+
let source_types = module.source_types(module_graph);
54+
if source_types.iter().any(|t| {
55+
matches!(
56+
t,
57+
SourceType::JavaScript | SourceType::Css | SourceType::CssImport
58+
)
59+
}) {
60+
return true;
61+
}
62+
// CSS modules created by the extract-css plugin use a `Custom` source type
63+
// (`css/mini-extract`). Detect them by their identifier prefix to keep
64+
// the dependency on `rspack_plugin_extract_css` out of this crate.
65+
module.identifier().starts_with("css|")
66+
}
67+
68+
/// Returns the absolute path of a module's resource, supporting both
69+
/// `NormalModule` and the synthetic `CssModule` from extract-css.
70+
fn module_resource_path(module: &dyn Module) -> Option<PathBuf> {
71+
if let Some(normal_module) = module.as_normal_module() {
72+
return normal_module
73+
.resource_resolved_data()
74+
.path()
75+
.map(|p| p.as_std_path().to_path_buf());
76+
}
77+
// For non-normal modules (extract-css `CssModule`), derive the resource
78+
// path from `name_for_condition`, which strips the loader chain and query
79+
// string.
80+
let name = module.name_for_condition()?;
81+
let path = PathBuf::from(name.as_ref());
82+
if path.is_absolute() { Some(path) } else { None }
83+
}
84+
5385
pub async fn preserve_modules(
5486
root: &Path,
5587
compilation: &mut Compilation,
@@ -75,163 +107,172 @@ pub async fn preserve_modules(
75107
continue;
76108
}
77109

78-
let module_graph = compilation.get_module_graph();
79-
let Some(normal_module) = module_graph
80-
.module_by_identifier(&module_id)
81-
.expect("should have module")
82-
.as_normal_module()
83-
else {
84-
continue;
85-
};
110+
let abs_path = {
111+
let module_graph = compilation.get_module_graph();
112+
let module = module_graph
113+
.module_by_identifier(&module_id)
114+
.expect("should have module");
86115

87-
let Some(abs_path) = normal_module
88-
.resource_resolved_data()
89-
.path()
90-
.map(|p| p.as_std_path())
91-
.map(|p| p.to_path_buf())
92-
else {
93-
continue;
116+
if !should_preserve_module(module.as_ref(), module_graph) {
117+
continue;
118+
}
119+
let Some(abs_path) = module_resource_path(module.as_ref()) else {
120+
continue;
121+
};
122+
abs_path
94123
};
124+
if !abs_path.starts_with(root) {
125+
continue;
126+
}
127+
95128
let chunk = match EsmLibraryPlugin::get_module_chunk(module_id, compilation) {
96129
Ok(c) => c,
97130
Err(e) => {
98131
errors.push(e.into());
99132
continue;
100133
}
101134
};
102-
let old_chunk = compilation
103-
.build_chunk_graph_artifact
104-
.chunk_by_ukey
105-
.expect_get_mut(&chunk);
106-
107-
if abs_path.starts_with(root) {
108-
// split module into single chunk named root
109-
let file_path = abs_path.relative(root);
110-
let extension = file_path.extension();
111-
112-
let new_extension = old_chunk
113-
.filename_template()
114-
.unwrap_or(&compilation.options.output.filename)
115-
.template()
116-
.map_or(Cow::Borrowed(".js"), |tpl| {
117-
if let Some(captures) = EXTENSION_JS.captures(tpl) {
118-
Cow::Owned(captures[1].to_string())
119-
} else {
120-
Cow::Borrowed(".js")
121-
}
122-
});
123-
let new_filename = if let Some(extension) = extension {
124-
let ext_lossy = extension.to_string_lossy();
125-
let file_path_lossy = file_path.to_string_lossy();
126-
let suffix = format!(".{ext_lossy}");
127-
let base = file_path_lossy
128-
.strip_suffix(&suffix)
129-
.unwrap_or(&file_path_lossy);
130-
format!("{base}{new_extension}").into()
131-
} else {
132-
file_path.to_string_lossy().to_string().into()
133-
};
134135

135-
let entry_name = if let Some(entry_names) = entry_name_for_module.get(&module_id) {
136-
if entry_names.len() > 1 {
137-
errors.push(
138-
rspack_error::error!(
139-
"{} is used in multiple entries: [{}], this is not allowed in preserveModules",
140-
module_id,
141-
entry_names
142-
.iter()
143-
.map(|s| s.as_str())
144-
.collect::<Vec<_>>()
145-
.join(", ")
146-
)
147-
.into(),
148-
);
149-
continue;
150-
}
136+
// Compute the `chunk.name` from the source path.
137+
//
138+
// Following Rollup's approach: strip the file extension and let the
139+
// per-type output template add the correct one back. rspack has separate
140+
// templates for each output type (`output.filename` → `[name].mjs` for
141+
// JS, `output.cssFilename` → `[name].css` for CSS, etc.), so we always
142+
// strip the source extension regardless of module type. This keeps
143+
// preserve_modules completely type-agnostic for naming.
144+
let file_path = abs_path.relative(root);
145+
// `to_slash()` normalises to forward slashes on all platforms so chunk
146+
// names (and the asset paths derived from them) stay consistent.
147+
let file_path_str = file_path
148+
.to_slash()
149+
.expect("relative path should be valid UTF-8")
150+
.into_owned();
151+
let base_name: String = if let Some(extension) = file_path.extension() {
152+
let suffix = format!(".{}", extension.to_string_lossy());
153+
file_path_str
154+
.strip_suffix(&suffix)
155+
.unwrap_or(&file_path_str)
156+
.to_string()
157+
} else {
158+
file_path_str
159+
};
151160

152-
entry_names.iter().next().cloned()
153-
} else {
154-
None
155-
};
161+
let entry_name = if let Some(entry_names) = entry_name_for_module.get(&module_id) {
162+
if entry_names.len() > 1 {
163+
errors.push(
164+
rspack_error::error!(
165+
"{} is used in multiple entries: [{}], this is not allowed in preserveModules",
166+
module_id,
167+
entry_names
168+
.iter()
169+
.map(|s| s.as_str())
170+
.collect::<Vec<_>>()
171+
.join(", ")
172+
)
173+
.into(),
174+
);
175+
continue;
176+
}
156177

157-
if compilation
178+
entry_names.iter().next().cloned()
179+
} else {
180+
None
181+
};
182+
183+
if compilation
184+
.build_chunk_graph_artifact
185+
.chunk_graph
186+
.get_chunk_modules_identifier(&chunk)
187+
.len()
188+
== 1
189+
{
190+
// This is the last module in the chunk — rename in-place.
191+
let old_chunk = compilation
158192
.build_chunk_graph_artifact
159-
.chunk_graph
160-
.get_chunk_modules_identifier(&chunk)
161-
.len()
162-
== 1
193+
.chunk_by_ukey
194+
.expect_get_mut(&chunk);
195+
if let Some(old_name) = old_chunk.name().map(|s| s.to_string())
196+
&& old_name != base_name
163197
{
164-
// this is last module in chunk, we can keep this chunk, just rename it
165-
old_chunk.set_filename_template(Some(new_filename));
166-
continue;
198+
compilation
199+
.build_chunk_graph_artifact
200+
.named_chunks
201+
.remove(&old_name);
167202
}
168-
169-
let new_chunk_ukey =
170-
Compilation::add_chunk(&mut compilation.build_chunk_graph_artifact.chunk_by_ukey);
203+
old_chunk.set_name(Some(base_name.clone()));
171204
compilation
172205
.build_chunk_graph_artifact
173-
.chunk_graph
174-
.add_chunk(new_chunk_ukey);
175-
let [Some(new_chunk), Some(old_chunk)] = compilation
176-
.build_chunk_graph_artifact
177-
.chunk_by_ukey
178-
.get_many_mut([&new_chunk_ukey, &chunk])
179-
else {
180-
unreachable!("new_chunk and old_chunk should be inserted already")
181-
};
206+
.named_chunks
207+
.insert(base_name, chunk);
208+
continue;
209+
}
182210

183-
new_chunk.set_filename_template(Some(new_filename));
184-
old_chunk.split(
185-
new_chunk,
186-
&mut compilation.build_chunk_graph_artifact.chunk_group_by_ukey,
187-
);
188-
// disconnect module from other chunks
211+
let new_chunk_ukey =
212+
Compilation::add_chunk(&mut compilation.build_chunk_graph_artifact.chunk_by_ukey);
213+
compilation
214+
.build_chunk_graph_artifact
215+
.chunk_graph
216+
.add_chunk(new_chunk_ukey);
217+
let [Some(new_chunk), Some(old_chunk)] = compilation
218+
.build_chunk_graph_artifact
219+
.chunk_by_ukey
220+
.get_many_mut([&new_chunk_ukey, &chunk])
221+
else {
222+
unreachable!("new_chunk and old_chunk should be inserted already")
223+
};
224+
225+
new_chunk.set_name(Some(base_name.clone()));
226+
old_chunk.split(
227+
new_chunk,
228+
&mut compilation.build_chunk_graph_artifact.chunk_group_by_ukey,
229+
);
230+
compilation
231+
.build_chunk_graph_artifact
232+
.named_chunks
233+
.insert(base_name.clone(), new_chunk_ukey);
234+
235+
// disconnect module from other chunks
236+
compilation
237+
.build_chunk_graph_artifact
238+
.chunk_graph
239+
.disconnect_chunk_and_module(&chunk, module_id);
240+
241+
compilation
242+
.build_chunk_graph_artifact
243+
.chunk_graph
244+
.connect_chunk_and_module(new_chunk_ukey, module_id);
245+
246+
if let Some(entry_name) = entry_name {
189247
compilation
190248
.build_chunk_graph_artifact
191249
.chunk_graph
192-
.disconnect_chunk_and_module(&chunk, module_id);
250+
.disconnect_chunk_and_entry_module(&chunk, module_id);
251+
252+
let entrypoint = compilation.entrypoint_by_name_mut(&entry_name);
253+
let ukey = entrypoint.ukey;
254+
entrypoint.set_entrypoint_chunk(new_chunk_ukey);
193255

194256
compilation
195257
.build_chunk_graph_artifact
196258
.chunk_graph
197-
.connect_chunk_and_module(new_chunk_ukey, module_id);
198-
199-
if let Some(entry_name) = entry_name {
200-
compilation
201-
.build_chunk_graph_artifact
202-
.chunk_graph
203-
.disconnect_chunk_and_entry_module(&chunk, module_id);
204-
205-
let entrypoint = compilation.entrypoint_by_name_mut(&entry_name);
206-
let ukey = entrypoint.ukey;
207-
entrypoint.set_entrypoint_chunk(new_chunk_ukey);
259+
.connect_chunk_and_entry_module(new_chunk_ukey, module_id, ukey);
208260

209-
compilation
210-
.build_chunk_graph_artifact
211-
.chunk_graph
212-
.connect_chunk_and_entry_module(new_chunk_ukey, module_id, ukey);
213-
214-
// Transfer the chunk name from the old entry chunk to the new chunk
215-
// to avoid duplicate output filenames. Without this, the old chunk
216-
// retains its entry name (e.g., "index") and if it has modules outside
217-
// the root (which don't get a filename_template), its output falls back
218-
// to `[name].mjs` → "index.mjs", conflicting with the new chunk's
219-
// filename_template "index.mjs".
220-
let old_chunk = compilation
221-
.build_chunk_graph_artifact
222-
.chunk_by_ukey
223-
.expect_get_mut(&chunk);
224-
if let Some(name) = old_chunk.name().map(|s| s.to_string()) {
225-
old_chunk.set_name(None);
226-
let new_chunk = compilation
227-
.build_chunk_graph_artifact
228-
.chunk_by_ukey
229-
.expect_get_mut(&new_chunk_ukey);
230-
new_chunk.set_name(Some(name.clone()));
261+
// Remove the entry name from the old chunk to avoid filename conflicts.
262+
// Without this, the old chunk retains its entry name (e.g. "index") and
263+
// its output falls back to `[name].mjs` → "index.mjs", conflicting with
264+
// the new chunk that already owns that name.
265+
let old_chunk = compilation
266+
.build_chunk_graph_artifact
267+
.chunk_by_ukey
268+
.expect_get_mut(&chunk);
269+
if let Some(old_name) = old_chunk.name().map(|s| s.to_string()) {
270+
old_chunk.set_name(None);
271+
if old_name != base_name {
231272
compilation
232273
.build_chunk_graph_artifact
233274
.named_chunks
234-
.insert(name, new_chunk_ukey);
275+
.remove(&old_name);
235276
}
236277
}
237278
}

0 commit comments

Comments
 (0)