optimize_barrel SWC transform and new optimizePackageImports config (#54572)

## Implementation

Base on #54530, we're implementing a `optimize_barrel` transform to
optimize barrel files to only export the names we need. If the
transformed file isn't a "barrel file", we just re-export the names from
it without any transformation.

Take `lucide-react` as an example, with #54530 we are able to transform

```js
import { IceCream } from 'lucide-react'
```

to 

```js
import { IceCream } from '__barrel_optimize__?names=IceCream!=!lucide-react?__barrel_optimize_noop__=IceCream'
```

And then, we apply that new request with a new Webpack module rule to
use the SWC loader with option `optimizeBarrelExports: ['IceCream']`,
which eventually got passed to this new `optimize_barrel` transform and
does the optimization.

## Notes

We'll have to add a new `getModularizeImportAliases` alias list to map
`lucide-react` to the ESM version, as we have the `['main', 'module']`
resolve order for the server compiler. Otherwise this optimization
doesn't work in that compiler.

There's no e2e test added because it's already covered by the
`modularize-imports` test as we removed the default `lucide-react`
transform rules and it still works.

We'll need to test other libs before migrating them to the new
`optimizePackageImports` option.

---

Closes #54571, closes #53605, closes #53789, closes #53894, closes
#54063.
This commit is contained in:
Shu Ding 2023-08-25 22:29:39 +02:00 committed by GitHub
parent eee137615e
commit b4a5663c5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 449 additions and 87 deletions

View file

@ -59,6 +59,7 @@ pub mod disallow_re_export_all_in_page;
pub mod named_import_transform;
pub mod next_dynamic;
pub mod next_ssg;
pub mod optimize_barrel;
pub mod page_config;
pub mod react_remove_properties;
pub mod react_server_components;
@ -132,6 +133,9 @@ pub struct TransformOptions {
#[serde(default)]
pub auto_modularize_imports: Option<named_import_transform::Config>,
#[serde(default)]
pub optimize_barrel_exports: Option<optimize_barrel::Config>,
#[serde(default)]
pub font_loaders: Option<next_transform_font::Config>,
@ -253,6 +257,11 @@ where
Some(config) => Either::Left(named_import_transform::named_import_transform(config.clone())),
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()),
},
opts.emotion
.as_ref()
.and_then(|config| {

View file

@ -60,10 +60,10 @@ impl Fold for NamedImportTransform {
}
if !skip_transform {
let names = specifier_names.join(",");
let new_src = format!(
"barrel-optimize-loader?names={}!{}",
specifier_names.join(","),
src_value
"__barrel_optimize__?names={}!=!{}?__barrel_optimize_noop__={}",
names, src_value, names,
);
// Create a new import declaration, keep everything the same except the source

View file

@ -0,0 +1,268 @@
use serde::Deserialize;
use turbopack_binding::swc::core::{
common::{FileName, DUMMY_SP},
ecma::{
ast::*,
utils::{private_ident, quote_str},
visit::Fold,
},
};
#[derive(Clone, Debug, Deserialize)]
pub struct Config {
pub names: Vec<String>,
}
pub fn optimize_barrel(filename: FileName, config: Config) -> impl Fold {
OptimizeBarrel {
filepath: filename.to_string(),
names: config.names,
}
}
#[derive(Debug, Default)]
struct OptimizeBarrel {
filepath: String,
names: Vec<String>,
}
impl Fold for OptimizeBarrel {
fn fold_module_items(&mut self, items: Vec<ModuleItem>) -> Vec<ModuleItem> {
// 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(),
};
}
// 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());
}
}
}
}
}
}
}
// The second pass to rebuild the module items.
let mut new_items = 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.).
let mut is_barrel = true;
for item in &items {
match item {
ModuleItem::ModuleDecl(decl) => {
match decl {
// export { foo } from './foo';
ModuleDecl::ExportNamed(export_named) => {
for spec in &export_named.specifiers {
match spec {
ExportSpecifier::Namespace(s) => {
let name_str = match &s.name {
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());
}
}
ExportSpecifier::Named(s) => {
if let Some(name) = &s.exported {
let name_str = match &name {
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: Some(
ModuleExportName::Ident(
Ident::new(
name_str.into(),
DUMMY_SP,
),
),
),
is_type_only: false,
},
)],
src: export_named.src.clone(),
type_only: false,
asserts: None,
}),
));
}
} 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;
}
}
}
}
// 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,
}),
));
}
}
}
}
}
_ => {
// Export expressions are not allowed in barrel files.
is_barrel = false;
break;
}
}
}
ModuleItem::Stmt(stmt) => match stmt {
Stmt::Expr(expr) => match &*expr.expr {
Expr::Lit(_) => {
new_items.push(item.clone());
}
_ => {
is_barrel = false;
break;
}
},
_ => {
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 !is_barrel {
new_items = vec![ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(
NamedExport {
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,
asserts: None,
},
))];
}
new_items
}
}

View file

@ -6,6 +6,7 @@ use next_swc::{
named_import_transform::named_import_transform,
next_dynamic::next_dynamic,
next_ssg::next_ssg,
optimize_barrel::optimize_barrel,
page_config::page_config_test,
react_remove_properties::remove_properties,
react_server_components::server_components,
@ -481,6 +482,35 @@ fn named_import_transform_fixture(input: PathBuf) {
);
}
#[fixture("tests/fixture/optimize-barrel/**/input.js")]
fn optimize_barrel_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(
FileName::Real(PathBuf::from("/some-project/node_modules/foo/file.js")),
json(
r#"
{
"names": ["x", "y", "z"]
}
"#
)
)
)
},
&input,
&output,
Default::default(),
);
}
fn json<T>(s: &str) -> T
where
T: DeserializeOwned,

View file

@ -1,3 +1,3 @@
import { A, B, C as F } from "barrel-optimize-loader?names=A,B,C!foo";
import { A, B, C as F } from "__barrel_optimize__?names=A,B,C!=!foo?__barrel_optimize_noop__=A,B,C";
import D from 'bar';
import E from 'baz';

View file

@ -1,3 +1,3 @@
import { A, B, C as F } from "barrel-optimize-loader?names=A,B,C!foo";
import { D } from "barrel-optimize-loader?names=D!bar";
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 E from 'baz';

View file

@ -0,0 +1,3 @@
export { foo, b as y } from './1'
export { x, a } from './2'
export { z }

View file

@ -0,0 +1,3 @@
export { b as y } from './1';
export { x } from './2';
export { z };

View file

@ -0,0 +1,6 @@
// De-optimize this file
const foo = 1
export { foo, b as y } from './1'
export { x, a } from './2'
export { z }

View file

@ -0,0 +1,2 @@
// De-optimize this file
export { x, y, z } from "/some-project/node_modules/foo/file.js";

View file

@ -0,0 +1,6 @@
// De-optimize this file
export * from 'x'
export { foo, b as y } from './1'
export { x, a } from './2'
export { z }

View file

@ -0,0 +1,2 @@
// De-optimize this file
export { x, y, z } from "/some-project/node_modules/foo/file.js";

View file

@ -0,0 +1,9 @@
'use client'
import foo, { a, b } from 'foo'
import z from 'bar'
export { a as x }
export { y } from '1'
export { b }
export { foo as default, z }

View file

@ -0,0 +1,7 @@
'use client';
import { a } from 'foo'
import z from 'bar'
export { a as x };
export { y } from '1';
export { z };

View file

@ -0,0 +1,2 @@
import * as index from './icons/index.js'
export { index as x }

View file

@ -0,0 +1,2 @@
import * as index from './icons/index.js'
export { index as x }

View file

@ -79,6 +79,7 @@ fn test(input: &Path, minify: bool) {
server_actions: None,
cjs_require_optimizer: None,
auto_modularize_imports: None,
optimize_barrel_exports: None,
};
let unresolved_mark = Mark::new();

View file

@ -301,6 +301,7 @@ export function getLoaderSWCOptions({
isPageFile,
hasReactRefresh,
modularizeImports,
optimizePackageImports,
swcPlugins,
compilerOptions,
jsConfig,
@ -310,6 +311,7 @@ export function getLoaderSWCOptions({
hasServerComponents,
isServerLayer,
isServerActionsEnabled,
optimizeBarrelExports,
}: // This is not passed yet as "paths" resolving is handled by webpack currently.
// resolvedBaseUrl,
{
@ -321,6 +323,9 @@ export function getLoaderSWCOptions({
isPageFile: boolean
hasReactRefresh: boolean
modularizeImports: NextConfig['modularizeImports']
optimizePackageImports?: NonNullable<
NextConfig['experimental']
>['optimizePackageImports']
swcPlugins: ExperimentalConfig['swcPlugins']
compilerOptions: NextConfig['compiler']
jsConfig: any
@ -330,6 +335,7 @@ export function getLoaderSWCOptions({
hasServerComponents?: boolean
isServerLayer: boolean
isServerActionsEnabled?: boolean
optimizeBarrelExports?: string[]
}) {
let baseOptions: any = getBaseSWCOptions({
filename,
@ -370,10 +376,17 @@ export function getLoaderSWCOptions({
},
},
}
baseOptions.autoModularizeImports = {
packages: [
// TODO: Add a list of packages that should be optimized by default
],
// Modularize import optimization for barrel files
if (optimizePackageImports) {
baseOptions.autoModularizeImports = {
packages: optimizePackageImports,
}
}
if (optimizeBarrelExports) {
baseOptions.optimizeBarrelExports = {
names: optimizeBarrelExports,
}
}
const isNextDist = nextDistPath.test(filename)

View file

@ -532,6 +532,27 @@ function getOptimizedAliases(): { [pkg: string]: string } {
)
}
// Alias these modules to be resolved with "module" if possible.
function getModularizeImportAliases(packages: string[]) {
const aliases: { [pkg: string]: string } = {}
const mainFields = ['module', 'main']
for (const pkg of packages) {
try {
const descriptionFileData = require(`${pkg}/package.json`)
for (const field of mainFields) {
if (descriptionFileData.hasOwnProperty(field)) {
aliases[pkg] = `${pkg}/${descriptionFileData[field]}`
break
}
}
} catch {}
}
return aliases
}
export function attachReactRefresh(
webpackConfig: webpack.Configuration,
targetLoader: webpack.RuleSetUseItem
@ -1165,6 +1186,14 @@ export default async function getBaseWebpackConfig(
...(isClient || isEdgeServer ? getOptimizedAliases() : {}),
...(reactProductionProfiling ? getReactProfilingInProduction() : {}),
// For Node server, we need to re-alias the package imports to prefer to
// resolve to the module export.
...(isNodeServer
? getModularizeImportAliases(
config.experimental.optimizePackageImports || []
)
: {}),
[RSC_ACTION_VALIDATE_ALIAS]:
'next/dist/build/webpack/loaders/next-flight-loader/action-validate',
@ -1397,6 +1426,12 @@ export default async function getBaseWebpackConfig(
return
}
// __barrel_optimize__ is a special marker that tells Next.js to
// optimize the import by removing unused exports. This has to be compiled.
if (request.startsWith('__barrel_optimize__')) {
return
}
// When in esm externals mode, and using import, we resolve with
// ESM resolving options.
// Also disable esm request when appDir is enabled
@ -1936,7 +1971,6 @@ export default async function getBaseWebpackConfig(
'next-invalid-import-error-loader',
'next-metadata-route-loader',
'modularize-import-loader',
'barrel-optimize-loader',
].reduce((alias, loader) => {
// using multiple aliases to replace `resolveLoader.modules`
alias[loader] = path.join(__dirname, 'webpack', 'loaders', loader)
@ -1951,6 +1985,25 @@ export default async function getBaseWebpackConfig(
},
module: {
rules: [
{
test: /__barrel_optimize__/,
use: ({
resourceQuery,
issuerLayer,
}: {
resourceQuery: string
issuerLayer: string
}) => {
const names = resourceQuery.slice('?names='.length).split(',')
return [
getSwcLoader({
isServerLayer:
issuerLayer === WEBPACK_LAYERS.reactServerComponents,
optimizeBarrelExports: names,
}),
]
},
},
...(hasAppDir
? [
{
@ -2006,9 +2059,7 @@ export default async function getBaseWebpackConfig(
...(hasAppDir && !isClient
? [
{
issuerLayer: {
or: [isWebpackServerLayer],
},
issuerLayer: isWebpackServerLayer,
test: {
// Resolve it if it is a source code file, and it has NOT been
// opted out of bundling.
@ -2144,9 +2195,7 @@ export default async function getBaseWebpackConfig(
? [
{
test: codeCondition.test,
issuerLayer: {
or: [isWebpackServerLayer],
},
issuerLayer: isWebpackServerLayer,
exclude: [asyncStoragesRegex],
use: swcLoaderForServerLayer,
},

View file

@ -1,5 +0,0 @@
export default function transformSource(this: any, source: string) {
// const { names }: any = this.getOptions()
// const { resourcePath } = this
return source
}

View file

@ -53,6 +53,7 @@ async function loaderTransform(
swcCacheDir,
hasServerComponents,
isServerLayer,
optimizeBarrelExports,
} = loaderOptions
const isPageFile = filename.startsWith(pagesDir)
const relativeFilePathFromRoot = path.relative(rootDir, filename)
@ -66,6 +67,7 @@ async function loaderTransform(
development: this.mode === 'development',
hasReactRefresh,
modularizeImports: nextConfig?.modularizeImports,
optimizePackageImports: nextConfig?.experimental?.optimizePackageImports,
swcPlugins: nextConfig?.experimental?.swcPlugins,
compilerOptions: nextConfig?.compiler,
jsConfig,
@ -75,6 +77,7 @@ async function loaderTransform(
hasServerComponents,
isServerActionsEnabled: nextConfig?.experimental?.serverActions,
isServerLayer,
optimizeBarrelExports,
})
const programmaticOptions = {

View file

@ -473,6 +473,9 @@ const configSchema = {
},
},
},
optimizePackageImports: {
type: 'array',
},
instrumentationHook: {
type: 'boolean',
},

View file

@ -236,6 +236,11 @@ export interface ExperimentalConfig {
webVitalsAttribution?: Array<(typeof WEB_VITALS)[number]>
/**
* Automatically apply the "modularizeImports" optimization to imports of the specified packages.
*/
optimizePackageImports?: string[]
turbo?: ExperimentalTurboOptions
turbotrace?: {
logLevel?:

View file

@ -678,48 +678,6 @@ function assignDefaults(
'lodash-es': {
transform: 'lodash-es/{{member}}',
},
'lucide-react': {
// Note that we need to first resolve to the base path (`lucide-react`) and join the subpath,
// instead of just resolving `lucide-react/esm/icons/{{kebabCase member}}` because this package
// doesn't have proper `exports` fields for individual icons in its package.json.
transform: {
// Special aliases
'(SortAsc|LucideSortAsc|SortAscIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/arrow-up-narrow-wide!lucide-react',
'(SortDesc|LucideSortDesc|SortDescIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/arrow-down-wide-narrow!lucide-react',
'(Verified|LucideVerified|VerifiedIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/badge-check!lucide-react',
'(Slash|LucideSlash|SlashIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/ban!lucide-react',
'(CurlyBraces|LucideCurlyBraces|CurlyBracesIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/braces!lucide-react',
'(CircleSlashed|LucideCircleSlashed|CircleSlashedIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/circle-slash-2!lucide-react',
'(SquareGantt|LucideSquareGantt|SquareGanttIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/gantt-chart-square!lucide-react',
'(SquareKanbanDashed|LucideSquareKanbanDashed|SquareKanbanDashedIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/kanban-square-dashed!lucide-react',
'(SquareKanban|LucideSquareKanban|SquareKanbanIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/kanban-square!lucide-react',
'(Edit3|LucideEdit3|Edit3Icon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/pen-line!lucide-react',
'(Edit|LucideEdit|EditIcon|PenBox|LucidePenBox|PenBoxIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/pen-square!lucide-react',
'(Edit2|LucideEdit2|Edit2Icon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/pen!lucide-react',
'(Stars|LucideStars|StarsIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/sparkles!lucide-react',
'(TextSelection|LucideTextSelection|TextSelectionIcon)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/text-select!lucide-react',
// General rules
'Lucide(.*)':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/{{ kebabCase memberMatches.[1] }}!lucide-react',
'(.*)Icon':
'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/{{ kebabCase memberMatches.[1] }}!lucide-react',
'*': 'modularize-import-loader?name={{ member }}&from=default&as=default&join=../esm/icons/{{ kebabCase member }}!lucide-react',
},
},
'@headlessui/react': {
transform: {
Transition:
@ -775,6 +733,15 @@ function assignDefaults(
},
}
const userProvidedOptimizePackageImports =
result.experimental?.optimizePackageImports || []
if (!result.experimental) {
result.experimental = {}
}
result.experimental.optimizePackageImports = [
...new Set([...userProvidedOptimizePackageImports, 'lucide-react']),
]
return result
}

View file

@ -1,12 +0,0 @@
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

View file

@ -1,11 +0,0 @@
'use client'
import { IceCream } from 'lucide-react'
export default function Page() {
return (
<div>
<IceCream />
</div>
)
}