diff --git a/packages/next-swc/crates/core/src/lib.rs b/packages/next-swc/crates/core/src/lib.rs index 7e20e5cf5bc9d..73a0ad75a176e 100644 --- a/packages/next-swc/crates/core/src/lib.rs +++ b/packages/next-swc/crates/core/src/lib.rs @@ -167,22 +167,12 @@ where } }; - let mut modularize_imports_config = match &opts.modularize_imports { + let modularize_imports_config = match &opts.modularize_imports { Some(config) => config.clone(), None => modularize_imports::Config { packages: std::collections::HashMap::new(), }, }; - modularize_imports_config.packages.insert( - "next/server".to_string(), - modularize_imports::PackageConfig { - transform: modularize_imports::Transform::String( - "next/dist/server/web/exports/{{ kebabCase member }}".to_string(), - ), - prevent_full_import: false, - skip_default_conversion: false, - }, - ); chain!( disallow_re_export_all_in_page::disallow_re_export_all_in_page(opts.is_page_file), diff --git a/packages/next-swc/crates/next-core/js/src/entry/config/next.js b/packages/next-swc/crates/next-core/js/src/entry/config/next.js index 3a985cd60df71..4a734bb34f905 100644 --- a/packages/next-swc/crates/next-core/js/src/entry/config/next.js +++ b/packages/next-swc/crates/next-core/js/src/entry/config/next.js @@ -20,6 +20,25 @@ const loadNextConfig = async (silent) => { nextConfig.exportPathMap = nextConfig.exportPathMap && {} nextConfig.webpack = nextConfig.webpack && {} + // Transform the `modularizeImports` option + nextConfig.modularizeImports = nextConfig.modularizeImports + ? Object.fromEntries( + Object.entries(nextConfig.modularizeImports).map(([mod, config]) => [ + mod, + { + ...config, + transform: + typeof config.transform === 'string' + ? config.transform + : Object.entries(config.transform).map(([key, value]) => [ + key, + value, + ]), + }, + ]) + ) + : undefined + if (nextConfig.experimental?.turbopack?.loaders) { ensureLoadersHaveSerializableOptions( nextConfig.experimental.turbopack.loaders diff --git a/packages/next-swc/crates/next-core/src/next_shared/transforms/modularize_imports.rs b/packages/next-swc/crates/next-core/src/next_shared/transforms/modularize_imports.rs index 4578b68823e22..ada8add3ef787 100644 --- a/packages/next-swc/crates/next-core/src/next_shared/transforms/modularize_imports.rs +++ b/packages/next-swc/crates/next-core/src/next_shared/transforms/modularize_imports.rs @@ -25,13 +25,22 @@ use super::module_rule_match_js_no_url; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, TraceRawVcs)] #[serde(rename_all = "camelCase")] pub struct ModularizeImportPackageConfig { - pub transform: String, + pub transform: Transform, #[serde(default)] pub prevent_full_import: bool, #[serde(default)] pub skip_default_conversion: bool, } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, TraceRawVcs)] +#[serde(untagged)] +pub enum Transform { + #[default] + None, + String(String), + Vec(Vec<(String, String)>), +} + /// Returns a rule which applies the Next.js modularize imports transform. pub fn get_next_modularize_imports_rule( modularize_imports_config: &IndexMap, @@ -61,7 +70,15 @@ impl ModularizeImportsTransformer { ( k.clone(), PackageConfig { - transform: modularize_imports::Transform::String(v.transform.clone()), + transform: match &v.transform { + Transform::String(s) => { + modularize_imports::Transform::String(s.clone()) + } + Transform::Vec(v) => modularize_imports::Transform::Vec(v.clone()), + Transform::None => { + panic!("Missing transform value for package {}", k) + } + }, prevent_full_import: v.prevent_full_import, skip_default_conversion: v.skip_default_conversion, }, diff --git a/packages/next/src/build/swc/options.ts b/packages/next/src/build/swc/options.ts index a5c5f4e0d317d..9cce65e428759 100644 --- a/packages/next/src/build/swc/options.ts +++ b/packages/next/src/build/swc/options.ts @@ -140,7 +140,24 @@ function getBaseSWCOptions({ reactRemoveProperties: jest ? false : compilerOptions?.reactRemoveProperties, - modularizeImports, + // Map the k-v map to an array of pairs. + modularizeImports: modularizeImports + ? Object.fromEntries( + Object.entries(modularizeImports).map(([mod, config]) => [ + mod, + { + ...config, + transform: + typeof config.transform === 'string' + ? config.transform + : Object.entries(config.transform).map(([key, value]) => [ + key, + value, + ]), + }, + ]) + ) + : undefined, relay: compilerOptions?.relay, // Always transform styled-jsx and error when `client-only` condition is triggered styledJsx: true, diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 037978482ac23..e0980dbd164a6 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -1926,6 +1926,7 @@ export default async function getBaseWebpackConfig( 'next-font-loader', 'next-invalid-import-error-loader', 'next-metadata-route-loader', + 'modularize-import-loader', ].reduce((alias, loader) => { // using multiple aliases to replace `resolveLoader.modules` alias[loader] = path.join(__dirname, 'webpack', 'loaders', loader) diff --git a/packages/next/src/build/webpack/loaders/modularize-import-loader.ts b/packages/next/src/build/webpack/loaders/modularize-import-loader.ts new file mode 100644 index 0000000000000..44e191798f7a6 --- /dev/null +++ b/packages/next/src/build/webpack/loaders/modularize-import-loader.ts @@ -0,0 +1,37 @@ +import path from 'path' + +export type ModularizeImportLoaderOptions = { + name: string + join?: string + from: 'default' | 'named' + as: 'default' | 'named' +} + +/** + * This loader is to create special re-exports from a specific file. + * For example, the following loader: + * + * modularize-import-loader?name=Arrow&from=Arrow&as=default&join=./icons/Arrow!lucide-react + * + * will be used to create a re-export of: + * + * export { Arrow as default } from "join(resolve_path('lucide-react'), '/icons/Arrow')" + * + * This works even if there's no export field in the package.json of the package. + */ +export default function transformSource(this: any) { + const { name, from, as, join }: ModularizeImportLoaderOptions = + this.getOptions() + const { resourcePath } = this + const fullPath = join + ? path.join(path.dirname(resourcePath), join) + : resourcePath + + return ` +export { + ${from === 'default' ? 'default' : name} as ${ + as === 'default' ? 'default' : name + } +} from ${JSON.stringify(fullPath)} +` +} diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 4b683dc3206c9..a3ecfe752864a 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -627,7 +627,7 @@ export interface NextConfig extends Record { modularizeImports?: Record< string, { - transform: string + transform: string | Record preventFullImport?: boolean skipDefaultConversion?: boolean } diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 7a254d137b4a4..c1ee49b731a94 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -679,25 +679,52 @@ function assignDefaults( 'lodash-es': { transform: 'lodash-es/{{member}}', }, - // TODO: Enable this once we have a way to remove the "-icon" suffix from the import path. - // Related discussion: https://github.com/vercel/next.js/pull/50900#discussion_r1239656782 - // 'lucide-react': { - // transform: 'lucide-react/dist/esm/icons/{{ kebabCase 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: { + 'Lucide(.*)': + 'modularize-import-loader?name={{ member }}&from=default&as=default&join=./icons/{{ kebabCase memberMatches.[1] }}!lucide-react', + '(.*)Icon': + 'modularize-import-loader?name={{ member }}&from=default&as=default&join=./icons/{{ kebabCase memberMatches.[1] }}!lucide-react', + '*': 'modularize-import-loader?name={{ member }}&from=default&as=default&join=./icons/{{ kebabCase member }}!lucide-react', + }, + }, ramda: { transform: 'ramda/es/{{member}}', }, 'react-bootstrap': { - transform: 'react-bootstrap/{{member}}', + transform: { + useAccordionButton: + 'modularize-import-loader?name=useAccordionButton&from=named&as=default!react-bootstrap/AccordionButton', + '*': 'react-bootstrap/{{member}}', + }, }, antd: { transform: 'antd/lib/{{kebabCase member}}', }, ahooks: { - transform: 'ahooks/es/{{member}}', + transform: { + createUpdateEffect: + 'modularize-import-loader?name=createUpdateEffect&from=named&as=default!ahooks/es/createUpdateEffect', + '*': 'ahooks/es/{{member}}', + }, }, '@ant-design/icons': { - transform: '@ant-design/icons/lib/icons/{{member}}', + transform: { + IconProvider: + 'modularize-import-loader?name=IconProvider&from=named&as=default!@ant-design/icons', + createFromIconfontCN: '@ant-design/icons/es/components/IconFont', + getTwoToneColor: + 'modularize-import-loader?name=getTwoToneColor&from=named&as=default!@ant-design/icons/es/components/twoTonePrimaryColor', + setTwoToneColor: + 'modularize-import-loader?name=setTwoToneColor&from=named&as=default!@ant-design/icons/es/components/twoTonePrimaryColor', + '*': '@ant-design/icons/lib/icons/{{member}}', + }, + }, + 'next/server': { + transform: 'next/dist/server/web/exports/{{ kebabCase member }}', }, } diff --git a/test/development/basic/modularize-imports.test.ts b/test/development/basic/modularize-imports.test.ts new file mode 100644 index 0000000000000..8125a01913455 --- /dev/null +++ b/test/development/basic/modularize-imports.test.ts @@ -0,0 +1,44 @@ +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': 'latest', + }, + }) + }) + 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(' + {children} + + ) +} diff --git a/test/development/basic/modularize-imports/app/page.js b/test/development/basic/modularize-imports/app/page.js new file mode 100644 index 0000000000000..8e3256155d8a5 --- /dev/null +++ b/test/development/basic/modularize-imports/app/page.js @@ -0,0 +1,11 @@ +import { IceCream, BackpackIcon, LucideActivity } from 'lucide-react' + +export default function Page() { + return ( + <> + + + + + ) +}