Skip to content

Commit 49640dc

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 49640dc

16 files changed

Lines changed: 347 additions & 134 deletions

File tree

crates/rspack_plugin_esm_library/src/preserve_modules.rs

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

3-
use regex::Regex;
3+
use cow_utils::CowUtils;
44
use rspack_collections::{IdentifierMap, IdentifierSet};
5-
use rspack_core::Compilation;
5+
use rspack_core::{Compilation, Module, SourceType};
66
use rspack_util::fx_hash::{FxHashMap, FxHashSet};
77
use sugar_path::SugarPath;
88

99
use crate::EsmLibraryPlugin;
1010

11-
static EXTENSION_JS: LazyLock<Regex> =
12-
LazyLock::new(|| Regex::new(r".+(\..+)$").expect("failed to compile EXTENSION_REGEXP"));
13-
1411
pub fn entry_modules(compilation: &Compilation) -> FxHashMap<String, IdentifierSet> {
1512
let module_graph = compilation.get_module_graph();
1613
compilation
@@ -50,6 +47,42 @@ pub fn entry_name_for_module(
5047
entry_name_for_module
5148
}
5249

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

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-
};
111+
let abs_path = {
112+
let module_graph = compilation.get_module_graph();
113+
let module = module_graph
114+
.module_by_identifier(&module_id)
115+
.expect("should have module");
86116

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;
117+
if !should_preserve_module(module.as_ref(), module_graph) {
118+
continue;
119+
}
120+
let Some(abs_path) = module_resource_path(module.as_ref()) else {
121+
continue;
122+
};
123+
abs_path
94124
};
125+
if !abs_path.starts_with(root) {
126+
continue;
127+
}
128+
95129
let chunk = match EsmLibraryPlugin::get_module_chunk(module_id, compilation) {
96130
Ok(c) => c,
97131
Err(e) => {
98132
errors.push(e.into());
99133
continue;
100134
}
101135
};
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-
};
134136

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-
}
137+
// Compute the `chunk.name` from the source path.
138+
//
139+
// Following Rollup's approach: strip the file extension and let the
140+
// per-type output template add the correct one back. rspack has separate
141+
// templates for each output type (`output.filename` → `[name].mjs` for
142+
// JS, `output.cssFilename` → `[name].css` for CSS, etc.), so we always
143+
// strip the source extension regardless of module type. This keeps
144+
// preserve_modules completely type-agnostic for naming.
145+
let file_path = abs_path.relative(root);
146+
// Normalize to forward slashes so chunk names (and the asset paths
147+
// derived from them) are consistent across platforms.
148+
let file_path_lossy = file_path
149+
.to_string_lossy()
150+
.cow_replace('\\', "/")
151+
.into_owned();
152+
let base_name: String = if let Some(extension) = file_path.extension() {
153+
let suffix = format!(".{}", extension.to_string_lossy());
154+
file_path_lossy
155+
.strip_suffix(&suffix)
156+
.unwrap_or(&file_path_lossy)
157+
.to_string()
158+
} else {
159+
file_path_lossy
160+
};
151161

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

157-
if compilation
179+
entry_names.iter().next().cloned()
180+
} else {
181+
None
182+
};
183+
184+
if compilation
185+
.build_chunk_graph_artifact
186+
.chunk_graph
187+
.get_chunk_modules_identifier(&chunk)
188+
.len()
189+
== 1
190+
{
191+
// This is the last module in the chunk — rename in-place.
192+
let old_chunk = compilation
158193
.build_chunk_graph_artifact
159-
.chunk_graph
160-
.get_chunk_modules_identifier(&chunk)
161-
.len()
162-
== 1
194+
.chunk_by_ukey
195+
.expect_get_mut(&chunk);
196+
if let Some(old_name) = old_chunk.name().map(|s| s.to_string())
197+
&& old_name != base_name
163198
{
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;
199+
compilation
200+
.build_chunk_graph_artifact
201+
.named_chunks
202+
.remove(&old_name);
167203
}
168-
169-
let new_chunk_ukey =
170-
Compilation::add_chunk(&mut compilation.build_chunk_graph_artifact.chunk_by_ukey);
204+
old_chunk.set_name(Some(base_name.clone()));
171205
compilation
172206
.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-
};
207+
.named_chunks
208+
.insert(base_name, chunk);
209+
continue;
210+
}
182211

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
212+
let new_chunk_ukey =
213+
Compilation::add_chunk(&mut compilation.build_chunk_graph_artifact.chunk_by_ukey);
214+
compilation
215+
.build_chunk_graph_artifact
216+
.chunk_graph
217+
.add_chunk(new_chunk_ukey);
218+
let [Some(new_chunk), Some(old_chunk)] = compilation
219+
.build_chunk_graph_artifact
220+
.chunk_by_ukey
221+
.get_many_mut([&new_chunk_ukey, &chunk])
222+
else {
223+
unreachable!("new_chunk and old_chunk should be inserted already")
224+
};
225+
226+
new_chunk.set_name(Some(base_name.clone()));
227+
old_chunk.split(
228+
new_chunk,
229+
&mut compilation.build_chunk_graph_artifact.chunk_group_by_ukey,
230+
);
231+
compilation
232+
.build_chunk_graph_artifact
233+
.named_chunks
234+
.insert(base_name.clone(), new_chunk_ukey);
235+
236+
// disconnect module from other chunks
237+
compilation
238+
.build_chunk_graph_artifact
239+
.chunk_graph
240+
.disconnect_chunk_and_module(&chunk, module_id);
241+
242+
compilation
243+
.build_chunk_graph_artifact
244+
.chunk_graph
245+
.connect_chunk_and_module(new_chunk_ukey, module_id);
246+
247+
if let Some(entry_name) = entry_name {
189248
compilation
190249
.build_chunk_graph_artifact
191250
.chunk_graph
192-
.disconnect_chunk_and_module(&chunk, module_id);
251+
.disconnect_chunk_and_entry_module(&chunk, module_id);
252+
253+
let entrypoint = compilation.entrypoint_by_name_mut(&entry_name);
254+
let ukey = entrypoint.ukey;
255+
entrypoint.set_entrypoint_chunk(new_chunk_ukey);
193256

194257
compilation
195258
.build_chunk_graph_artifact
196259
.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);
260+
.connect_chunk_and_entry_module(new_chunk_ukey, module_id, ukey);
208261

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()));
262+
// Remove the entry name from the old chunk to avoid filename conflicts.
263+
// Without this, the old chunk retains its entry name (e.g. "index") and
264+
// its output falls back to `[name].mjs` → "index.mjs", conflicting with
265+
// the new chunk that already owns that name.
266+
let old_chunk = compilation
267+
.build_chunk_graph_artifact
268+
.chunk_by_ukey
269+
.expect_get_mut(&chunk);
270+
if let Some(old_name) = old_chunk.name().map(|s| s.to_string()) {
271+
old_chunk.set_name(None);
272+
if old_name != base_name {
231273
compilation
232274
.build_chunk_graph_artifact
233275
.named_chunks
234-
.insert(name, new_chunk_ukey);
276+
.remove(&old_name);
235277
}
236278
}
237279
}

0 commit comments

Comments
 (0)