Fix barrel optimization to ignore layers (#59254)

Fixes #57624. The recent issue was an unexpected side effect caused by
305bb01506,
which only affects specific packages like `@mui/material`.

The problem was that the entry file of `@mui/material` has `"use
client"` at top, which affects the compilation result to output
reference info only (when on the RSC layer), instead of keeping the
original export statements. And the fix here is to ignore all layer info
and React specific transforms here, as barrel optimization isn't related
to all these framework features at all. To keep all directives
unchanged, the SWC transform needs to parse and pass that info to the
Webpack loader.

This PR adds a test to ensure that `@mui/material` is working as
expected (less than 1500 modules compiled). Without this feature it'll
be ~2400 modules.

Closes NEXT-1793, closes NEXT-1762.
This commit is contained in:
Shu Ding 2023-12-06 00:17:31 +08:00 committed by GitHub
parent b88e263f4c
commit 6790004d79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 162 additions and 54 deletions

View file

@ -73,11 +73,16 @@ impl Fold for OptimizeBarrel {
// We only apply this optimization to barrel files. Here we consider // We only apply this optimization to barrel files. Here we consider
// a barrel file to be a file that only exports from other modules. // a barrel file to be a file that only exports from other modules.
// Besides that, lit expressions are allowed as well ("use client", etc.). // Besides that, lit expressions are allowed as well ("use client", etc.).
let mut allowed_directives = true;
let mut directives = vec![];
let mut is_barrel = true; let mut is_barrel = true;
for item in &items { for item in &items {
match item { match item {
ModuleItem::ModuleDecl(decl) => { ModuleItem::ModuleDecl(decl) => {
allowed_directives = false;
match decl { match decl {
ModuleDecl::Import(_) => {} ModuleDecl::Import(_) => {}
// export { foo } from './foo'; // export { foo } from './foo';
@ -198,10 +203,17 @@ impl Fold for OptimizeBarrel {
} }
ModuleItem::Stmt(stmt) => match stmt { ModuleItem::Stmt(stmt) => match stmt {
Stmt::Expr(expr) => match &*expr.expr { Stmt::Expr(expr) => match &*expr.expr {
Expr::Lit(_) => { Expr::Lit(l) => {
new_items.push(item.clone()); if let Lit::Str(s) = l {
if allowed_directives && s.value.starts_with("use ") {
directives.push(s.value.to_string());
}
} else {
allowed_directives = false;
}
} }
_ => { _ => {
allowed_directives = false;
if !self.wildcard { if !self.wildcard {
is_barrel = false; is_barrel = false;
break; break;
@ -209,6 +221,7 @@ impl Fold for OptimizeBarrel {
} }
}, },
_ => { _ => {
allowed_directives = false;
if !self.wildcard { if !self.wildcard {
is_barrel = false; is_barrel = false;
break; break;
@ -259,6 +272,31 @@ impl Fold for OptimizeBarrel {
type_only: false, type_only: false,
}))); })));
} }
// Push directives.
if !directives.is_empty() {
new_items.push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl {
span: DUMMY_SP,
decl: Decl::Var(Box::new(VarDecl {
span: DUMMY_SP,
kind: VarDeclKind::Const,
declare: false,
decls: vec![VarDeclarator {
span: DUMMY_SP,
name: Pat::Ident(BindingIdent {
id: private_ident!("__next_private_directive_list__"),
type_ann: None,
}),
init: Some(Box::new(Expr::Lit(Lit::Str(Str {
span: DUMMY_SP,
value: serde_json::to_string(&directives).unwrap().into(),
raw: None,
})))),
definite: false,
}],
})),
})));
}
} }
new_items new_items

View file

@ -1,3 +1,9 @@
/* Test */
/* Test directives inside comments
'use server'
*/
// This should be kept
'use client' 'use client'
import foo, { a, b } from 'foo' import foo, { a, b } from 'foo'
@ -7,3 +13,7 @@ export { a as x }
export { y } from '1' export { y } from '1'
export { b } export { b }
export { foo as default, z } export { foo as default, z }
// This should be removed as it's not on top
// prettier-ignore
'use strict'

View file

@ -1,2 +1,5 @@
'use client'; /* Test */ /* Test directives inside comments
'use server'
*/ // This should be kept
export const __next_private_export_map__ = '[["x","foo","a"],["y","1","y"],["b","foo","b"],["default","foo","default"],["z","bar","default"]]'; export const __next_private_export_map__ = '[["x","foo","a"],["y","1","y"],["b","foo","b"],["default","foo","default"],["z","bar","default"]]';
export const __next_private_directive_list__ = '["use client"]';

View file

@ -746,7 +746,7 @@ export default async function getBaseWebpackConfig(
config.experimental.externalDir || !!config.transpilePackages config.experimental.externalDir || !!config.transpilePackages
const codeCondition = { const codeCondition = {
test: /\.(tsx|ts|js|cjs|mjs|jsx)$/, test: { or: [/\.(tsx|ts|js|cjs|mjs|jsx)$/, /__barrel_optimize__/] },
...(shouldIncludeExternalDirs ...(shouldIncludeExternalDirs
? // Allowing importing TS/TSX files from outside of the root dir. ? // Allowing importing TS/TSX files from outside of the root dir.
{} {}
@ -1124,37 +1124,6 @@ export default async function getBaseWebpackConfig(
}, },
module: { module: {
rules: [ rules: [
{
// 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(',')
return [
{
loader: 'next-barrel-loader',
options: {
names,
swcCacheDir: path.join(
dir,
config?.distDir ?? '.next',
'cache',
'swc'
),
},
// 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,
},
]
},
},
// Alias server-only and client-only to proper exports based on bundling layers // Alias server-only and client-only to proper exports based on bundling layers
{ {
issuerLayer: { issuerLayer: {
@ -1580,6 +1549,40 @@ export default async function getBaseWebpackConfig(
test: /[\\/]next[\\/]dist[\\/](esm[\\/])?server[\\/]og[\\/]image-response\.js/, test: /[\\/]next[\\/]dist[\\/](esm[\\/])?server[\\/]og[\\/]image-response\.js/,
sideEffects: false, sideEffects: false,
}, },
{
// This loader rule should be before other rules, as it can output code
// that still contains `"use client"` or `"use server"` statements that
// needs to be re-transformed by the RSC compilers.
// 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(',')
return [
{
loader: 'next-barrel-loader',
options: {
names,
swcCacheDir: path.join(
dir,
config?.distDir ?? '.next',
'cache',
'swc'
),
},
// 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,
},
]
},
},
], ],
}, },
plugins: [ plugins: [

View file

@ -88,7 +88,6 @@ import type webpack from 'webpack'
import path from 'path' import path from 'path'
import { transform } from '../../swc' import { transform } from '../../swc'
import { WEBPACK_LAYERS } from '../../../lib/constants'
// This is a in-memory cache for the mapping of barrel exports. This only applies // This is a in-memory cache for the mapping of barrel exports. This only applies
// to the packages that we optimize. It will never change (e.g. upgrading packages) // to the packages that we optimize. It will never change (e.g. upgrading packages)
@ -97,14 +96,13 @@ import { WEBPACK_LAYERS } from '../../../lib/constants'
const barrelTransformMappingCache = new Map< const barrelTransformMappingCache = new Map<
string, string,
{ {
prefix: string
exportList: [string, string, string][] exportList: [string, string, string][]
wildcardExports: string[] wildcardExports: string[]
isClientEntry: boolean
} | null } | null
>() >()
async function getBarrelMapping( async function getBarrelMapping(
layer: string | null | undefined,
resourcePath: string, resourcePath: string,
swcCacheDir: string, swcCacheDir: string,
resolve: (context: string, request: string) => Promise<string>, resolve: (context: string, request: string) => Promise<string>,
@ -135,9 +133,6 @@ async function getBarrelMapping(
optimizeBarrelExports: { optimizeBarrelExports: {
wildcard: isWildcard, wildcard: isWildcard,
}, },
serverComponents: {
isReactServerLayer: layer === WEBPACK_LAYERS.reactServerComponents,
},
jsc: { jsc: {
parser: { parser: {
syntax: isTypeScript ? 'typescript' : 'ecmascript', syntax: isTypeScript ? 'typescript' : 'ecmascript',
@ -155,7 +150,11 @@ async function getBarrelMapping(
// Avoid circular `export *` dependencies // Avoid circular `export *` dependencies
const visited = new Set<string>() const visited = new Set<string>()
async function getMatches(file: string, isWildcard: boolean) { async function getMatches(
file: string,
isWildcard: boolean,
isClientEntry: boolean
) {
if (visited.has(file)) { if (visited.has(file)) {
return null return null
} }
@ -180,7 +179,15 @@ async function getBarrelMapping(
return null return null
} }
const prefix = matches[1] const matchedDirectives = output.match(
/^([^]*)export (const|var) __next_private_directive_list__ = '([^']+)'/
)
const directiveList = matchedDirectives
? JSON.parse(matchedDirectives[3])
: []
// "use client" in barrel files has to be transferred to the target file.
isClientEntry = directiveList.includes('use client')
let exportList = JSON.parse(matches[3].slice(1, -1)) as [ let exportList = JSON.parse(matches[3].slice(1, -1)) as [
string, string,
string, string,
@ -209,7 +216,11 @@ async function getBarrelMapping(
req.replace('__barrel_optimize__?names=__PLACEHOLDER__!=!', '') req.replace('__barrel_optimize__?names=__PLACEHOLDER__!=!', '')
) )
const targetMatches = await getMatches(targetPath, true) const targetMatches = await getMatches(
targetPath,
true,
isClientEntry
)
if (targetMatches) { if (targetMatches) {
// Merge the export list // Merge the export list
exportList = exportList.concat(targetMatches.exportList) exportList = exportList.concat(targetMatches.exportList)
@ -219,13 +230,13 @@ async function getBarrelMapping(
} }
return { return {
prefix,
exportList, exportList,
wildcardExports, wildcardExports,
isClientEntry,
} }
} }
const res = await getMatches(resourcePath, false) const res = await getMatches(resourcePath, false, false)
barrelTransformMappingCache.set(resourcePath, res) barrelTransformMappingCache.set(resourcePath, res)
return res return res
@ -249,7 +260,6 @@ const NextBarrelLoader = async function (
}) })
const mapping = await getBarrelMapping( const mapping = await getBarrelMapping(
this._module?.layer,
this.resourcePath, this.resourcePath,
swcCacheDir, swcCacheDir,
resolve, resolve,
@ -271,15 +281,14 @@ const NextBarrelLoader = async function (
return return
} }
// It needs to keep the prefix for comments and directives like "use client".
const prefix = mapping.prefix
const exportList = mapping.exportList const exportList = mapping.exportList
const isClientEntry = mapping.isClientEntry
const exportMap = new Map<string, [string, string]>() const exportMap = new Map<string, [string, string]>()
for (const [name, filePath, orig] of exportList) { for (const [name, filePath, orig] of exportList) {
exportMap.set(name, [filePath, orig]) exportMap.set(name, [filePath, orig])
} }
let output = prefix let output = ''
let missedNames: string[] = [] let missedNames: string[] = []
for (const name of names) { for (const name of names) {
// If the name matches // If the name matches
@ -313,6 +322,12 @@ const NextBarrelLoader = async function (
} }
} }
// When it has `"use client"` inherited from its barrel files, we need to
// prefix it to this target file as well.
if (isClientEntry) {
output = `"use client";\n${output}`
}
this.callback(null, output) this.callback(null, output)
} }

View file

@ -1,4 +1,7 @@
import { RSC_MODULE_TYPES } from '../../../shared/lib/constants' import {
BARREL_OPTIMIZATION_PREFIX,
RSC_MODULE_TYPES,
} from '../../../shared/lib/constants'
import { getModuleBuildInfo } from './get-module-build-info' import { getModuleBuildInfo } from './get-module-build-info'
import { regexCSS } from './utils' import { regexCSS } from './utils'
@ -26,7 +29,11 @@ export default function transformSource(this: any) {
.filter((request) => (isServer ? !regexCSS.test(request) : true)) .filter((request) => (isServer ? !regexCSS.test(request) : true))
.map( .map(
(request) => (request) =>
`import(/* webpackMode: "eager" */ ${JSON.stringify(request)})` `import(/* webpackMode: "eager" */ ${JSON.stringify(
request.startsWith(BARREL_OPTIMIZATION_PREFIX)
? request.replace(':', '!=!')
: request
)})`
) )
.join(';\n') .join(';\n')

View file

@ -193,7 +193,11 @@ export class FlightClientEntryPlugin {
const modQuery = mod.resourceResolveData?.query || '' const modQuery = mod.resourceResolveData?.query || ''
// query is already part of mod.resource // query is already part of mod.resource
// so it's only necessary to add it for matchResource or mod.resourceResolveData // so it's only necessary to add it for matchResource or mod.resourceResolveData
const modResource = modPath ? modPath + modQuery : mod.resource const modResource = modPath
? modPath.startsWith(BARREL_OPTIMIZATION_PREFIX)
? mod.resource
: modPath + modQuery
: mod.resource
if (mod.layer !== WEBPACK_LAYERS.serverSideRendering) { if (mod.layer !== WEBPACK_LAYERS.serverSideRendering) {
return return

View file

@ -28,6 +28,9 @@ createNextDescribe(
'@heroicons/react': '2.0.18', '@heroicons/react': '2.0.18',
'@visx/visx': '3.3.0', '@visx/visx': '3.3.0',
'recursive-barrel': '1.0.0', 'recursive-barrel': '1.0.0',
'@mui/material': '5.14.19',
'@emotion/styled': '11.11.0',
'@emotion/react': '11.11.1',
}, },
}, },
({ next }) => { ({ next }) => {
@ -127,6 +130,25 @@ createNextDescribe(
expect(html).toContain('<linearGradient') expect(html).toContain('<linearGradient')
}) })
it('should support MUI', async () => {
let logs = ''
next.on('stdout', (log) => {
logs += log
})
// Ensure that MUI is working
const html = await next.render('/mui')
expect(html).toContain('test_mui')
const modules = [...logs.matchAll(/\((\d+) modules\)/g)]
expect(modules.length).toBeGreaterThanOrEqual(1)
for (const [, moduleCount] of modules) {
// Ensure that the number of modules is less than 1500 - otherwise we're
// importing the entire library.
expect(parseInt(moduleCount)).toBeLessThan(1500)
}
})
it('should not break "use client" directive in optimized packages', async () => { it('should not break "use client" directive in optimized packages', async () => {
const html = await next.render('/client') const html = await next.render('/client')
expect(html).toContain('this is a client component') expect(html).toContain('this is a client component')

View file

@ -0,0 +1,5 @@
import { Button } from '@mui/material'
export default function Page() {
return <Button>test_mui</Button>
}

View file

@ -1459,7 +1459,8 @@
"optimizePackageImports app - should render the icons correctly without creating all the modules", "optimizePackageImports app - should render the icons correctly without creating all the modules",
"optimizePackageImports pages - should optimize recursive wildcard export mapping", "optimizePackageImports pages - should optimize recursive wildcard export mapping",
"optimizePackageImports pages - should render the icons correctly without creating all the modules", "optimizePackageImports pages - should render the icons correctly without creating all the modules",
"optimizePackageImports should support visx" "optimizePackageImports should support visx",
"optimizePackageImports should support MUI"
], ],
"pending": [], "pending": [],
"flakey": [], "flakey": [],