diff --git a/crates/rspack_core/src/utils/property_name.rs b/crates/rspack_core/src/utils/property_name.rs index d652a03202c0..e69d7c0eeebe 100644 --- a/crates/rspack_core/src/utils/property_name.rs +++ b/crates/rspack_core/src/utils/property_name.rs @@ -70,3 +70,17 @@ pub fn property_name(prop: &str) -> Result> { .map(Cow::from) } } + +/// Returns the name quoted for ESM export/import specifiers. +/// Unlike `property_name`, this function allows reserved words like "default" +/// since they are valid in export/import context (e.g., `export { foo as default }`). +/// Only quotes names that are not valid JavaScript identifiers (e.g., "a.b" -> "\"a.b\""). +pub fn export_name(name: &str) -> Result> { + if SAFE_IDENTIFIER.is_match(name) { + Ok(Cow::from(name)) + } else { + serde_json::to_string(name) + .to_rspack_result() + .map(Cow::from) + } +} diff --git a/crates/rspack_plugin_esm_library/src/render.rs b/crates/rspack_plugin_esm_library/src/render.rs index 79eaa5d93082..1af74096b69c 100644 --- a/crates/rspack_plugin_esm_library/src/render.rs +++ b/crates/rspack_plugin_esm_library/src/render.rs @@ -5,8 +5,8 @@ use rspack_collections::{IdentifierIndexSet, UkeyIndexMap, UkeySet}; use rspack_core::{ AssetInfo, Chunk, ChunkGraph, ChunkRenderContext, ChunkUkey, CodeGenerationDataFilename, Compilation, ConcatenatedModuleInfo, DependencyId, InitFragment, ModuleIdentifier, PathData, - PathInfo, RuntimeGlobals, RuntimeVariable, SourceType, get_js_chunk_filename_template, - get_undo_path, render_init_fragments, + PathInfo, RuntimeGlobals, RuntimeVariable, SourceType, export_name, + get_js_chunk_filename_template, get_undo_path, render_init_fragments, rspack_sources::{ConcatSource, RawStringSource, ReplaceSource, Source, SourceExt}, }; use rspack_error::Result; @@ -374,10 +374,12 @@ var {} = {{}}; } else { let mut imports = Vec::new(); for (atom, local) in symbols.atoms.iter() { + let atom_name = export_name(atom).expect("should have export_name"); if atom == local { - imports.push(atom.to_string()); + imports.push(atom_name.into_owned()); } else { - imports.push(format!("{atom} as {local}")); + let local_name = export_name(local).expect("should have export_name"); + imports.push(format!("{atom_name} as {local_name}")); } } format!( @@ -436,10 +438,12 @@ var {} = {{}}; imported .iter() .map(|(imported, local)| { + let imported_name = export_name(imported).expect("should have export_name"); if imported == local { - Cow::Borrowed(imported.as_str()) + imported_name.into_owned() } else { - Cow::Owned(format!("{imported} as {local}")) + let local_name = export_name(local).expect("should have export_name"); + format!("{imported_name} as {local_name}") } }) .collect::>() @@ -478,20 +482,32 @@ var {} = {{}}; for (raw_symbol, exports) in exports { let mut exports = exports.iter().collect::>(); exports.sort_unstable(); - for export_name in exports { - let is_default = export_name.as_str() == "default"; + for exported_name in exports { + let is_default = exported_name.as_str() == "default"; if is_default { if export_default.is_none() { export_default = Some(raw_symbol); } else { // multiple export default - export_specifiers.insert(Cow::Borrowed(raw_symbol)); + export_specifiers.insert(Cow::Owned( + export_name(raw_symbol) + .expect("should have export_name") + .into_owned(), + )); } - } else if raw_symbol == export_name { - export_specifiers.insert(Cow::Borrowed(raw_symbol)); + } else if raw_symbol == exported_name { + export_specifiers.insert(Cow::Owned( + export_name(raw_symbol) + .expect("should have export_name") + .into_owned(), + )); } else { - export_specifiers.insert(Cow::Owned(format!("{raw_symbol} as {export_name}"))); + let raw_symbol_name = export_name(raw_symbol).expect("should have export_name"); + let exported_name_str = export_name(exported_name).expect("should have export_name"); + export_specifiers.insert(Cow::Owned(format!( + "{raw_symbol_name} as {exported_name_str}" + ))); } } } @@ -516,8 +532,9 @@ var {} = {{}}; serde_json::to_string(source).expect("should have correct request") ))); } else { + let name_str = export_name(name).expect("should have export_name"); final_source.add(RawStringSource::from(format!( - "export * as {name} from {};\n", + "export * as {name_str} from {};\n", serde_json::to_string(source).expect("should have correct request") ))); } @@ -536,11 +553,16 @@ var {} = {{}}; .flat_map(|(imported, exports)| { let mut vec = exports.iter().collect::>(); vec.sort_unstable(); - vec.into_iter().map(move |export_name| { - if *imported == export_name { - Cow::Borrowed(imported.as_str()) + let imported_name = export_name(imported) + .expect("should have export_name") + .into_owned(); + vec.into_iter().map(move |exported_name| { + if *imported == exported_name { + imported_name.clone() } else { - Cow::Owned(format!("{imported} as {export_name}")) + let exported_name_str = + export_name(exported_name).expect("should have export_name"); + format!("{imported_name} as {exported_name_str}") } }) }) diff --git a/tests/rspack-test/esmOutputCases/re-exports/string-specifier/__snapshots__/esm.snap.txt b/tests/rspack-test/esmOutputCases/re-exports/string-specifier/__snapshots__/esm.snap.txt new file mode 100644 index 000000000000..e933c255c7a3 --- /dev/null +++ b/tests/rspack-test/esmOutputCases/re-exports/string-specifier/__snapshots__/esm.snap.txt @@ -0,0 +1,27 @@ +```mjs title=lib-lib_js.mjs +// ./lib.js +const foo = () => 42 + + +export { foo as "b.c" }; + +``` + +```mjs title=main.mjs +import { "b.c" as lib_foo } from "./lib-lib_js.mjs"; + +// ./index.js + + +console.log.bind(lib_foo) + + + +it('should have correct export', async () => { + const { "b.c": foo } = await import(/* webpackIgnore: true */ './main.mjs') + + expect(foo()).toBe(42) +}) +export { "b.c" } from "./lib-lib_js.mjs"; + +``` \ No newline at end of file diff --git a/tests/rspack-test/esmOutputCases/re-exports/string-specifier/index.js b/tests/rspack-test/esmOutputCases/re-exports/string-specifier/index.js new file mode 100644 index 000000000000..3dc45d061d0a --- /dev/null +++ b/tests/rspack-test/esmOutputCases/re-exports/string-specifier/index.js @@ -0,0 +1,11 @@ +import { "a.b" as lib } from './lib'; + +console.log.bind(lib) + +export {lib as "b.c"} + +it('should have correct export', async () => { + const { "b.c": foo } = await import(/* webpackIgnore: true */ './main.mjs') + + expect(foo()).toBe(42) +}) \ No newline at end of file diff --git a/tests/rspack-test/esmOutputCases/re-exports/string-specifier/lib.js b/tests/rspack-test/esmOutputCases/re-exports/string-specifier/lib.js new file mode 100644 index 000000000000..2b33c966dfaa --- /dev/null +++ b/tests/rspack-test/esmOutputCases/re-exports/string-specifier/lib.js @@ -0,0 +1,3 @@ +const foo = () => 42 + +export {foo as "a.b"} \ No newline at end of file diff --git a/tests/rspack-test/esmOutputCases/re-exports/string-specifier/rspack.config.js b/tests/rspack-test/esmOutputCases/re-exports/string-specifier/rspack.config.js new file mode 100644 index 000000000000..e73e5e1bd4dd --- /dev/null +++ b/tests/rspack-test/esmOutputCases/re-exports/string-specifier/rspack.config.js @@ -0,0 +1,19 @@ +module.exports = { + module: { + rules: [ + { + test: /\.json$/, + type: 'json' + } + ] + }, + optimization: { + splitChunks: { + cacheGroups: { + lib: { + test: /lib\.js/ + } + } + } + } +};