Skip to content

Commit bf89bee

Browse files
huozhisokra
andauthored
Support esm externals in app router (#65041)
### What Support `esmExternals` working in app router ### Why `esmExternals` was disabled for app router that most of the packages are picking up the CJS bundles for externals. This PR enables to resolve the ESM bundle for external packages. We have two issues discovered while enabling the flag, any esm external packages will fail in client page SSR and server action. We fixed them by changing the below bundling logics: * When a client page having a async dependency, we can await the page during in rendering * When a server action having a async dependency, we changed the server action entry creation with webpack along with the server client entry creation together, then webpack can handle the modules async propagation properly. Fixes #60756 Closes NEXT-2435 Closes NEXT-2472 Closes NEXT-3225 --------- Co-authored-by: Tobias Koppers <tobias.koppers@googlemail.com>
1 parent b91019d commit bf89bee

5 files changed

Lines changed: 114 additions & 146 deletions

File tree

packages/next/src/build/handle-externals.ts

Lines changed: 28 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { WEBPACK_LAYERS } from '../lib/constants'
21
import type { WebpackLayerName } from '../lib/constants'
2+
import type { NextConfigComplete } from '../server/config-shared'
3+
import type { ResolveOptions } from 'webpack'
34
import { defaultOverrides } from '../server/require-hook'
45
import { BARREL_OPTIMIZATION_PREFIX } from '../shared/lib/constants'
56
import path from '../shared/lib/isomorphic/path'
@@ -10,7 +11,6 @@ import {
1011
NODE_RESOLVE_OPTIONS,
1112
} from './webpack-config'
1213
import { isWebpackAppLayer, isWebpackServerOnlyLayer } from './utils'
13-
import type { NextConfigComplete } from '../server/config-shared'
1414
import { normalizePathSep } from '../shared/lib/page-path/normalize-path-sep'
1515
const reactPackagesRegex = /^(react|react-dom|react-server-dom-webpack)($|\/)/
1616

@@ -25,15 +25,6 @@ const externalPattern = new RegExp(
2525

2626
const nodeModulesRegex = /node_modules[/\\].*\.[mc]?js$/
2727

28-
function containsImportInPackages(
29-
request: string,
30-
packages: string[]
31-
): boolean {
32-
return packages.some(
33-
(pkg) => request === pkg || request.startsWith(pkg + '/')
34-
)
35-
}
36-
3728
export function isResourceInPackages(
3829
resource: string,
3930
packageNames?: string[],
@@ -57,9 +48,9 @@ export async function resolveExternal(
5748
context: string,
5849
request: string,
5950
isEsmRequested: boolean,
60-
optOutBundlingPackages: string[],
51+
_optOutBundlingPackages: string[],
6152
getResolve: (
62-
options: any
53+
options: ResolveOptions
6354
) => (
6455
resolveContext: string,
6556
resolveRequest: string
@@ -78,19 +69,12 @@ export async function resolveExternal(
7869
let isEsm: boolean = false
7970

8071
const preferEsmOptions =
81-
esmExternals &&
82-
isEsmRequested &&
83-
// For package that marked as externals that should be not bundled,
84-
// we don't resolve them as ESM since it could be resolved as async module,
85-
// such as `import(external package)` in the bundle, valued as a `Promise`.
86-
!containsImportInPackages(request, optOutBundlingPackages)
87-
? [true, false]
88-
: [false]
72+
esmExternals && isEsmRequested ? [true, false] : [false]
8973

9074
for (const preferEsm of preferEsmOptions) {
91-
const resolve = getResolve(
92-
preferEsm ? esmResolveOptions : nodeResolveOptions
93-
)
75+
const resolveOptions = preferEsm ? esmResolveOptions : nodeResolveOptions
76+
77+
const resolve = getResolve(resolveOptions)
9478

9579
// Resolve the import with the webpack provided context, this
9680
// ensures we're resolving the correct version when multiple
@@ -273,23 +257,6 @@ export function makeExternalHandler({
273257
return resolveNextExternal(request)
274258
}
275259

276-
// Early return if the request needs to be bundled, such as in the client layer.
277-
// Treat react packages and next internals as external for SSR layer,
278-
// also map react to builtin ones with require-hook.
279-
// Otherwise keep continue the process to resolve the externals.
280-
if (layer === WEBPACK_LAYERS.serverSideRendering) {
281-
const isRelative = request.startsWith('.')
282-
const fullRequest = isRelative
283-
? normalizePathSep(path.join(context, request))
284-
: request
285-
286-
// Check if it's opt out bundling package first
287-
if (containsImportInPackages(fullRequest, optOutBundlingPackages)) {
288-
return fullRequest
289-
}
290-
return resolveNextExternal(fullRequest)
291-
}
292-
293260
// TODO-APP: Let's avoid this resolve call as much as possible, and eventually get rid of it.
294261
const resolveResult = await resolveExternal(
295262
dir,
@@ -320,6 +287,13 @@ export function makeExternalHandler({
320287
return
321288
}
322289

290+
const isOptOutBundling = optOutBundlingPackageRegex.test(res)
291+
// Apply bundling rules to all app layers.
292+
// Since handleExternals only handle the server layers, we don't need to exclude client here
293+
if (!isOptOutBundling && isAppLayer) {
294+
return
295+
}
296+
323297
// ESM externals can only be imported (and not required).
324298
// Make an exception in loose mode.
325299
if (!isEsmRequested && isEsm && !looseEsmExternals && !isLocal) {
@@ -370,13 +344,11 @@ export function makeExternalHandler({
370344

371345
const resolvedBundlingOptOutRes = resolveBundlingOptOutPackages({
372346
resolvedRes: res,
373-
optOutBundlingPackageRegex,
374347
config,
375348
resolvedExternalPackageDirs,
376-
isEsm,
377349
isAppLayer,
378-
layer,
379350
externalType,
351+
isOptOutBundling,
380352
request,
381353
})
382354
if (resolvedBundlingOptOutRes) {
@@ -390,41 +362,32 @@ export function makeExternalHandler({
390362

391363
function resolveBundlingOptOutPackages({
392364
resolvedRes,
393-
optOutBundlingPackageRegex,
394365
config,
395366
resolvedExternalPackageDirs,
396-
isEsm,
397367
isAppLayer,
398-
layer,
399368
externalType,
369+
isOptOutBundling,
400370
request,
401371
}: {
402372
resolvedRes: string
403-
optOutBundlingPackageRegex: RegExp
404373
config: NextConfigComplete
405374
resolvedExternalPackageDirs: Map<string, string>
406-
isEsm: boolean
407375
isAppLayer: boolean
408-
layer: WebpackLayerName | null
409376
externalType: string
377+
isOptOutBundling: boolean
410378
request: string
411379
}) {
412-
const shouldBeBundled =
413-
isResourceInPackages(
414-
resolvedRes,
415-
config.transpilePackages,
416-
resolvedExternalPackageDirs
417-
) ||
418-
(isEsm && isAppLayer) ||
419-
(!isAppLayer && config.bundlePagesRouterDependencies)
420-
421380
if (nodeModulesRegex.test(resolvedRes)) {
422-
const isOptOutBundling = optOutBundlingPackageRegex.test(resolvedRes)
423-
if (isWebpackServerOnlyLayer(layer)) {
424-
if (isOptOutBundling) {
425-
return `${externalType} ${request}` // Externalize if opted out
426-
}
427-
} else if (!shouldBeBundled || isOptOutBundling) {
381+
const shouldBundlePages =
382+
!isAppLayer && config.bundlePagesRouterDependencies && !isOptOutBundling
383+
const shouldBeBundled =
384+
shouldBundlePages ||
385+
isResourceInPackages(
386+
resolvedRes,
387+
config.transpilePackages,
388+
resolvedExternalPackageDirs
389+
)
390+
if (!shouldBeBundled) {
428391
return `${externalType} ${request}` // Externalize if not bundled or opted out
429392
}
430393
}

packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts

Lines changed: 66 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -430,75 +430,6 @@ export class FlightClientEntryPlugin {
430430
)
431431
}
432432

433-
compilation.hooks.finishModules.tapPromise(PLUGIN_NAME, async () => {
434-
const addedClientActionEntryList: Promise<any>[] = []
435-
const actionMapsPerClientEntry: Record<string, Map<string, string[]>> = {}
436-
437-
// We need to create extra action entries that are created from the
438-
// client layer.
439-
// Start from each entry's created SSR dependency from our previous step.
440-
for (const [name, ssrEntryDependencies] of Object.entries(
441-
createdSSRDependenciesForEntry
442-
)) {
443-
// Collect from all entries, e.g. layout.js, page.js, loading.js, ...
444-
// add aggregate them.
445-
const actionEntryImports = this.collectClientActionsFromDependencies({
446-
compilation,
447-
dependencies: ssrEntryDependencies,
448-
})
449-
450-
if (actionEntryImports.size > 0) {
451-
if (!actionMapsPerClientEntry[name]) {
452-
actionMapsPerClientEntry[name] = new Map()
453-
}
454-
actionMapsPerClientEntry[name] = new Map([
455-
...actionMapsPerClientEntry[name],
456-
...actionEntryImports,
457-
])
458-
}
459-
}
460-
461-
for (const [name, actionEntryImports] of Object.entries(
462-
actionMapsPerClientEntry
463-
)) {
464-
// If an action method is already created in the server layer, we don't
465-
// need to create it again in the action layer.
466-
// This is to avoid duplicate action instances and make sure the module
467-
// state is shared.
468-
let remainingClientImportedActions = false
469-
const remainingActionEntryImports = new Map<string, string[]>()
470-
for (const [dep, actionNames] of actionEntryImports) {
471-
const remainingActionNames = []
472-
for (const actionName of actionNames) {
473-
const id = name + '@' + dep + '@' + actionName
474-
if (!createdActions.has(id)) {
475-
remainingActionNames.push(actionName)
476-
}
477-
}
478-
if (remainingActionNames.length > 0) {
479-
remainingActionEntryImports.set(dep, remainingActionNames)
480-
remainingClientImportedActions = true
481-
}
482-
}
483-
484-
if (remainingClientImportedActions) {
485-
addedClientActionEntryList.push(
486-
this.injectActionEntry({
487-
compiler,
488-
compilation,
489-
actions: remainingActionEntryImports,
490-
entryName: name,
491-
bundlePath: name,
492-
fromClient: true,
493-
})
494-
)
495-
}
496-
}
497-
498-
await Promise.all(addedClientActionEntryList)
499-
return
500-
})
501-
502433
// Invalidate in development to trigger recompilation
503434
const invalidator = getInvalidator(compiler.outputPath)
504435
// Check if any of the entry injections need an invalidation
@@ -521,6 +452,72 @@ export class FlightClientEntryPlugin {
521452

522453
// Wait for action entries to be added.
523454
await Promise.all(addActionEntryList)
455+
456+
const addedClientActionEntryList: Promise<any>[] = []
457+
const actionMapsPerClientEntry: Record<string, Map<string, string[]>> = {}
458+
459+
// We need to create extra action entries that are created from the
460+
// client layer.
461+
// Start from each entry's created SSR dependency from our previous step.
462+
for (const [name, ssrEntryDependencies] of Object.entries(
463+
createdSSRDependenciesForEntry
464+
)) {
465+
// Collect from all entries, e.g. layout.js, page.js, loading.js, ...
466+
// add aggregate them.
467+
const actionEntryImports = this.collectClientActionsFromDependencies({
468+
compilation,
469+
dependencies: ssrEntryDependencies,
470+
})
471+
472+
if (actionEntryImports.size > 0) {
473+
if (!actionMapsPerClientEntry[name]) {
474+
actionMapsPerClientEntry[name] = new Map()
475+
}
476+
actionMapsPerClientEntry[name] = new Map([
477+
...actionMapsPerClientEntry[name],
478+
...actionEntryImports,
479+
])
480+
}
481+
}
482+
483+
for (const [name, actionEntryImports] of Object.entries(
484+
actionMapsPerClientEntry
485+
)) {
486+
// If an action method is already created in the server layer, we don't
487+
// need to create it again in the action layer.
488+
// This is to avoid duplicate action instances and make sure the module
489+
// state is shared.
490+
let remainingClientImportedActions = false
491+
const remainingActionEntryImports = new Map<string, string[]>()
492+
for (const [dep, actionNames] of actionEntryImports) {
493+
const remainingActionNames = []
494+
for (const actionName of actionNames) {
495+
const id = name + '@' + dep + '@' + actionName
496+
if (!createdActions.has(id)) {
497+
remainingActionNames.push(actionName)
498+
}
499+
}
500+
if (remainingActionNames.length > 0) {
501+
remainingActionEntryImports.set(dep, remainingActionNames)
502+
remainingClientImportedActions = true
503+
}
504+
}
505+
506+
if (remainingClientImportedActions) {
507+
addedClientActionEntryList.push(
508+
this.injectActionEntry({
509+
compiler,
510+
compilation,
511+
actions: remainingActionEntryImports,
512+
entryName: name,
513+
bundlePath: name,
514+
fromClient: true,
515+
})
516+
)
517+
}
518+
}
519+
520+
await Promise.all(addedClientActionEntryList)
524521
}
525522

526523
collectClientActionsFromDependencies({

packages/next/src/server/app-render/create-component-tree.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ async function createComponentTreeInternal({
265265
}
266266

267267
const LayoutOrPage: React.ComponentType<any> | undefined = layoutOrPageMod
268-
? interopDefault(layoutOrPageMod)
268+
? await interopDefault(layoutOrPageMod)
269269
: undefined
270270

271271
/**

test/e2e/app-dir/app-external/app-external.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ async function resolveStreamResponse(response: any, onData?: any) {
1616
}
1717

1818
describe('app dir - external dependency', () => {
19-
const { next, skipped } = nextTestSetup({
19+
const { next, skipped, isTurbopack } = nextTestSetup({
2020
files: __dirname,
2121
dependencies: {
2222
swr: 'latest',
@@ -279,14 +279,17 @@ describe('app dir - external dependency', () => {
279279
})
280280

281281
describe('server actions', () => {
282-
it('should not prefer to resolve esm over cjs for bundling optout packages', async () => {
282+
it('should prefer to resolve esm over cjs for bundling optout packages', async () => {
283283
const browser = await next.browser('/optout/action')
284284
expect(await browser.elementByCss('#dual-pkg-outout p').text()).toBe('')
285285

286286
browser.elementByCss('#dual-pkg-outout button').click()
287287
await check(async () => {
288288
const text = await browser.elementByCss('#dual-pkg-outout p').text()
289-
expect(text).toBe('dual-pkg-optout:cjs')
289+
// TODO: enable esm externals for app router in turbopack for actions
290+
expect(text).toBe(
291+
isTurbopack ? 'dual-pkg-optout:cjs' : 'dual-pkg-optout:mjs'
292+
)
290293
return 'success'
291294
}, /success/)
292295
})

0 commit comments

Comments
 (0)