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 ;
44use rspack_collections:: { IdentifierMap , IdentifierSet } ;
5- use rspack_core:: Compilation ;
5+ use rspack_core:: { Compilation , Module , SourceType } ;
66use rspack_util:: fx_hash:: { FxHashMap , FxHashSet } ;
77use sugar_path:: SugarPath ;
88
99use crate :: EsmLibraryPlugin ;
1010
11- static EXTENSION_JS : LazyLock < Regex > =
12- LazyLock :: new ( || Regex :: new ( r".+(\..+)$" ) . expect ( "failed to compile EXTENSION_REGEXP" ) ) ;
13-
1411pub 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+
5386pub 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