diff --git a/packages/next/src/build/handle-externals.ts b/packages/next/src/build/handle-externals.ts index 9365b50d44282..8c7f4ccc55ce7 100644 --- a/packages/next/src/build/handle-externals.ts +++ b/packages/next/src/build/handle-externals.ts @@ -28,8 +28,9 @@ export function isResourceInPackages( resource: string, packageNames?: string[], packageDirMapping?: Map -) { - return packageNames?.some((p: string) => +): boolean { + if (!packageNames) return false + return packageNames.some((p: string) => packageDirMapping && packageDirMapping.has(p) ? resource.startsWith(packageDirMapping.get(p)! + path.sep) : resource.includes( diff --git a/packages/next/src/build/swc/options.ts b/packages/next/src/build/swc/options.ts index fb9cef6116440..da59be94ec2c3 100644 --- a/packages/next/src/build/swc/options.ts +++ b/packages/next/src/build/swc/options.ts @@ -209,6 +209,22 @@ function getStyledComponentsOptions( } } +/* +Output module type + +For app router where server components is enabled, we prefer to bundle es6 modules, +Use output module es6 to make sure: +- the esm module is present +- if the module is mixed syntax, the esm + cjs code are both present + +For pages router will remain untouched +*/ +function getModuleOptions( + esm: boolean | undefined = false +): { module: { type: 'es6' } } | {} { + return esm ? { module: { type: 'es6' } } : {} +} + function getEmotionOptions( emotionConfig: undefined | boolean | EmotionConfig, development: boolean @@ -319,6 +335,7 @@ export function getLoaderSWCOptions({ relativeFilePathFromRoot, serverComponents, isReactServerLayer, + esm, }: { filename: string development: boolean @@ -338,6 +355,7 @@ export function getLoaderSWCOptions({ supportedBrowsers: string[] | undefined swcCacheDir: string relativeFilePathFromRoot: string + esm?: boolean serverComponents?: boolean isReactServerLayer?: boolean }) { @@ -412,6 +430,7 @@ export function getLoaderSWCOptions({ node: process.versions.node, }, }, + ...getModuleOptions(esm), } } else { const options = { @@ -423,7 +442,7 @@ export function getLoaderSWCOptions({ type: 'commonjs', }, } - : {}), + : getModuleOptions(esm)), disableNextSsg: !isPageFile, isDevelopment: development, isServerCompiler: isServer, diff --git a/packages/next/src/build/webpack-config-rules/resolve.ts b/packages/next/src/build/webpack-config-rules/resolve.ts index f50f6c92ee629..35f9f56969ef4 100644 --- a/packages/next/src/build/webpack-config-rules/resolve.ts +++ b/packages/next/src/build/webpack-config-rules/resolve.ts @@ -12,20 +12,20 @@ export const edgeConditionNames = [ ] const mainFieldsPerCompiler: Record< - CompilerNameValues | 'app-router-server', + CompilerNameValues | 'server-esm', string[] > = { // For default case, prefer CJS over ESM on server side. e.g. pages dir SSR [COMPILER_NAMES.server]: ['main', 'module'], [COMPILER_NAMES.client]: ['browser', 'module', 'main'], [COMPILER_NAMES.edgeServer]: edgeConditionNames, - // For app router since everything is bundled, prefer ESM over CJS - 'app-router-server': ['module', 'main'], + // For bundling-all strategy, prefer ESM over CJS + 'server-esm': ['module', 'main'], } export function getMainField( - pageType: 'app' | 'pages', - compilerType: CompilerNameValues + compilerType: CompilerNameValues, + preferEsm: boolean ) { if (compilerType === COMPILER_NAMES.edgeServer) { return edgeConditionNames @@ -34,7 +34,7 @@ export function getMainField( } // Prefer module fields over main fields for isomorphic packages on server layer - return pageType === 'app' - ? mainFieldsPerCompiler['app-router-server'] + return preferEsm + ? mainFieldsPerCompiler['server-esm'] : mainFieldsPerCompiler[COMPILER_NAMES.server] } diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index a371aa27d1f87..cec79d499fcc9 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -437,17 +437,26 @@ export default async function getBaseWebpackConfig( } } + // RSC loaders, prefer ESM, set `esm` to true const swcServerLayerLoader = getSwcLoader({ serverComponents: true, isReactServerLayer: true, + esm: true, }) const swcClientLayerLoader = getSwcLoader({ serverComponents: true, isReactServerLayer: false, + esm: true, + }) + // Default swc loaders for pages doesn't prefer ESM. + const swcDefaultLoader = getSwcLoader({ + serverComponents: true, + isReactServerLayer: false, + esm: false, }) const defaultLoaders = { - babel: useSWCLoader ? swcClientLayerLoader : babelLoader!, + babel: useSWCLoader ? swcDefaultLoader : babelLoader!, } const swcLoaderForServerLayer = hasAppDir @@ -621,7 +630,7 @@ export default async function getBaseWebpackConfig( } : undefined), // default main fields use pages dir ones, and customize app router ones in loaders. - mainFields: getMainField('pages', compilerType), + mainFields: getMainField(compilerType, false), ...(isEdgeServer && { conditionNames: edgeConditionNames, }), @@ -736,8 +745,13 @@ export default async function getBaseWebpackConfig( const shouldIncludeExternalDirs = config.experimental.externalDir || !!config.transpilePackages - function createLoaderRuleExclude(skipNodeModules: boolean) { - return (excludePath: string) => { + const codeCondition = { + test: /\.(tsx|ts|js|cjs|mjs|jsx)$/, + ...(shouldIncludeExternalDirs + ? // Allowing importing TS/TSX files from outside of the root dir. + {} + : { include: [dir, ...babelIncludeRegexes] }), + exclude: (excludePath: string) => { if (babelIncludeRegexes.some((r) => r.test(excludePath))) { return false } @@ -748,17 +762,8 @@ export default async function getBaseWebpackConfig( ) if (shouldBeBundled) return false - return skipNodeModules && excludePath.includes('node_modules') - } - } - - const codeCondition = { - test: /\.(tsx|ts|js|cjs|mjs|jsx)$/, - ...(shouldIncludeExternalDirs - ? // Allowing importing TS/TSX files from outside of the root dir. - {} - : { include: [dir, ...babelIncludeRegexes] }), - exclude: createLoaderRuleExclude(true), + return excludePath.includes('node_modules') + }, } let webpackConfig: webpack.Configuration = { @@ -1281,7 +1286,7 @@ export default async function getBaseWebpackConfig( ], }, resolve: { - mainFields: getMainField('app', compilerType), + mainFields: getMainField(compilerType, true), conditionNames: reactServerCondition, // If missing the alias override here, the default alias will be used which aliases // react to the direct file path, not the package name. In that case the condition @@ -1416,7 +1421,7 @@ export default async function getBaseWebpackConfig( issuerLayer: [WEBPACK_LAYERS.appPagesBrowser], use: swcLoaderForClientLayer, resolve: { - mainFields: getMainField('app', compilerType), + mainFields: getMainField(compilerType, true), }, }, { @@ -1424,7 +1429,7 @@ export default async function getBaseWebpackConfig( issuerLayer: [WEBPACK_LAYERS.serverSideRendering], use: swcLoaderForClientLayer, resolve: { - mainFields: getMainField('app', compilerType), + mainFields: getMainField(compilerType, true), }, }, ] 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 511c0773fd63b..aff07d2700b61 100644 --- a/packages/next/src/build/webpack/loaders/next-swc-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-swc-loader.ts @@ -44,6 +44,7 @@ export interface SWCLoaderOptions { swcCacheDir: string serverComponents?: boolean isReactServerLayer?: boolean + esm?: boolean } async function loaderTransform( @@ -69,6 +70,7 @@ async function loaderTransform( swcCacheDir, serverComponents, isReactServerLayer, + esm, } = loaderOptions const isPageFile = filename.startsWith(pagesDir) const relativeFilePathFromRoot = path.relative(rootDir, filename) @@ -92,6 +94,7 @@ async function loaderTransform( relativeFilePathFromRoot, serverComponents, isReactServerLayer, + esm, }) const programmaticOptions = { diff --git a/test/e2e/app-dir/app-external/app-external.test.ts b/test/e2e/app-dir/app-external/app-external.test.ts index dbf044d7324cc..c458ab3397da9 100644 --- a/test/e2e/app-dir/app-external/app-external.test.ts +++ b/test/e2e/app-dir/app-external/app-external.test.ts @@ -210,6 +210,24 @@ createNextDescribe( }) }) + describe('mixed syntax external modules', () => { + it('should handle mixed module with next/dynamic', async () => { + const browser = await next.browser('/mixed/dynamic') + expect(await browser.elementByCss('#component').text()).toContain( + 'mixed-syntax-esm' + ) + }) + + it('should handle mixed module in server and client components', async () => { + const $ = await next.render$('/mixed/import') + expect(await $('#server').text()).toContain('server:mixed-syntax-esm') + expect(await $('#client').text()).toContain('client:mixed-syntax-esm') + expect(await $('#relative-mixed').text()).toContain( + 'relative-mixed-syntax-esm' + ) + }) + }) + it('should export client module references in esm', async () => { const html = await next.render('/esm-client-ref') expect(html).toContain('hello') diff --git a/test/e2e/app-dir/app-external/app/mixed/dynamic/page.js b/test/e2e/app-dir/app-external/app/mixed/dynamic/page.js new file mode 100644 index 0000000000000..65c82ecbb6560 --- /dev/null +++ b/test/e2e/app-dir/app-external/app/mixed/dynamic/page.js @@ -0,0 +1,12 @@ +'use client' + +import dynamic from 'next/dynamic' + +const Dynamic = dynamic( + () => import('mixed-syntax-esm').then((mod) => mod.Component), + { ssr: false } +) + +export default function Page() { + return +} diff --git a/test/e2e/app-dir/app-external/app/mixed/import/client.js b/test/e2e/app-dir/app-external/app/mixed/import/client.js new file mode 100644 index 0000000000000..c5f9320ac7a13 --- /dev/null +++ b/test/e2e/app-dir/app-external/app/mixed/import/client.js @@ -0,0 +1,7 @@ +'use client' + +import { value } from 'mixed-syntax-esm' + +export function Client() { + return 'client:' + value +} diff --git a/test/e2e/app-dir/app-external/app/mixed/import/mixed-mod.mjs b/test/e2e/app-dir/app-external/app/mixed/import/mixed-mod.mjs new file mode 100644 index 0000000000000..327be28b64f12 --- /dev/null +++ b/test/e2e/app-dir/app-external/app/mixed/import/mixed-mod.mjs @@ -0,0 +1,5 @@ +;(module) => { + module.exports = {} +} + +export const value = 'relative-mixed-syntax-esm' diff --git a/test/e2e/app-dir/app-external/app/mixed/import/page.js b/test/e2e/app-dir/app-external/app/mixed/import/page.js new file mode 100644 index 0000000000000..67ce376201f32 --- /dev/null +++ b/test/e2e/app-dir/app-external/app/mixed/import/page.js @@ -0,0 +1,15 @@ +import { value } from 'mixed-syntax-esm' +import { Client } from './client' +import { value as relativeMixedValue } from './mixed-mod.mjs' + +export default function Page() { + return ( + <> +

{'server:' + value}

+

+ +

+

{relativeMixedValue}

+ + ) +} diff --git a/test/e2e/app-dir/app-external/node_modules_bak/mixed-syntax-esm/index.mjs b/test/e2e/app-dir/app-external/node_modules_bak/mixed-syntax-esm/index.mjs new file mode 100644 index 0000000000000..ca05246cd1486 --- /dev/null +++ b/test/e2e/app-dir/app-external/node_modules_bak/mixed-syntax-esm/index.mjs @@ -0,0 +1,14 @@ +import React from 'react' +;(module) => { + module.exports = {} +} + +export const value = 'mixed-syntax-esm' + +export function Component() { + return /*#__PURE__*/ React.createElement( + 'p', + { id: 'component' }, + 'mixed-syntax-esm' + ) +} diff --git a/test/e2e/app-dir/app-external/node_modules_bak/mixed-syntax-esm/package.json b/test/e2e/app-dir/app-external/node_modules_bak/mixed-syntax-esm/package.json new file mode 100644 index 0000000000000..b6629e247888a --- /dev/null +++ b/test/e2e/app-dir/app-external/node_modules_bak/mixed-syntax-esm/package.json @@ -0,0 +1,3 @@ +{ + "exports": "./index.mjs" +} diff --git a/test/e2e/app-dir/third-parties/app/gtm/page.js b/test/e2e/app-dir/third-parties/app/gtm/page.js index 34d56d8508d59..3662bc2cf72e4 100644 --- a/test/e2e/app-dir/third-parties/app/gtm/page.js +++ b/test/e2e/app-dir/third-parties/app/gtm/page.js @@ -9,7 +9,7 @@ const Page = () => { } return ( -
+

GTM