From 2a55e721e5140dc4505e04ef749ee434c817ec17 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Thu, 22 Aug 2024 18:42:28 +0200 Subject: [PATCH] Apply optimization for unused actions (#69178) --- .../plugins/flight-client-entry-plugin.ts | 211 ++++++++++++++++-- .../actions-tree-shaking/_testing/utils.ts | 78 +++++++ .../actions-tree-shaking/basic/app/actions.js | 13 ++ .../basic/app/client/page.js | 21 ++ .../basic/app/inline/page.js | 13 ++ .../actions-tree-shaking/basic/app/layout.js | 7 + .../basic/app/server/page.js | 10 + .../basic/basic-edge.test.ts | 3 + .../actions-tree-shaking/basic/basic.test.ts | 33 +++ .../mixed-module-actions/app/layout.js | 7 + .../app/mixed-module/cjs/actions.js | 13 ++ .../app/mixed-module/cjs/page.js | 13 ++ .../app/mixed-module/esm/actions.js | 13 ++ .../app/mixed-module/esm/page.js | 13 ++ .../mixed-module-actions-edge.test.ts | 3 + .../mixed-module-actions.test.ts | 29 +++ .../reexport/app/layout.js | 7 + .../app/named-reexport/client/actions.js | 13 ++ .../app/named-reexport/client/page.js | 21 ++ .../named-reexport/client/reexport-action.js | 5 + .../app/named-reexport/server/actions.js | 13 ++ .../app/named-reexport/server/page.js | 12 + .../named-reexport/server/reexport-action.js | 5 + .../app/namespace-reexport/client/actions.js | 13 ++ .../app/namespace-reexport/client/page.js | 21 ++ .../app/namespace-reexport/server/actions.js | 13 ++ .../app/namespace-reexport/server/page.js | 12 + .../reexport/reexport-edge.test.ts | 3 + .../reexport/reexport.test.ts | 35 +++ .../app/client/actions.js | 13 ++ .../app/client/one/page.js | 22 ++ .../app/client/two/page.js | 22 ++ .../shared-module-actions/app/layout.js | 7 + .../app/server/actions.js | 13 ++ .../app/server/one/page.js | 13 ++ .../app/server/two/page.js | 13 ++ .../shared-module-actions-edge.test.ts | 3 + .../shared-module-actions.test.ts | 34 +++ test/turbopack-build-tests-manifest.json | 72 ++++++ 39 files changed, 841 insertions(+), 24 deletions(-) create mode 100644 test/production/app-dir/actions-tree-shaking/_testing/utils.ts create mode 100644 test/production/app-dir/actions-tree-shaking/basic/app/actions.js create mode 100644 test/production/app-dir/actions-tree-shaking/basic/app/client/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/basic/app/inline/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/basic/app/layout.js create mode 100644 test/production/app-dir/actions-tree-shaking/basic/app/server/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/basic/basic-edge.test.ts create mode 100644 test/production/app-dir/actions-tree-shaking/basic/basic.test.ts create mode 100644 test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/layout.js create mode 100644 test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/cjs/actions.js create mode 100644 test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/cjs/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/esm/actions.js create mode 100644 test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/esm/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions-edge.test.ts create mode 100644 test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions.test.ts create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/layout.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/actions.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/reexport-action.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/actions.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/reexport-action.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/client/actions.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/client/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/server/actions.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/server/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/reexport-edge.test.ts create mode 100644 test/production/app-dir/actions-tree-shaking/reexport/reexport.test.ts create mode 100644 test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/actions.js create mode 100644 test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/one/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/two/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/shared-module-actions/app/layout.js create mode 100644 test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/actions.js create mode 100644 test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/one/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/two/page.js create mode 100644 test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions-edge.test.ts create mode 100644 test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions.test.ts diff --git a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts index c539db3baf206..051ffddada6d3 100644 --- a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts @@ -30,6 +30,7 @@ import { isClientComponentEntryModule, isCSSMod, regexCSS, + isActionServerLayerEntryModule, } from '../loaders/utils' import { traverseModules, @@ -77,6 +78,11 @@ const pluginState = getProxiedPluginState({ serverActions: {} as ActionManifest['node'], edgeServerActions: {} as ActionManifest['edge'], + usedActions: { + node: {} as Record>, + edge: {} as Record>, + }, + actionModServerId: {} as Record< string, { @@ -165,12 +171,25 @@ function deduplicateCSSImportsForEntry(mergedCSSimports: CssImports) { return dedupedCSSImports } +// Collection of action module path and action names per runtime. +type UsedActionMap = { + node: Record> + edge: Record> +} +type UsedActionPerEntry = { + [entryName: string]: UsedActionMap +} + export class FlightClientEntryPlugin { dev: boolean appDir: string encryptionKey: string isEdgeServer: boolean assetPrefix: string + webpackRuntime: string + + // Collect the used actions based on the entry name and runtime. + usedActions: UsedActionPerEntry constructor(options: Options) { this.dev = options.dev @@ -178,6 +197,43 @@ export class FlightClientEntryPlugin { this.isEdgeServer = options.isEdgeServer this.assetPrefix = !this.dev && !this.isEdgeServer ? '../' : '' this.encryptionKey = options.encryptionKey + this.webpackRuntime = this.isEdgeServer + ? EDGE_RUNTIME_WEBPACK + : DEFAULT_RUNTIME_WEBPACK + + this.usedActions = {} + } + + getUsedActionsInEntry( + entryName: string, + modResource: string + ): Set | undefined { + const runtime = this.isEdgeServer ? 'edge' : 'node' + const actionsRuntimeMap = this.usedActions[entryName] + const actionMap = actionsRuntimeMap ? actionsRuntimeMap[runtime] : undefined + return actionMap ? actionMap[modResource] : undefined + } + + setUsedActionsInEntry( + entryName: string, + modResource: string, + actionNames: string[] + ) { + const runtime = this.isEdgeServer ? 'edge' : 'node' + if (!this.usedActions[entryName]) { + this.usedActions[entryName] = { + node: {}, + edge: {}, + } + } + if (!this.usedActions[entryName][runtime]) { + this.usedActions[entryName][runtime] = {} + } + const actionsMap = this.usedActions[entryName][runtime] + if (!actionsMap[modResource]) { + actionsMap[modResource] = new Set() + } + actionNames.forEach((name) => actionsMap[modResource].add(name)) } apply(compiler: webpack.Compiler) { @@ -299,6 +355,7 @@ export class FlightClientEntryPlugin { const { clientComponentImports, actionImports, cssImports } = this.collectComponentInfoFromServerEntryDependency({ + entryName: name, entryRequest, compilation, resolvedModule: connection.resolvedModule, @@ -414,11 +471,6 @@ export class FlightClientEntryPlugin { for (const [name, actionEntryImports] of Object.entries( actionMapsPerEntry )) { - for (const [dep, actionNames] of actionEntryImports) { - for (const actionName of actionNames) { - createdActions.add(name + '@' + dep + '@' + actionName) - } - } addActionEntryList.push( this.injectActionEntry({ compiler, @@ -426,6 +478,7 @@ export class FlightClientEntryPlugin { actions: actionEntryImports, entryName: name, bundlePath: name, + createdActions, }) ) } @@ -465,6 +518,7 @@ export class FlightClientEntryPlugin { // Collect from all entries, e.g. layout.js, page.js, loading.js, ... // add aggregate them. const actionEntryImports = this.collectClientActionsFromDependencies({ + entryName: name, compilation, dependencies: ssrEntryDependencies, }) @@ -512,6 +566,7 @@ export class FlightClientEntryPlugin { entryName: name, bundlePath: name, fromClient: true, + createdActions, }) ) } @@ -521,9 +576,11 @@ export class FlightClientEntryPlugin { } collectClientActionsFromDependencies({ + entryName, compilation, dependencies, }: { + entryName: string compilation: webpack.Compilation dependencies: ReturnType[] }) { @@ -541,23 +598,45 @@ export class FlightClientEntryPlugin { entryRequest: string resolvedModule: any }) => { - const collectActionsInDep = (mod: webpack.NormalModule): void => { + const collectActionsInDep = ( + mod: webpack.NormalModule, + ids: string[] + ): void => { if (!mod) return const modResource = getModuleResource(mod) - if (!modResource || visitedModule.has(modResource)) return - visitedModule.add(modResource) + if (!modResource) return const actions = getActionsFromBuildInfo(mod) + + // Collect used exported actions. + if (visitedModule.has(modResource) && actions) { + this.setUsedActionsInEntry(entryName, modResource, ids) + } + + if (visitedModule.has(modResource)) return + + visitedModule.add(modResource) + if (actions) { collectedActions.set(modResource, actions) } + // Collect used exported actions transversely. getModuleReferencesInOrder(mod, compilation.moduleGraph).forEach( - (connection) => { + (connection: any) => { + let dependencyIds: string[] = [] + const depModule = connection.dependency + if (depModule?.ids) { + dependencyIds.push(...depModule.ids) + } else { + dependencyIds = depModule.category === 'esm' ? [] : ['*'] + } + collectActionsInDep( - connection.resolvedModule as webpack.NormalModule + connection.resolvedModule as webpack.NormalModule, + dependencyIds ) } ) @@ -569,7 +648,7 @@ export class FlightClientEntryPlugin { !entryRequest.includes('next-flight-action-entry-loader') ) { // Traverse the module graph to find all client components. - collectActionsInDep(resolvedModule) + collectActionsInDep(resolvedModule, []) } } @@ -599,10 +678,12 @@ export class FlightClientEntryPlugin { } collectComponentInfoFromServerEntryDependency({ + entryName, entryRequest, compilation, resolvedModule, }: { + entryName: string entryRequest: string compilation: webpack.Compilation resolvedModule: any /* Dependency */ @@ -612,7 +693,8 @@ export class FlightClientEntryPlugin { actionImports: [string, string[]][] } { // Keep track of checked modules to avoid infinite loops with recursive imports. - const visited = new Set() + const visitedOfClientComponentsTraverse = new Set() + const visitedOfActionTraverse = new Set() // Info to collect. const clientComponentImports: ClientComponentImports = {} @@ -625,11 +707,10 @@ export class FlightClientEntryPlugin { ): void => { if (!mod) return - const isCSS = isCSSMod(mod) const modResource = getModuleResource(mod) if (!modResource) return - if (visited.has(modResource)) { + if (visitedOfClientComponentsTraverse.has(modResource)) { if (clientComponentImports[modResource]) { addClientImport( mod, @@ -641,25 +722,21 @@ export class FlightClientEntryPlugin { } return } - visited.add(modResource) + visitedOfClientComponentsTraverse.add(modResource) const actions = getActionsFromBuildInfo(mod) if (actions) { actionImports.push([modResource, actions]) } - const webpackRuntime = this.isEdgeServer - ? EDGE_RUNTIME_WEBPACK - : DEFAULT_RUNTIME_WEBPACK - - if (isCSS) { + if (isCSSMod(mod)) { const sideEffectFree = mod.factoryMeta && (mod.factoryMeta as any).sideEffectFree if (sideEffectFree) { const unused = !compilation.moduleGraph .getExportsInfo(mod) - .isModuleUsed(webpackRuntime) + .isModuleUsed(this.webpackRuntime) if (unused) return } @@ -697,9 +774,56 @@ export class FlightClientEntryPlugin { ) } + const filterUsedActions = ( + mod: webpack.NormalModule, + importedIdentifiers: string[] + ): void => { + if (!mod) return + + const modResource = getModuleResource(mod) + + if (!modResource) return + if (visitedOfActionTraverse.has(modResource)) { + if (this.getUsedActionsInEntry(entryName, modResource)) { + this.setUsedActionsInEntry( + entryName, + modResource, + importedIdentifiers + ) + } + return + } + visitedOfActionTraverse.add(modResource) + + if (isActionServerLayerEntryModule(mod)) { + // `ids` are the identifiers that are imported from the dependency, + // if it's present, it's an array of strings. + this.setUsedActionsInEntry(entryName, modResource, importedIdentifiers) + + return + } + + getModuleReferencesInOrder(mod, compilation.moduleGraph).forEach( + (connection: any) => { + let dependencyIds: string[] = [] + const depModule = connection.dependency + if (depModule?.ids) { + dependencyIds.push(...depModule.ids) + } else { + dependencyIds = depModule.category === 'esm' ? [] : ['*'] + } + + filterUsedActions(connection.resolvedModule, dependencyIds) + } + ) + } + // Traverse the module graph to find all client components. filterClientComponents(resolvedModule, []) + // Traverse the module graph to find all used actions. + filterUsedActions(resolvedModule, []) + return { clientComponentImports, cssImports: CSSImports.size @@ -842,16 +966,49 @@ export class FlightClientEntryPlugin { entryName, bundlePath, fromClient, + createdActions, }: { compiler: webpack.Compiler compilation: webpack.Compilation actions: Map entryName: string bundlePath: string + createdActions: Set fromClient?: boolean }) { + // Filter out the unused actions before create action entry. + for (const [filePath, names] of actions.entries()) { + const usedActionNames = this.getUsedActionsInEntry(entryName, filePath) + if (!usedActionNames) continue + const containsAll = usedActionNames.has('*') + if (usedActionNames && !containsAll) { + const filteredNames = names.filter( + (name) => usedActionNames.has(name) || isInlineActionIdentifier(name) + ) + actions.set(filePath, filteredNames) + } else if (!containsAll) { + // If we didn't collect the used, we erase them from the collected actions + // to avoid creating the action entry. + if ( + names.filter((name) => !isInlineActionIdentifier(name)).length === 0 + ) { + actions.delete(filePath) + } + } + } + const actionsArray = Array.from(actions.entries()) + for (const [dep, actionNames] of actions) { + for (const actionName of actionNames) { + createdActions.add(entryName + '@' + dep + '@' + actionName) + } + } + + if (actionsArray.length === 0) { + return Promise.resolve() + } + const actionLoader = `next-flight-action-entry-loader?${stringify({ actions: JSON.stringify(actionsArray), __client_imported__: fromClient, @@ -860,9 +1017,10 @@ export class FlightClientEntryPlugin { const currentCompilerServerActions = this.isEdgeServer ? pluginState.edgeServerActions : pluginState.serverActions - for (const [p, names] of actionsArray) { - for (const name of names) { - const id = generateActionId(p, name) + + for (const [actionFilePath, actionNames] of actionsArray) { + for (const name of actionNames) { + const id = generateActionId(actionFilePath, name) if (typeof currentCompilerServerActions[id] === 'undefined') { currentCompilerServerActions[id] = { workers: {}, @@ -1074,3 +1232,8 @@ function getModuleResource(mod: webpack.NormalModule): string { } return modResource } + +// x-ref crates/next-custom-transforms/src/transforms/server_actions.rs `gen_ident` funcition +function isInlineActionIdentifier(name: string) { + return name.startsWith('$$ACTION_') +} diff --git a/test/production/app-dir/actions-tree-shaking/_testing/utils.ts b/test/production/app-dir/actions-tree-shaking/_testing/utils.ts new file mode 100644 index 0000000000000..71ce00c0f3e84 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/_testing/utils.ts @@ -0,0 +1,78 @@ +import { type NextInstance } from 'e2e-utils' + +async function getActionsMappingByRuntime( + next: NextInstance, + runtime: 'node' | 'edge' +) { + const manifest = JSON.parse( + await next.readFile('.next/server/server-reference-manifest.json') + ) + + return manifest[runtime] +} + +export function markLayoutAsEdge(next: NextInstance) { + beforeAll(async () => { + await next.stop() + const layoutContent = await next.readFile('app/layout.js') + await next.patchFile( + 'app/layout.js', + layoutContent + `\nexport const runtime = 'edge'` + ) + await next.start() + }) +} + +/* +{ + [route path]: { [layer]: Set ] +} +*/ +type ActionsMappingOfRuntime = { + [actionId: string]: { + workers: { + [route: string]: string + } + layer: { + [route: string]: string + } + } +} +type ActionState = { + [route: string]: { + [layer: string]: number + } +} + +function getActionsRoutesState( + actionsMappingOfRuntime: ActionsMappingOfRuntime +): ActionState { + const state: ActionState = {} + Object.keys(actionsMappingOfRuntime).forEach((actionId) => { + const action = actionsMappingOfRuntime[actionId] + const routePaths = Object.keys(action.workers) + + routePaths.forEach((routePath) => { + if (!state[routePath]) { + state[routePath] = {} + } + const layer = action.layer[routePath] + + if (!state[routePath][layer]) { + state[routePath][layer] = 0 + } + + state[routePath][layer]++ + }) + }) + + return state +} + +export async function getActionsRoutesStateByRuntime(next: NextInstance) { + const actionsMappingOfRuntime = await getActionsMappingByRuntime( + next, + process.env.TEST_EDGE ? 'edge' : 'node' + ) + return getActionsRoutesState(actionsMappingOfRuntime) +} diff --git a/test/production/app-dir/actions-tree-shaking/basic/app/actions.js b/test/production/app-dir/actions-tree-shaking/basic/app/actions.js new file mode 100644 index 0000000000000..baab9fc1592e2 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/basic/app/actions.js @@ -0,0 +1,13 @@ +'use server' + +export async function serverComponentAction() { + return 'server-action' +} + +export async function clientComponentAction() { + return 'client-action' +} + +export async function unusedExportedAction() { + return 'unused-exported-action' +} diff --git a/test/production/app-dir/actions-tree-shaking/basic/app/client/page.js b/test/production/app-dir/actions-tree-shaking/basic/app/client/page.js new file mode 100644 index 0000000000000..acb21285408f0 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/basic/app/client/page.js @@ -0,0 +1,21 @@ +'use client' + +import { useState } from 'react' +import { clientComponentAction } from '../actions' + +export default function Page() { + const [text, setText] = useState('initial') + return ( +
+ + {text} +
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/basic/app/inline/page.js b/test/production/app-dir/actions-tree-shaking/basic/app/inline/page.js new file mode 100644 index 0000000000000..2af62bdd5b516 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/basic/app/inline/page.js @@ -0,0 +1,13 @@ +export default function Page() { + // Inline Server Action + async function inlineServerAction() { + 'use server' + return 'inline-server-action' + } + + return ( +
+ +
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/basic/app/layout.js b/test/production/app-dir/actions-tree-shaking/basic/app/layout.js new file mode 100644 index 0000000000000..750eb927b1980 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/basic/app/layout.js @@ -0,0 +1,7 @@ +export default function Layout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/production/app-dir/actions-tree-shaking/basic/app/server/page.js b/test/production/app-dir/actions-tree-shaking/basic/app/server/page.js new file mode 100644 index 0000000000000..0c45f46e3d3cc --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/basic/app/server/page.js @@ -0,0 +1,10 @@ +import { serverComponentAction } from '../actions' + +export default function Page() { + return ( +
+ + +
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/basic/basic-edge.test.ts b/test/production/app-dir/actions-tree-shaking/basic/basic-edge.test.ts new file mode 100644 index 0000000000000..8b3a400e43107 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/basic/basic-edge.test.ts @@ -0,0 +1,3 @@ +process.env.TEST_EDGE = '1' + +require('./basic.test') diff --git a/test/production/app-dir/actions-tree-shaking/basic/basic.test.ts b/test/production/app-dir/actions-tree-shaking/basic/basic.test.ts new file mode 100644 index 0000000000000..8562000a161f8 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/basic/basic.test.ts @@ -0,0 +1,33 @@ +import { nextTestSetup } from 'e2e-utils' +import { + getActionsRoutesStateByRuntime, + markLayoutAsEdge, +} from '../_testing/utils' + +describe('actions-tree-shaking - basic', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + if (process.env.TEST_EDGE) { + markLayoutAsEdge(next) + } + + it('should not have the unused action in the manifest', async () => { + const actionsRoutesState = await getActionsRoutesStateByRuntime(next) + + expect(actionsRoutesState).toMatchObject({ + // only one server layer action + 'app/server/page': { + rsc: 1, + }, + // only one browser layer action + 'app/client/page': { + 'action-browser': 1, + }, + 'app/inline/page': { + rsc: 1, + }, + }) + }) +}) diff --git a/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/layout.js b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/layout.js new file mode 100644 index 0000000000000..750eb927b1980 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/layout.js @@ -0,0 +1,7 @@ +export default function Layout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/cjs/actions.js b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/cjs/actions.js new file mode 100644 index 0000000000000..5363b9bc3cf58 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/cjs/actions.js @@ -0,0 +1,13 @@ +'use server' + +export async function esmModuleTypeAction() { + return 'esm-module-type-action' +} + +export async function cjsModuleTypeAction() { + return 'cjs-module-type-action' +} + +export async function unusedModuleTypeAction1() { + return 'unused-module-type-action-1' +} diff --git a/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/cjs/page.js b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/cjs/page.js new file mode 100644 index 0000000000000..db9cde5fc60a8 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/cjs/page.js @@ -0,0 +1,13 @@ +const { cjsModuleTypeAction } = require('./actions') + +export default function Page() { + return ( +
+

One

+
+ + +
+
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/esm/actions.js b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/esm/actions.js new file mode 100644 index 0000000000000..5363b9bc3cf58 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/esm/actions.js @@ -0,0 +1,13 @@ +'use server' + +export async function esmModuleTypeAction() { + return 'esm-module-type-action' +} + +export async function cjsModuleTypeAction() { + return 'cjs-module-type-action' +} + +export async function unusedModuleTypeAction1() { + return 'unused-module-type-action-1' +} diff --git a/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/esm/page.js b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/esm/page.js new file mode 100644 index 0000000000000..c4a6b7efc43fa --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/app/mixed-module/esm/page.js @@ -0,0 +1,13 @@ +import { esmModuleTypeAction } from './actions' + +export default function Page() { + return ( +
+

One

+
+ + +
+
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions-edge.test.ts b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions-edge.test.ts new file mode 100644 index 0000000000000..3aea19702e51c --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions-edge.test.ts @@ -0,0 +1,3 @@ +process.env.TEST_EDGE = '1' + +require('./mixed-module-actions.test') diff --git a/test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions.test.ts b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions.test.ts new file mode 100644 index 0000000000000..c1db8543579fe --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions.test.ts @@ -0,0 +1,29 @@ +import { nextTestSetup } from 'e2e-utils' +import { + getActionsRoutesStateByRuntime, + markLayoutAsEdge, +} from '../_testing/utils' + +describe('actions-tree-shaking - mixed-module-actions', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + if (process.env.TEST_EDGE) { + markLayoutAsEdge(next) + } + + it('should not do tree shake for cjs module when import server actions', async () => { + const actionsRoutesState = await getActionsRoutesStateByRuntime(next) + + expect(actionsRoutesState).toMatchObject({ + 'app/mixed-module/esm/page': { + rsc: 1, + }, + // CJS import is not able to tree shake, so it will include all actions + 'app/mixed-module/cjs/page': { + rsc: 3, + }, + }) + }) +}) diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/layout.js b/test/production/app-dir/actions-tree-shaking/reexport/app/layout.js new file mode 100644 index 0000000000000..750eb927b1980 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/layout.js @@ -0,0 +1,7 @@ +export default function Layout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/actions.js b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/actions.js new file mode 100644 index 0000000000000..a8ac4706d0637 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/actions.js @@ -0,0 +1,13 @@ +'use server' + +export async function sharedClientLayerAction() { + return 'shared-client-layer-action' +} + +export async function unusedClientLayerAction1() { + return 'unused-client-layer-action-1' +} + +export async function unusedClientLayerAction2() { + return 'unused-client-layer-action-2' +} diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/page.js b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/page.js new file mode 100644 index 0000000000000..255bec56559d6 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/page.js @@ -0,0 +1,21 @@ +'use client' + +import { useState } from 'react' +import { sharedClientLayerAction } from './reexport-action' + +export default function Page() { + const [text, setText] = useState('initial') + return ( +
+ + {text} +
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/reexport-action.js b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/reexport-action.js new file mode 100644 index 0000000000000..fa2dbb7fe37ac --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/client/reexport-action.js @@ -0,0 +1,5 @@ +export { + sharedClientLayerAction, + unusedClientLayerAction1, + unusedClientLayerAction2, +} from './actions' diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/actions.js b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/actions.js new file mode 100644 index 0000000000000..da6609602621d --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/actions.js @@ -0,0 +1,13 @@ +'use server' + +export async function sharedServerLayerAction() { + return 'shared-server-layer-action' +} + +export async function unusedServerLayerAction1() { + return 'unused-server-layer-action-1' +} + +export async function unusedServerLayerAction2() { + return 'unused-server-layer-action-2' +} diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/page.js b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/page.js new file mode 100644 index 0000000000000..aacdbe6dfd179 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/page.js @@ -0,0 +1,12 @@ +import { sharedServerLayerAction } from './reexport-action' + +export default function Page() { + return ( +
+
+ + +
+
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/reexport-action.js b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/reexport-action.js new file mode 100644 index 0000000000000..e2688239bd066 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/named-reexport/server/reexport-action.js @@ -0,0 +1,5 @@ +export { + sharedServerLayerAction, + unusedServerLayerAction1, + unusedServerLayerAction2, +} from './actions' diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/client/actions.js b/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/client/actions.js new file mode 100644 index 0000000000000..a8ac4706d0637 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/client/actions.js @@ -0,0 +1,13 @@ +'use server' + +export async function sharedClientLayerAction() { + return 'shared-client-layer-action' +} + +export async function unusedClientLayerAction1() { + return 'unused-client-layer-action-1' +} + +export async function unusedClientLayerAction2() { + return 'unused-client-layer-action-2' +} diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/client/page.js b/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/client/page.js new file mode 100644 index 0000000000000..8ebbc07c51115 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/client/page.js @@ -0,0 +1,21 @@ +'use client' + +import { useState } from 'react' +import * as actionMod from './actions' + +export default function Page() { + const [text, setText] = useState('initial') + return ( +
+ + {text} +
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/server/actions.js b/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/server/actions.js new file mode 100644 index 0000000000000..da6609602621d --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/server/actions.js @@ -0,0 +1,13 @@ +'use server' + +export async function sharedServerLayerAction() { + return 'shared-server-layer-action' +} + +export async function unusedServerLayerAction1() { + return 'unused-server-layer-action-1' +} + +export async function unusedServerLayerAction2() { + return 'unused-server-layer-action-2' +} diff --git a/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/server/page.js b/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/server/page.js new file mode 100644 index 0000000000000..0284f35c64f16 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/app/namespace-reexport/server/page.js @@ -0,0 +1,12 @@ +import * as actionMod from './actions' + +export default function Page() { + return ( +
+
+ + +
+
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/reexport/reexport-edge.test.ts b/test/production/app-dir/actions-tree-shaking/reexport/reexport-edge.test.ts new file mode 100644 index 0000000000000..5634ba4646912 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/reexport-edge.test.ts @@ -0,0 +1,3 @@ +process.env.TEST_EDGE = '1' + +require('./reexport.test') diff --git a/test/production/app-dir/actions-tree-shaking/reexport/reexport.test.ts b/test/production/app-dir/actions-tree-shaking/reexport/reexport.test.ts new file mode 100644 index 0000000000000..d452ad5a4e9cd --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/reexport/reexport.test.ts @@ -0,0 +1,35 @@ +import { nextTestSetup } from 'e2e-utils' +import { + getActionsRoutesStateByRuntime, + markLayoutAsEdge, +} from '../_testing/utils' + +describe('actions-tree-shaking - reexport', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + if (process.env.TEST_EDGE) { + markLayoutAsEdge(next) + } + + it('should not have the unused action in the manifest', async () => { + const actionsRoutesState = await getActionsRoutesStateByRuntime(next) + + expect(actionsRoutesState).toMatchObject({ + 'app/namespace-reexport/server/page': { + rsc: 1, + }, + 'app/namespace-reexport/client/page': { + 'action-browser': 1, + }, + // We're not able to tree-shake these re-exports here + 'app/named-reexport/server/page': { + rsc: 3, + }, + 'app/named-reexport/client/page': { + 'action-browser': 3, + }, + }) + }) +}) diff --git a/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/actions.js b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/actions.js new file mode 100644 index 0000000000000..a8ac4706d0637 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/actions.js @@ -0,0 +1,13 @@ +'use server' + +export async function sharedClientLayerAction() { + return 'shared-client-layer-action' +} + +export async function unusedClientLayerAction1() { + return 'unused-client-layer-action-1' +} + +export async function unusedClientLayerAction2() { + return 'unused-client-layer-action-2' +} diff --git a/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/one/page.js b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/one/page.js new file mode 100644 index 0000000000000..2403399025b94 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/one/page.js @@ -0,0 +1,22 @@ +'use client' + +import { useState } from 'react' +import { sharedClientLayerAction } from '../actions' + +export default function Page() { + const [text, setText] = useState('initial') + return ( +
+

One

+ + {text} +
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/two/page.js b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/two/page.js new file mode 100644 index 0000000000000..8fce3d15570d3 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/client/two/page.js @@ -0,0 +1,22 @@ +'use client' + +import { useState } from 'react' +import { sharedClientLayerAction } from '../actions' + +export default function Page() { + const [text, setText] = useState('initial') + return ( +
+

Two

+ + {text} +
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/layout.js b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/layout.js new file mode 100644 index 0000000000000..750eb927b1980 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/layout.js @@ -0,0 +1,7 @@ +export default function Layout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/actions.js b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/actions.js new file mode 100644 index 0000000000000..da6609602621d --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/actions.js @@ -0,0 +1,13 @@ +'use server' + +export async function sharedServerLayerAction() { + return 'shared-server-layer-action' +} + +export async function unusedServerLayerAction1() { + return 'unused-server-layer-action-1' +} + +export async function unusedServerLayerAction2() { + return 'unused-server-layer-action-2' +} diff --git a/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/one/page.js b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/one/page.js new file mode 100644 index 0000000000000..d9390291722dc --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/one/page.js @@ -0,0 +1,13 @@ +import { sharedServerLayerAction } from '../actions' + +export default function Page() { + return ( +
+

One

+
+ + +
+
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/two/page.js b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/two/page.js new file mode 100644 index 0000000000000..d9390291722dc --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/shared-module-actions/app/server/two/page.js @@ -0,0 +1,13 @@ +import { sharedServerLayerAction } from '../actions' + +export default function Page() { + return ( +
+

One

+
+ + +
+
+ ) +} diff --git a/test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions-edge.test.ts b/test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions-edge.test.ts new file mode 100644 index 0000000000000..403c93484f8a2 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions-edge.test.ts @@ -0,0 +1,3 @@ +process.env.TEST_EDGE = '1' + +require('./shared-module-actions.test') diff --git a/test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions.test.ts b/test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions.test.ts new file mode 100644 index 0000000000000..c9c90a99ccb27 --- /dev/null +++ b/test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions.test.ts @@ -0,0 +1,34 @@ +import { nextTestSetup } from 'e2e-utils' +import { + getActionsRoutesStateByRuntime, + markLayoutAsEdge, +} from '../_testing/utils' + +describe('actions-tree-shaking - shared-module-actions', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + if (process.env.TEST_EDGE) { + markLayoutAsEdge(next) + } + + it('should not have the unused action in the manifest', async () => { + const actionsRoutesState = await getActionsRoutesStateByRuntime(next) + + expect(actionsRoutesState).toMatchObject({ + 'app/server/one/page': { + rsc: 1, + }, + 'app/server/two/page': { + rsc: 1, + }, + 'app/client/one/page': { + 'action-browser': 1, + }, + 'app/client/two/page': { + 'action-browser': 1, + }, + }) + }) +}) diff --git a/test/turbopack-build-tests-manifest.json b/test/turbopack-build-tests-manifest.json index 82eb7f19ccc8b..4b1a22aa6e15d 100644 --- a/test/turbopack-build-tests-manifest.json +++ b/test/turbopack-build-tests-manifest.json @@ -15528,6 +15528,78 @@ "pending": [], "flakey": [], "runtimeError": false + }, + "test/production/app-dir/actions-tree-shaking/basic/basic.test.ts": { + "passed": [], + "failed": [ + "actions-tree-shaking - basic should not have the unused action in the manifest" + ], + "pending": [], + "flakey": [], + "runtimeError": false + }, + "test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions.test.ts": { + "passed": [], + "failed": [ + "actions-tree-shaking - mixed-module-actions should not do tree shake for cjs module when import server actions" + ], + "pending": [], + "flakey": [], + "runtimeError": false + }, + "test/production/app-dir/actions-tree-shaking/reexport/reexport.test.ts": { + "passed": [], + "failed": [ + "actions-tree-shaking - reexport should not have the unused action in the manifest" + ], + "pending": [], + "flakey": [], + "runtimeError": false + }, + "test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions.test.ts": { + "passed": [], + "failed": [ + "actions-tree-shaking - shared-module-actions should not have the unused action in the manifest" + ], + "pending": [], + "flakey": [], + "runtimeError": false + }, + "test/production/app-dir/actions-tree-shaking/basic/basic-edge.test.ts": { + "passed": [], + "failed": [ + "actions-tree-shaking - basic should not have the unused action in the manifest" + ], + "pending": [], + "flakey": [], + "runtimeError": false + }, + "test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions-edge.test.ts": { + "passed": [], + "failed": [ + "actions-tree-shaking - mixed-module-actions should not do tree shake for cjs module when import server actions" + ], + "pending": [], + "flakey": [], + "runtimeError": false + }, + "test/production/app-dir/actions-tree-shaking/reexport/reexport-edge.test.ts": { + "passed": [], + "failed": [ + "actions-tree-shaking - reexport should not have the unused action in the manifest" + ], + "pending": [], + "flakey": [], + "runtimeError": false + }, + "test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions-edge.test.ts": { + "passed": [], + "failed": [ + "actions-tree-shaking - shared-module-actions should not have the unused action in the manifest" + ], + "pending": [], + "flakey": [], + "runtimeError": false } }, "rules": {