diff --git a/packages/next-swc/crates/core/src/lib.rs b/packages/next-swc/crates/core/src/lib.rs index 11aea351c7b38..24ec042918138 100644 --- a/packages/next-swc/crates/core/src/lib.rs +++ b/packages/next-swc/crates/core/src/lib.rs @@ -258,9 +258,8 @@ where None => Either::Right(noop()), }, match &opts.optimize_barrel_exports { - Some(config) => Either::Left(optimize_barrel::optimize_barrel( - file.name.clone(),config.clone())), - None => Either::Right(noop()), + Some(config) => Either::Left(optimize_barrel::optimize_barrel(config.clone())), + _ => Either::Right(noop()), }, opts.emotion .as_ref() diff --git a/packages/next-swc/crates/core/src/named_import_transform.rs b/packages/next-swc/crates/core/src/named_import_transform.rs index 4e95899cb4ee3..df76e0c6f12b4 100644 --- a/packages/next-swc/crates/core/src/named_import_transform.rs +++ b/packages/next-swc/crates/core/src/named_import_transform.rs @@ -61,10 +61,7 @@ impl Fold for NamedImportTransform { if !skip_transform { let names = specifier_names.join(","); - let new_src = format!( - "__barrel_optimize__?names={}!=!{}?__barrel_optimize_noop__={}", - names, src_value, names, - ); + let new_src = format!("__barrel_optimize__?names={}!=!{}", names, src_value); // Create a new import declaration, keep everything the same except the source let mut new_decl = decl.clone(); diff --git a/packages/next-swc/crates/core/src/optimize_barrel.rs b/packages/next-swc/crates/core/src/optimize_barrel.rs index 0fee0a2e3e955..52d6a518b7569 100644 --- a/packages/next-swc/crates/core/src/optimize_barrel.rs +++ b/packages/next-swc/crates/core/src/optimize_barrel.rs @@ -1,59 +1,63 @@ +use std::collections::HashMap; + use serde::Deserialize; use turbopack_binding::swc::core::{ - common::{FileName, DUMMY_SP}, - ecma::{ - ast::*, - utils::{private_ident, quote_str}, - visit::Fold, - }, + common::DUMMY_SP, + ecma::{ast::*, utils::private_ident, visit::Fold}, }; #[derive(Clone, Debug, Deserialize)] pub struct Config { - pub names: Vec, + pub wildcard: bool, } -pub fn optimize_barrel(filename: FileName, config: Config) -> impl Fold { +pub fn optimize_barrel(config: Config) -> impl Fold { OptimizeBarrel { - filepath: filename.to_string(), - names: config.names, + wildcard: config.wildcard, } } #[derive(Debug, Default)] struct OptimizeBarrel { - filepath: String, - names: Vec, + wildcard: bool, } impl Fold for OptimizeBarrel { fn fold_module_items(&mut self, items: Vec) -> Vec { - // One pre-pass to find all the local idents that we are referencing. - let mut local_idents = vec![]; - for item in &items { - if let ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(export_named)) = item { - if export_named.src.is_none() { - for spec in &export_named.specifiers { - if let ExportSpecifier::Named(s) = spec { - let str_name; - if let Some(name) = &s.exported { - str_name = match &name { - ModuleExportName::Ident(n) => n.sym.to_string(), - ModuleExportName::Str(n) => n.value.to_string(), - }; - } else { - str_name = match &s.orig { - ModuleExportName::Ident(n) => n.sym.to_string(), - ModuleExportName::Str(n) => n.value.to_string(), - }; - } + // One pre-pass to find all the local idents that we are referencing, so we can + // handle the case of `import foo from 'a'; export { foo };` correctly. - // If the exported name needs to be kept, track the local ident. - if self.names.contains(&str_name) { - if let ModuleExportName::Ident(i) = &s.orig { - local_idents.push(i.sym.clone()); - } - } + // Map of "local ident" -> ("source module", "orig ident") + let mut local_idents = HashMap::new(); + for item in &items { + if let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = item { + for spec in &import_decl.specifiers { + let src = import_decl.src.value.to_string(); + match spec { + ImportSpecifier::Named(s) => { + local_idents.insert( + s.local.sym.to_string(), + ( + src.clone(), + match &s.imported { + Some(n) => match &n { + ModuleExportName::Ident(n) => n.sym.to_string(), + ModuleExportName::Str(n) => n.value.to_string(), + }, + None => s.local.sym.to_string(), + }, + ), + ); + } + ImportSpecifier::Namespace(s) => { + local_idents + .insert(s.local.sym.to_string(), (src.clone(), "*".to_string())); + } + ImportSpecifier::Default(s) => { + local_idents.insert( + s.local.sym.to_string(), + (src.clone(), "default".to_string()), + ); } } } @@ -63,6 +67,10 @@ impl Fold for OptimizeBarrel { // The second pass to rebuild the module items. let mut new_items = vec![]; + // Exported meta information. + let mut export_map = vec![]; + let mut export_wildcards = vec![]; + // We only apply this optimization to barrel files. Here we consider // a barrel file to be a file that only exports from other modules. // Besides that, lit expressions are allowed as well ("use client", etc.). @@ -71,6 +79,7 @@ impl Fold for OptimizeBarrel { match item { ModuleItem::ModuleDecl(decl) => { match decl { + ModuleDecl::Import(_) => {} // export { foo } from './foo'; ModuleDecl::ExportNamed(export_named) => { for spec in &export_named.specifiers { @@ -80,142 +89,110 @@ impl Fold for OptimizeBarrel { ModuleExportName::Ident(n) => n.sym.to_string(), ModuleExportName::Str(n) => n.value.to_string(), }; - if self.names.contains(&name_str) { - new_items.push(item.clone()); + if let Some(src) = &export_named.src { + export_map.push(( + name_str.clone(), + src.value.to_string(), + "*".to_string(), + )); + } else if self.wildcard { + export_map.push(( + name_str.clone(), + "".into(), + "*".to_string(), + )); + } else { + is_barrel = false; + break; } } ExportSpecifier::Named(s) => { - if let Some(name) = &s.exported { - let name_str = match &name { + let orig_str = match &s.orig { + ModuleExportName::Ident(n) => n.sym.to_string(), + ModuleExportName::Str(n) => n.value.to_string(), + }; + let name_str = match &s.exported { + Some(n) => match &n { ModuleExportName::Ident(n) => n.sym.to_string(), ModuleExportName::Str(n) => n.value.to_string(), - }; + }, + None => orig_str.clone(), + }; - if self.names.contains(&name_str) { - new_items.push(ModuleItem::ModuleDecl( - ModuleDecl::ExportNamed(NamedExport { - span: DUMMY_SP, - specifiers: vec![ExportSpecifier::Named( - ExportNamedSpecifier { - span: DUMMY_SP, - orig: s.orig.clone(), - exported: Some( - ModuleExportName::Ident( - Ident::new( - name_str.into(), - DUMMY_SP, - ), - ), - ), - is_type_only: false, - }, - )], - src: export_named.src.clone(), - type_only: false, - asserts: None, - }), - )); - } + if let Some(src) = &export_named.src { + export_map.push(( + name_str.clone(), + src.value.to_string(), + orig_str.clone(), + )); + } else if let Some((src, orig)) = + local_idents.get(&orig_str) + { + export_map.push(( + name_str.clone(), + src.clone(), + orig.clone(), + )); + } else if self.wildcard { + export_map.push(( + name_str.clone(), + "".into(), + orig_str.clone(), + )); } else { - let name_str = match &s.orig { - ModuleExportName::Ident(n) => n.sym.to_string(), - ModuleExportName::Str(n) => n.value.to_string(), - }; - - if self.names.contains(&name_str) { - new_items.push(ModuleItem::ModuleDecl( - ModuleDecl::ExportNamed(NamedExport { - span: DUMMY_SP, - specifiers: vec![ExportSpecifier::Named( - ExportNamedSpecifier { - span: DUMMY_SP, - orig: s.orig.clone(), - exported: None, - is_type_only: false, - }, - )], - src: export_named.src.clone(), - type_only: false, - asserts: None, - }), - )); - } + is_barrel = false; + break; } } _ => { - is_barrel = false; - break; + if !self.wildcard { + is_barrel = false; + break; + } } } } } - // Keep import statements that create the local idents we need. - ModuleDecl::Import(import_decl) => { - for spec in &import_decl.specifiers { - match spec { - ImportSpecifier::Named(s) => { - if local_idents.contains(&s.local.sym) { - new_items.push(ModuleItem::ModuleDecl( - ModuleDecl::Import(ImportDecl { - span: DUMMY_SP, - specifiers: vec![ImportSpecifier::Named( - ImportNamedSpecifier { - span: DUMMY_SP, - local: s.local.clone(), - imported: s.imported.clone(), - is_type_only: false, - }, - )], - src: import_decl.src.clone(), - type_only: false, - asserts: None, - }), - )); - } - } - ImportSpecifier::Default(s) => { - if local_idents.contains(&s.local.sym) { - new_items.push(ModuleItem::ModuleDecl( - ModuleDecl::Import(ImportDecl { - span: DUMMY_SP, - specifiers: vec![ImportSpecifier::Default( - ImportDefaultSpecifier { - span: DUMMY_SP, - local: s.local.clone(), - }, - )], - src: import_decl.src.clone(), - type_only: false, - asserts: None, - }), - )); - } - } - ImportSpecifier::Namespace(s) => { - if local_idents.contains(&s.local.sym) { - new_items.push(ModuleItem::ModuleDecl( - ModuleDecl::Import(ImportDecl { - span: DUMMY_SP, - specifiers: vec![ImportSpecifier::Namespace( - ImportStarAsSpecifier { - span: DUMMY_SP, - local: s.local.clone(), - }, - )], - src: import_decl.src.clone(), - type_only: false, - asserts: None, - }), - )); - } + ModuleDecl::ExportAll(export_all) => { + export_wildcards.push(export_all.src.value.to_string()); + } + ModuleDecl::ExportDecl(export_decl) => { + // Export declarations are not allowed in barrel files. + if !self.wildcard { + is_barrel = false; + break; + } + + match &export_decl.decl { + Decl::Class(class) => { + export_map.push(( + class.ident.sym.to_string(), + "".into(), + "".into(), + )); + } + Decl::Fn(func) => { + export_map.push(( + func.ident.sym.to_string(), + "".into(), + "".into(), + )); + } + Decl::Var(var) => { + let ids = collect_idents_in_var_decls(&var.decls); + for id in ids { + export_map.push((id, "".into(), "".into())); } } + _ => {} } } _ => { - // Export expressions are not allowed in barrel files. - is_barrel = false; - break; + if !self.wildcard { + // Other expressions are not allowed in barrel files. + is_barrel = false; + break; + } } } } @@ -225,44 +202,149 @@ impl Fold for OptimizeBarrel { new_items.push(item.clone()); } _ => { - is_barrel = false; - break; + if !self.wildcard { + is_barrel = false; + break; + } } }, _ => { - is_barrel = false; - break; + if !self.wildcard { + is_barrel = false; + break; + } } }, } } - // If the file is not a barrel file, we need to create a new module that - // re-exports from the original file. - // This is to avoid creating multiple instances of the original module. + // If the file is not a barrel file, we export nothing. if !is_barrel { - new_items = vec![ModuleItem::ModuleDecl(ModuleDecl::ExportNamed( - NamedExport { + new_items = vec![]; + } else { + // Otherwise we export the meta information. + new_items.push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: Decl::Var(Box::new(VarDecl { span: DUMMY_SP, - specifiers: self - .names - .iter() - .map(|name| { - ExportSpecifier::Named(ExportNamedSpecifier { - span: DUMMY_SP, - orig: ModuleExportName::Ident(private_ident!(name.clone())), - exported: None, - is_type_only: false, - }) - }) - .collect(), - src: Some(Box::new(quote_str!(self.filepath.to_string()))), - type_only: false, + kind: VarDeclKind::Const, + declare: false, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(BindingIdent { + id: private_ident!("__next_private_export_map__"), + type_ann: None, + }), + init: Some(Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: serde_json::to_string(&export_map).unwrap().into(), + raw: None, + })))), + definite: false, + }], + })), + }))); + + // Push "export *" statements for each wildcard export. + for src in export_wildcards { + new_items.push(ModuleItem::ModuleDecl(ModuleDecl::ExportAll(ExportAll { + span: DUMMY_SP, + src: Box::new(Str { + span: DUMMY_SP, + value: format!("__barrel_optimize__?names=__PLACEHOLDER__!=!{}", src) + .into(), + raw: None, + }), asserts: None, - }, - ))]; + type_only: false, + }))); + } } new_items } } + +fn collect_idents_in_array_pat(elems: &[Option]) -> Vec { + let mut ids = Vec::new(); + + for elem in elems.iter().flatten() { + match elem { + Pat::Ident(ident) => { + ids.push(ident.sym.to_string()); + } + Pat::Array(array) => { + ids.extend(collect_idents_in_array_pat(&array.elems)); + } + Pat::Object(object) => { + ids.extend(collect_idents_in_object_pat(&object.props)); + } + Pat::Rest(rest) => { + if let Pat::Ident(ident) = &*rest.arg { + ids.push(ident.sym.to_string()); + } + } + _ => {} + } + } + + ids +} + +fn collect_idents_in_object_pat(props: &[ObjectPatProp]) -> Vec { + let mut ids = Vec::new(); + + for prop in props { + match prop { + ObjectPatProp::KeyValue(KeyValuePatProp { key, value }) => { + if let PropName::Ident(ident) = key { + ids.push(ident.sym.to_string()); + } + + match &**value { + Pat::Ident(ident) => { + ids.push(ident.sym.to_string()); + } + Pat::Array(array) => { + ids.extend(collect_idents_in_array_pat(&array.elems)); + } + Pat::Object(object) => { + ids.extend(collect_idents_in_object_pat(&object.props)); + } + _ => {} + } + } + ObjectPatProp::Assign(AssignPatProp { key, .. }) => { + ids.push(key.to_string()); + } + ObjectPatProp::Rest(RestPat { arg, .. }) => { + if let Pat::Ident(ident) = &**arg { + ids.push(ident.sym.to_string()); + } + } + } + } + + ids +} + +fn collect_idents_in_var_decls(decls: &[VarDeclarator]) -> Vec { + let mut ids = Vec::new(); + + for decl in decls { + match &decl.name { + Pat::Ident(ident) => { + ids.push(ident.sym.to_string()); + } + Pat::Array(array) => { + ids.extend(collect_idents_in_array_pat(&array.elems)); + } + Pat::Object(object) => { + ids.extend(collect_idents_in_object_pat(&object.props)); + } + _ => {} + } + } + + ids +} diff --git a/packages/next-swc/crates/core/tests/fixture.rs b/packages/next-swc/crates/core/tests/fixture.rs index 8046417205f1f..bb2a627ae4cc6 100644 --- a/packages/next-swc/crates/core/tests/fixture.rs +++ b/packages/next-swc/crates/core/tests/fixture.rs @@ -482,7 +482,7 @@ fn named_import_transform_fixture(input: PathBuf) { ); } -#[fixture("tests/fixture/optimize-barrel/**/input.js")] +#[fixture("tests/fixture/optimize-barrel/normal/**/input.js")] fn optimize_barrel_fixture(input: PathBuf) { let output = input.parent().unwrap().join("output.js"); test_fixture( @@ -493,16 +493,39 @@ fn optimize_barrel_fixture(input: PathBuf) { chain!( resolver(unresolved_mark, top_level_mark, false), - optimize_barrel( - FileName::Real(PathBuf::from("/some-project/node_modules/foo/file.js")), - json( - r#" + optimize_barrel(json( + r#" { - "names": ["x", "y", "z"] + "wildcard": false } - "# - ) - ) + "# + )) + ) + }, + &input, + &output, + Default::default(), + ); +} + +#[fixture("tests/fixture/optimize-barrel/wildcard/**/input.js")] +fn optimize_barrel_wildcard_fixture(input: PathBuf) { + let output = input.parent().unwrap().join("output.js"); + test_fixture( + syntax(), + &|_tr| { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + + chain!( + resolver(unresolved_mark, top_level_mark, false), + optimize_barrel(json( + r#" + { + "wildcard": true + } + "# + )) ) }, &input, diff --git a/packages/next-swc/crates/core/tests/fixture/named-import-transform/1/output.js b/packages/next-swc/crates/core/tests/fixture/named-import-transform/1/output.js index 53fc2884247cf..1bd90351be658 100644 --- a/packages/next-swc/crates/core/tests/fixture/named-import-transform/1/output.js +++ b/packages/next-swc/crates/core/tests/fixture/named-import-transform/1/output.js @@ -1,3 +1,3 @@ -import { A, B, C as F } from "__barrel_optimize__?names=A,B,C!=!foo?__barrel_optimize_noop__=A,B,C"; +import { A, B, C as F } from "__barrel_optimize__?names=A,B,C!=!foo"; import D from 'bar'; import E from 'baz'; diff --git a/packages/next-swc/crates/core/tests/fixture/named-import-transform/2/output.js b/packages/next-swc/crates/core/tests/fixture/named-import-transform/2/output.js index b4d1a9b11dece..d6e92160e66ac 100644 --- a/packages/next-swc/crates/core/tests/fixture/named-import-transform/2/output.js +++ b/packages/next-swc/crates/core/tests/fixture/named-import-transform/2/output.js @@ -1,3 +1,3 @@ -import { A, B, C as F } from "__barrel_optimize__?names=A,B,C!=!foo?__barrel_optimize_noop__=A,B,C"; -import { D } from "__barrel_optimize__?names=D!=!bar?__barrel_optimize_noop__=D"; +import { A, B, C as F } from "__barrel_optimize__?names=A,B,C!=!foo"; +import { D } from "__barrel_optimize__?names=D!=!bar"; import E from 'baz'; diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/1/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/1/output.js deleted file mode 100644 index 0edcd356f6fc2..0000000000000 --- a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/1/output.js +++ /dev/null @@ -1,3 +0,0 @@ -export { b as y } from './1'; -export { x } from './2'; -export { z }; diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/2/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/2/output.js deleted file mode 100644 index 3f5d1a74b87bd..0000000000000 --- a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/2/output.js +++ /dev/null @@ -1,2 +0,0 @@ -// De-optimize this file -export { x, y, z } from "/some-project/node_modules/foo/file.js"; diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/3/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/3/output.js deleted file mode 100644 index 3f5d1a74b87bd..0000000000000 --- a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/3/output.js +++ /dev/null @@ -1,2 +0,0 @@ -// De-optimize this file -export { x, y, z } from "/some-project/node_modules/foo/file.js"; diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/4/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/4/output.js deleted file mode 100644 index 2a8069f942f66..0000000000000 --- a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/4/output.js +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; -import { a } from 'foo' -import z from 'bar' - -export { a as x }; -export { y } from '1'; -export { z }; diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/5/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/5/output.js deleted file mode 100644 index 605b446a453c6..0000000000000 --- a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/5/output.js +++ /dev/null @@ -1,2 +0,0 @@ -import * as index from './icons/index.js' -export { index as x } diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/1/input.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/1/input.js similarity index 74% rename from packages/next-swc/crates/core/tests/fixture/optimize-barrel/1/input.js rename to packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/1/input.js index 747c0573c9167..593c41c13a409 100644 --- a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/1/input.js +++ b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/1/input.js @@ -1,3 +1,5 @@ +import { z } from './3' + export { foo, b as y } from './1' export { x, a } from './2' export { z } diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/1/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/1/output.js new file mode 100644 index 0000000000000..561d951c9b3b8 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/1/output.js @@ -0,0 +1 @@ +export const __next_private_export_map__ = '[["foo","./1","foo"],["y","./1","b"],["x","./2","x"],["a","./2","a"],["z","./3","z"]]'; \ No newline at end of file diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/2/input.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/2/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/optimize-barrel/2/input.js rename to packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/2/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/2/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/2/output.js new file mode 100644 index 0000000000000..3fe5e5efedc73 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/2/output.js @@ -0,0 +1 @@ +// De-optimize this file \ No newline at end of file diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/3/input.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/3/input.js similarity index 55% rename from packages/next-swc/crates/core/tests/fixture/optimize-barrel/3/input.js rename to packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/3/input.js index 630019266de2e..1e085fea00d3a 100644 --- a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/3/input.js +++ b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/3/input.js @@ -1,6 +1,7 @@ -// De-optimize this file +import { a as z } from './3' export * from 'x' +export * from 'y' export { foo, b as y } from './1' export { x, a } from './2' -export { z } +export { z as w } diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/3/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/3/output.js new file mode 100644 index 0000000000000..a03d39f63e906 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/3/output.js @@ -0,0 +1,3 @@ +export const __next_private_export_map__ = '[["foo","./1","foo"],["y","./1","b"],["x","./2","x"],["a","./2","a"],["w","./3","a"]]'; +export * from "__barrel_optimize__?names=__PLACEHOLDER__!=!x"; +export * from "__barrel_optimize__?names=__PLACEHOLDER__!=!y"; diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/4/input.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/4/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/optimize-barrel/4/input.js rename to packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/4/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/4/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/4/output.js new file mode 100644 index 0000000000000..abe88ed8a7f81 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/4/output.js @@ -0,0 +1,2 @@ +'use client'; +export const __next_private_export_map__ = '[["x","foo","a"],["y","1","y"],["b","foo","b"],["default","foo","default"],["z","bar","default"]]'; diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/5/input.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/5/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/optimize-barrel/5/input.js rename to packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/5/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/5/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/5/output.js new file mode 100644 index 0000000000000..84600fcc61708 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/normal/5/output.js @@ -0,0 +1 @@ +export const __next_private_export_map__ = '[["x","./icons/index.js","*"]]'; \ No newline at end of file diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/wildcard/1/input.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/wildcard/1/input.js new file mode 100644 index 0000000000000..254f287f75a1f --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/wildcard/1/input.js @@ -0,0 +1,3 @@ +export const a = 1 +export { b } +export * from 'c' diff --git a/packages/next-swc/crates/core/tests/fixture/optimize-barrel/wildcard/1/output.js b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/wildcard/1/output.js new file mode 100644 index 0000000000000..fca97a150721b --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize-barrel/wildcard/1/output.js @@ -0,0 +1,2 @@ +export const __next_private_export_map__ = '[["a","",""],["b","","b"]]'; +export * from "__barrel_optimize__?names=__PLACEHOLDER__!=!c"; diff --git a/packages/next/src/build/swc/options.ts b/packages/next/src/build/swc/options.ts index 6af12afce99e6..38e0fa1df3c72 100644 --- a/packages/next/src/build/swc/options.ts +++ b/packages/next/src/build/swc/options.ts @@ -384,9 +384,7 @@ export function getLoaderSWCOptions({ } } if (optimizeBarrelExports) { - baseOptions.optimizeBarrelExports = { - names: optimizeBarrelExports, - } + baseOptions.optimizeBarrelExports = optimizeBarrelExports } const isNextDist = nextDistPath.test(filename) diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 92bfa627938ba..148b90b313aa1 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -532,10 +532,14 @@ function getModularizeImportAliases(packages: string[]) { for (const pkg of packages) { try { const descriptionFileData = require(`${pkg}/package.json`) + const descriptionFilePath = require.resolve(`${pkg}/package.json`) for (const field of mainFields) { if (descriptionFileData.hasOwnProperty(field)) { - aliases[pkg] = `${pkg}/${descriptionFileData[field]}` + aliases[pkg] = path.join( + path.dirname(descriptionFilePath), + descriptionFileData[field] + ) break } } @@ -1178,7 +1182,7 @@ export default async function getBaseWebpackConfig( ...(reactProductionProfiling ? getReactProfilingInProduction() : {}), // For Node server, we need to re-alias the package imports to prefer to - // resolve to the module export. + // resolve to the ESM export. ...(isNodeServer ? getModularizeImportAliases( config.experimental.optimizePackageImports || [] @@ -1962,6 +1966,7 @@ export default async function getBaseWebpackConfig( 'next-invalid-import-error-loader', 'next-metadata-route-loader', 'modularize-import-loader', + 'next-barrel-loader', ].reduce((alias, loader) => { // using multiple aliases to replace `resolveLoader.modules` alias[loader] = path.join(__dirname, 'webpack', 'loaders', loader) @@ -1977,24 +1982,51 @@ export default async function getBaseWebpackConfig( module: { rules: [ { - test: /__barrel_optimize__/, - use: ({ - resourceQuery, - issuerLayer, - }: { - resourceQuery: string - issuerLayer: string - }) => { - const names = resourceQuery.slice('?names='.length).split(',') + // This loader rule passes the resource to the SWC loader with + // `optimizeBarrelExports` enabled. This option makes the SWC to + // transform the original code to be a JSON of its export map, so + // the barrel loader can analyze it and only keep the needed ones. + test: /__barrel_transform__/, + use: ({ resourceQuery }: { resourceQuery: string }) => { + const isFromWildcardExport = /[&?]wildcard/.test(resourceQuery) + return [ getSwcLoader({ - isServerLayer: - issuerLayer === WEBPACK_LAYERS.reactServerComponents, - optimizeBarrelExports: names, + hasServerComponents: false, + optimizeBarrelExports: { + wildcard: isFromWildcardExport, + }, }), ] }, }, + { + // This loader rule works like a bridge between user's import and + // the target module behind a package's barrel file. It reads SWC's + // analysis result from the previous loader, and directly returns the + // code that only exports values that are asked by the user. + test: /__barrel_optimize__/, + use: ({ resourceQuery }: { resourceQuery: string }) => { + const names = ( + resourceQuery.match(/\?names=([^&]+)/)?.[1] || '' + ).split(',') + const isFromWildcardExport = /[&?]wildcard/.test(resourceQuery) + + return [ + { + loader: 'next-barrel-loader', + options: { + names, + wildcard: isFromWildcardExport, + }, + // This is part of the request value to serve as the module key. + // The barrel loader are no-op re-exported modules keyed by + // export names. + ident: 'next-barrel-loader:' + resourceQuery, + }, + ] + }, + }, ...(hasAppDir ? [ { diff --git a/packages/next/src/build/webpack/loaders/next-barrel-loader.ts b/packages/next/src/build/webpack/loaders/next-barrel-loader.ts new file mode 100644 index 0000000000000..d55851d1dfb46 --- /dev/null +++ b/packages/next/src/build/webpack/loaders/next-barrel-loader.ts @@ -0,0 +1,179 @@ +/** + * ## Barrel Optimizations + * + * This loader is used to optimize the imports of "barrel" files that have many + * re-exports. Currently, both Node.js and Webpack have to enter all of these + * submodules even if we only need a few of them. + * + * For example, say a file `foo.js` with the following contents: + * + * export { a } from './a' + * export { b } from './b' + * export { c } from './c' + * ... + * + * If the user imports `a` only, this loader will accept the `names` option to + * be `['a']`. Then, it request the "__barrel_transform__" SWC transform to load + * `foo.js` and receive the following output: + * + * export const __next_private_export_map__ = '[["./a","a","a"],["./b","b","b"],["./c","c","c"],...]' + * + * The export map, generated by SWC, is a JSON that represents the exports of + * that module, their original file, and their original name (since you can do + * `export { a as b }`). + * + * Then, this loader can safely remove all the exports that are not needed and + * re-export the ones from `names`: + * + * export { a } from './a' + * + * That's the basic situation and also the happy path. + * + * + * + * ## Wildcard Exports + * + * For wildcard exports (e.g. `export * from './a'`), it becomes a bit more complicated. + * Say `foo.js` with the following contents: + * + * export * from './a' + * export * from './b' + * export * from './c' + * ... + * + * If the user imports `bar` from it, SWC can never know which files are going to be + * exporting `bar`. So, we have to keep all the wildcard exports and do the same + * process recursively. This loader will return the following output: + * + * export * from '__barrel_optimize__?names=bar&wildcard!=!./a' + * export * from '__barrel_optimize__?names=bar&wildcard!=!./b' + * export * from '__barrel_optimize__?names=bar&wildcard!=!./c' + * ... + * + * The "!=!" tells Webpack to use the same loader to process './a', './b', and './c'. + * After the recursive process, the "inner loaders" will either return an empty string + * or: + * + * export * from './target' + * + * Where `target` is the file that exports `bar`. + * + * + * + * ## Non-Barrel Files + * + * If the file is not a barrel, we can't apply any optimizations. That's because + * we can't easily remove things from the file. For example, say `foo.js` with: + * + * const v = 1 + * export function b () { + * return v + * } + * + * If the user imports `b` only, we can't remove the `const v = 1` even though + * the file is side-effect free. In these caes, this loader will simply re-export + * `foo.js`: + * + * export * from './foo' + * + * Besides these cases, this loader also carefully handles the module cache so + * SWC won't analyze the same file twice, and no instance of the same file will + * be accidentally created as different instances. + */ + +import type webpack from 'webpack' + +const NextBarrelLoader = async function ( + this: webpack.LoaderContext<{ + names: string[] + wildcard: boolean + }> +) { + this.async() + const { names, wildcard } = this.getOptions() + + const source = await new Promise((resolve, reject) => { + this.loadModule( + `__barrel_transform__${wildcard ? '?wildcard' : ''}!=!${ + this.resourcePath + }`, + (err, src) => { + if (err) { + reject(err) + } else { + resolve(src) + } + } + ) + }) + + const matches = source.match( + /^([^]*)export const __next_private_export_map__ = '([^']+)'/ + ) + + if (!matches) { + // This file isn't a barrel and we can't apply any optimizations. Let's re-export everything. + // Since this loader accepts `names` and the request is keyed with `names`, we can't simply + // return the original source here. That will create these imports with different names as + // different modules instances. + this.callback(null, `export * from ${JSON.stringify(this.resourcePath)}`) + return + } + + const wildcardExports = [...source.matchAll(/export \* from "([^"]+)"/g)] + + // It needs to keep the prefix for comments and directives like "use client". + const prefix = matches[1] + + const exportList = JSON.parse(matches[2]) as [string, string, string][] + const exportMap = new Map() + for (const [name, path, orig] of exportList) { + exportMap.set(name, [path, orig]) + } + + let output = prefix + let missedNames: string[] = [] + for (const name of names) { + // If the name matches + if (exportMap.has(name)) { + const decl = exportMap.get(name)! + + // In the wildcard case, all exports are from the file itself. + if (wildcard) { + decl[0] = this.resourcePath + decl[1] = name + } + + if (decl[1] === '*') { + output += `\nexport * as ${name} from ${JSON.stringify(decl[0])}` + } else if (decl[1] === 'default') { + output += `\nexport { default as ${name} } from ${JSON.stringify( + decl[0] + )}` + } else if (decl[1] === name) { + output += `\nexport { ${name} } from ${JSON.stringify(decl[0])}` + } else { + output += `\nexport { ${decl[1]} as ${name} } from ${JSON.stringify( + decl[0] + )}` + } + } else { + missedNames.push(name) + } + } + + // These are from wildcard exports. + if (missedNames.length > 0) { + for (const match of wildcardExports) { + const path = match[1] + + output += `\nexport * from ${JSON.stringify( + path.replace('__PLACEHOLDER__', missedNames.join(',') + '&wildcard') + )}` + } + } + + this.callback(null, output) +} + +export default NextBarrelLoader diff --git a/packages/next/src/build/webpack/loaders/next-swc-loader.ts b/packages/next/src/build/webpack/loaders/next-swc-loader.ts index 79253bf59acf5..4426ca9ab9fee 100644 --- a/packages/next/src/build/webpack/loaders/next-swc-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-swc-loader.ts @@ -58,6 +58,13 @@ async function loaderTransform( const isPageFile = filename.startsWith(pagesDir) const relativeFilePathFromRoot = path.relative(rootDir, filename) + // For testing purposes + if (process.env.NEXT_TEST_MODE) { + if (loaderOptions.optimizeBarrelExports) { + console.log('optimizeBarrelExports:', filename) + } + } + const swcOptions = getLoaderSWCOptions({ pagesDir, appDir, diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index b5b5e787b5aa4..f24d73dbda0d4 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -678,24 +678,6 @@ function assignDefaults( 'lodash-es': { transform: 'lodash-es/{{member}}', }, - '@headlessui/react': { - transform: { - Transition: - 'modularize-import-loader?name={{member}}&join=./components/transitions/transition!@headlessui/react', - Tab: 'modularize-import-loader?name={{member}}&join=./components/tabs/tabs!@headlessui/react', - '*': 'modularize-import-loader?name={{member}}&join=./components/{{ kebabCase member }}/{{ kebabCase member }}!@headlessui/react', - }, - skipDefaultConversion: true, - }, - '@heroicons/react/20/solid': { - transform: '@heroicons/react/20/solid/esm/{{member}}', - }, - '@heroicons/react/24/solid': { - transform: '@heroicons/react/24/solid/esm/{{member}}', - }, - '@heroicons/react/24/outline': { - transform: '@heroicons/react/24/outline/esm/{{member}}', - }, ramda: { transform: 'ramda/es/{{member}}', }, @@ -739,7 +721,18 @@ function assignDefaults( result.experimental = {} } result.experimental.optimizePackageImports = [ - ...new Set([...userProvidedOptimizePackageImports, 'lucide-react']), + ...new Set([ + ...userProvidedOptimizePackageImports, + 'lucide-react', + '@headlessui/react', + '@fortawesome/fontawesome-svg-core', + '@fortawesome/free-solid-svg-icons', + '@headlessui-float/react', + 'react-hot-toast', + '@heroicons/react/20/solid', + '@heroicons/react/24/solid', + '@heroicons/react/24/outline', + ]), ] return result diff --git a/test/development/basic/barrel-optimization.test.ts b/test/development/basic/barrel-optimization.test.ts new file mode 100644 index 0000000000000..5977e27925cb4 --- /dev/null +++ b/test/development/basic/barrel-optimization.test.ts @@ -0,0 +1,120 @@ +import { join } from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +describe('optimizePackageImports', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + env: { + NEXT_TEST_MODE: '1', + }, + files: { + app: new FileRef(join(__dirname, 'barrel-optimization/app')), + pages: new FileRef(join(__dirname, 'barrel-optimization/pages')), + components: new FileRef( + join(__dirname, 'barrel-optimization/components') + ), + 'next.config.js': new FileRef( + join(__dirname, 'barrel-optimization/next.config.js') + ), + node_modules_bak: new FileRef( + join(__dirname, 'barrel-optimization/node_modules_bak') + ), + }, + packageJson: { + scripts: { + setup: `cp -r ./node_modules_bak/* ./node_modules`, + build: `yarn setup && next build`, + dev: `yarn setup && next dev`, + start: 'next start', + }, + }, + installCommand: 'yarn', + startCommand: (global as any).isNextDev ? 'yarn dev' : 'yarn start', + buildCommand: 'yarn build', + dependencies: { + 'lucide-react': '0.264.0', + '@headlessui/react': '1.7.17', + '@heroicons/react': '2.0.18', + }, + }) + }) + afterAll(() => next.destroy()) + + it('app - should render the icons correctly without creating all the modules', async () => { + let logs = '' + next.on('stdout', (log) => { + logs += log + }) + + const html = await next.render('/') + + // Ensure the icons are rendered + expect(html).toContain(' { + let logs = '' + next.on('stdout', (log) => { + logs += log + }) + + const html = await next.render('/pages-route') + + // Ensure the icons are rendered + expect(html).toContain(' { + let logs = '' + next.on('stdout', (log) => { + logs += log + }) + + const html = await next.render('/dedupe') + + // Ensure the icons are rendered + expect(html).toContain(' { + const html = await next.render('/recursive') + expect(html).toContain('

42

') + }) +}) diff --git a/test/development/basic/barrel-optimization/app/dedupe/page.js b/test/development/basic/barrel-optimization/app/dedupe/page.js new file mode 100644 index 0000000000000..0cef1fe7cfba5 --- /dev/null +++ b/test/development/basic/barrel-optimization/app/dedupe/page.js @@ -0,0 +1,23 @@ +// The barrel file shouldn't be transformed many times if it's imported multiple times. + +import { ImageIcon } from 'lucide-react' +import { IceCream } from 'lucide-react' +import { AccessibilityIcon } from 'lucide-react' +import { VariableIcon } from 'lucide-react' +import { Table2Icon } from 'lucide-react' +import { Package2Icon } from 'lucide-react' +import { ZapIcon } from 'lucide-react' + +export default function Page() { + return ( + <> + + + + + + + + + ) +} diff --git a/test/development/basic/modularize-imports/app/layout.js b/test/development/basic/barrel-optimization/app/layout.js similarity index 100% rename from test/development/basic/modularize-imports/app/layout.js rename to test/development/basic/barrel-optimization/app/layout.js diff --git a/test/development/basic/barrel-optimization/app/page.js b/test/development/basic/barrel-optimization/app/page.js new file mode 100644 index 0000000000000..7debba889662e --- /dev/null +++ b/test/development/basic/barrel-optimization/app/page.js @@ -0,0 +1,15 @@ +import { Comp } from '../components/slow-component' +import { AcademicCapIcon as A } from '@heroicons/react/20/solid' +import { AcademicCapIcon as B } from '@heroicons/react/24/outline' +import { AcademicCapIcon as C } from '@heroicons/react/24/solid' + +export default function Page() { + return ( + <> + + + + + + ) +} diff --git a/test/development/basic/barrel-optimization/app/recursive/page.js b/test/development/basic/barrel-optimization/app/recursive/page.js new file mode 100644 index 0000000000000..0040d9dde3dbe --- /dev/null +++ b/test/development/basic/barrel-optimization/app/recursive/page.js @@ -0,0 +1,5 @@ +import { c } from 'my-lib' + +export default function Page() { + return

{c}

+} diff --git a/test/development/basic/modularize-imports/app/page.js b/test/development/basic/barrel-optimization/components/slow-component.js similarity index 92% rename from test/development/basic/modularize-imports/app/page.js rename to test/development/basic/barrel-optimization/components/slow-component.js index c327c4d2142f8..aaa6e63dc9046 100644 --- a/test/development/basic/modularize-imports/app/page.js +++ b/test/development/basic/barrel-optimization/components/slow-component.js @@ -21,11 +21,14 @@ import { Edit2, LucideEdit3, TextSelection, + createLucideIcon, } from 'lucide-react' import { Tab, RadioGroup, Transition } from '@headlessui/react' -export default function Page() { +export function Comp() { + // eslint-disable-next-line no-undef + globalThis.__noop__ = createLucideIcon return ( <> diff --git a/test/development/basic/barrel-optimization/next.config.js b/test/development/basic/barrel-optimization/next.config.js new file mode 100644 index 0000000000000..d966eb99681dc --- /dev/null +++ b/test/development/basic/barrel-optimization/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + optimizePackageImports: ['my-lib'], + }, +} diff --git a/test/development/basic/barrel-optimization/node_modules_bak/my-lib/a.js b/test/development/basic/barrel-optimization/node_modules_bak/my-lib/a.js new file mode 100644 index 0000000000000..35dccc1ad442a --- /dev/null +++ b/test/development/basic/barrel-optimization/node_modules_bak/my-lib/a.js @@ -0,0 +1 @@ +export * from './b' diff --git a/test/development/basic/barrel-optimization/node_modules_bak/my-lib/b.js b/test/development/basic/barrel-optimization/node_modules_bak/my-lib/b.js new file mode 100644 index 0000000000000..3d3691900e01d --- /dev/null +++ b/test/development/basic/barrel-optimization/node_modules_bak/my-lib/b.js @@ -0,0 +1 @@ +export * from './c' diff --git a/test/development/basic/barrel-optimization/node_modules_bak/my-lib/c.js b/test/development/basic/barrel-optimization/node_modules_bak/my-lib/c.js new file mode 100644 index 0000000000000..aa4ff5c8ac77e --- /dev/null +++ b/test/development/basic/barrel-optimization/node_modules_bak/my-lib/c.js @@ -0,0 +1 @@ +export const c = 42 diff --git a/test/development/basic/barrel-optimization/node_modules_bak/my-lib/index.js b/test/development/basic/barrel-optimization/node_modules_bak/my-lib/index.js new file mode 100644 index 0000000000000..21c83d4780fc6 --- /dev/null +++ b/test/development/basic/barrel-optimization/node_modules_bak/my-lib/index.js @@ -0,0 +1 @@ +export * from './a' diff --git a/test/development/basic/barrel-optimization/pages/pages-route.js b/test/development/basic/barrel-optimization/pages/pages-route.js new file mode 100644 index 0000000000000..1f761e5eb9a46 --- /dev/null +++ b/test/development/basic/barrel-optimization/pages/pages-route.js @@ -0,0 +1,5 @@ +import { Comp } from '../components/slow-component' + +export default function Page() { + return +} diff --git a/test/development/basic/modularize-imports.test.ts b/test/development/basic/modularize-imports.test.ts deleted file mode 100644 index 9629d9bd06741..0000000000000 --- a/test/development/basic/modularize-imports.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { join } from 'path' -import { createNext, FileRef } from 'e2e-utils' -import { NextInstance } from 'test/lib/next-modes/base' - -describe('modularize-imports', () => { - let next: NextInstance - - beforeAll(async () => { - next = await createNext({ - files: { - app: new FileRef(join(__dirname, 'modularize-imports/app')), - }, - dependencies: { - 'lucide-react': '0.264.0', - '@headlessui/react': '1.7.17', - }, - }) - }) - afterAll(() => next.destroy()) - - it('should render the icons correctly without creating all the modules', async () => { - let logs = '' - next.on('stdout', (log) => { - logs += log - }) - - const html = await next.render('/') - - // Ensure the icons are rendered - expect(html).toContain('