Skip to content

Commit a2e272a

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 c88a8d5 commit a2e272a

16 files changed

Lines changed: 343 additions & 136 deletions

File tree

crates/rspack_plugin_esm_library/src/preserve_modules.rs

Lines changed: 172 additions & 136 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,167 @@ 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-
};
134-
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-
}
151135

152-
entry_names.iter().next().cloned()
153-
} else {
154-
None
155-
};
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+
let file_path_lossy = file_path.to_string_lossy().into_owned();
146+
let base_name: String = if let Some(extension) = file_path.extension() {
147+
let suffix = format!(".{}", extension.to_string_lossy());
148+
file_path_lossy
149+
.strip_suffix(&suffix)
150+
.unwrap_or(&file_path_lossy)
151+
.to_string()
152+
} else {
153+
file_path_lossy
154+
};
156155

157-
if compilation
158-
.build_chunk_graph_artifact
159-
.chunk_graph
160-
.get_chunk_modules_identifier(&chunk)
161-
.len()
162-
== 1
163-
{
164-
// this is last module in chunk, we can keep this chunk, just rename it
165-
old_chunk.set_filename_template(Some(new_filename));
156+
let entry_name = if let Some(entry_names) = entry_name_for_module.get(&module_id) {
157+
if entry_names.len() > 1 {
158+
errors.push(
159+
rspack_error::error!(
160+
"{} is used in multiple entries: [{}], this is not allowed in preserveModules",
161+
module_id,
162+
entry_names
163+
.iter()
164+
.map(|s| s.as_str())
165+
.collect::<Vec<_>>()
166+
.join(", ")
167+
)
168+
.into(),
169+
);
166170
continue;
167171
}
168172

169-
let new_chunk_ukey =
170-
Compilation::add_chunk(&mut compilation.build_chunk_graph_artifact.chunk_by_ukey);
171-
compilation
172-
.build_chunk_graph_artifact
173-
.chunk_graph
174-
.add_chunk(new_chunk_ukey);
175-
let [Some(new_chunk), Some(old_chunk)] = compilation
173+
entry_names.iter().next().cloned()
174+
} else {
175+
None
176+
};
177+
178+
if compilation
179+
.build_chunk_graph_artifact
180+
.chunk_graph
181+
.get_chunk_modules_identifier(&chunk)
182+
.len()
183+
== 1
184+
{
185+
// This is the last module in the chunk — rename in-place.
186+
let old_chunk = compilation
176187
.build_chunk_graph_artifact
177188
.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-
};
189+
.expect_get_mut(&chunk);
190+
if let Some(old_name) = old_chunk.name().map(|s| s.to_string()) {
191+
if old_name != base_name {
192+
compilation
193+
.build_chunk_graph_artifact
194+
.named_chunks
195+
.remove(&old_name);
196+
}
197+
}
198+
old_chunk.set_name(Some(base_name.clone()));
199+
compilation
200+
.build_chunk_graph_artifact
201+
.named_chunks
202+
.insert(base_name, chunk);
203+
continue;
204+
}
182205

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

194251
compilation
195252
.build_chunk_graph_artifact
196253
.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);
208-
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()));
254+
.connect_chunk_and_entry_module(new_chunk_ukey, module_id, ukey);
255+
256+
// Remove the entry name from the old chunk to avoid filename conflicts.
257+
// Without this, the old chunk retains its entry name (e.g. "index") and
258+
// its output falls back to `[name].mjs` → "index.mjs", conflicting with
259+
// the new chunk that already owns that name.
260+
let old_chunk = compilation
261+
.build_chunk_graph_artifact
262+
.chunk_by_ukey
263+
.expect_get_mut(&chunk);
264+
if let Some(old_name) = old_chunk.name().map(|s| s.to_string()) {
265+
old_chunk.set_name(None);
266+
if old_name != base_name {
231267
compilation
232268
.build_chunk_graph_artifact
233269
.named_chunks
234-
.insert(name, new_chunk_ukey);
270+
.remove(&old_name);
235271
}
236272
}
237273
}

0 commit comments

Comments
 (0)