From 380e720d645dfa87d2ea973a8ee9620deb3b8421 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 May 2024 11:57:09 -0400 Subject: [PATCH] feat: adds experimental serverOnlyDependencies property --- .../serverOnlyDependencies.mdx | 23 +++++++++++++++++ packages/next/src/build/webpack-config.ts | 1 + .../plugins/flight-client-entry-plugin.ts | 7 +++++- packages/next/src/server/config-schema.ts | 1 + packages/next/src/server/config-shared.ts | 4 +++ .../app/server-only-dep/client.js | 5 ++++ .../app/server-only-dep/page.js | 6 +++++ .../app/server-only-dep/server-only.js | 5 ++++ .../index.test.ts | 25 +++++++++++++++++++ .../next.config.js | 9 +++++++ 10 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 docs/02-app/02-api-reference/05-next-config-js/serverOnlyDependencies.mdx create mode 100644 test/production/app-dir/client-components-tree-shaking/app/server-only-dep/client.js create mode 100644 test/production/app-dir/client-components-tree-shaking/app/server-only-dep/page.js create mode 100644 test/production/app-dir/client-components-tree-shaking/app/server-only-dep/server-only.js create mode 100644 test/production/app-dir/client-components-tree-shaking/next.config.js diff --git a/docs/02-app/02-api-reference/05-next-config-js/serverOnlyDependencies.mdx b/docs/02-app/02-api-reference/05-next-config-js/serverOnlyDependencies.mdx new file mode 100644 index 0000000000000..94ec2a962ee16 --- /dev/null +++ b/docs/02-app/02-api-reference/05-next-config-js/serverOnlyDependencies.mdx @@ -0,0 +1,23 @@ +--- +title: serverOnlyDependencies +description: Mark certain imports as server-only to disable the addition of any subsequent, undesired client-side JS to the client-side bundle. +--- + +Next.js automatically tree-shakes client dependencies from server components, but there are cases where bundler tree-shaking is not smart enough to solely rely on. For example, tree-shaking works by determining if a dependency is referenced. If a client dependency is referenced (via adding to an object property, logging it, or similar) it will not be tree-shaken and will appear in the resulting client-side bundle. + +This is an escape hatch that can be used to mark certain dependencies as server-only, in which case, no subsequent client dependencies will be added to the resulting client-side JS. + +```js filename="next.config.js" +const path = require('path') + +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + serverOnlyDependencies: [path.resolve(__dirname, './app/my-server-dep.js')], + }, +} + +module.exports = nextConfig +``` + +In most cases, you should not need to use this property - but it can be helpful to library authors building on Next.js. diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 9816b5ebb41c9..24db1503a9672 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -1854,6 +1854,7 @@ export default async function getBaseWebpackConfig( }) : new FlightClientEntryPlugin({ appDir, + ignore: config.experimental.serverOnlyDependencies ?? [], dev, isEdgeServer, encryptionKey, 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 7d34bb82966bf..2335374c33abd 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 @@ -46,6 +46,7 @@ import { getModuleBuildInfo } from '../loaders/get-module-build-info' interface Options { dev: boolean appDir: string + ignore: string[] isEdgeServer: boolean encryptionKey: string } @@ -170,10 +171,12 @@ export class FlightClientEntryPlugin { encryptionKey: string isEdgeServer: boolean assetPrefix: string + ignore: string[] constructor(options: Options) { this.dev = options.dev this.appDir = options.appDir + this.ignore = options.ignore this.isEdgeServer = options.isEdgeServer this.assetPrefix = !this.dev && !this.isEdgeServer ? '../' : '' this.encryptionKey = options.encryptionKey @@ -662,7 +665,9 @@ export class FlightClientEntryPlugin { modRequest = mod.matchResource + ':' + modRequest } - if (!modRequest) return + // If there is no modRequest, or it's explicitly ignored by `serverOnlyDependencies`, + // we can skip everything from here on for this import + if (!modRequest || this.ignore.includes(modRequest)) return if (visited.has(modRequest)) { if (clientComponentImports[modRequest]) { const isCjsModule = diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 6dddc6ae6d3ef..4c14bd04d995f 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -315,6 +315,7 @@ export const configSchema: zod.ZodType = z.lazy(() => taint: z.boolean().optional(), prerenderEarlyExit: z.boolean().optional(), proxyTimeout: z.number().gte(0).optional(), + serverOnlyDependencies: z.array(z.string()).optional(), serverComponentsExternalPackages: z.array(z.string()).optional(), scrollRestoration: z.boolean().optional(), sri: z diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 7d2a458d9acae..35f47fbb83bbc 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -281,6 +281,10 @@ export interface ExperimentalConfig { * @see https://nextjs.org/docs/app/api-reference/next-config-js/serverComponentsExternalPackages */ serverComponentsExternalPackages?: string[] + /** + * A list of imports that should completely disable any client dependencies from being added to generated client JS. + */ + serverOnlyDependencies?: string[] webVitalsAttribution?: Array<(typeof WEB_VITALS)[number]> diff --git a/test/production/app-dir/client-components-tree-shaking/app/server-only-dep/client.js b/test/production/app-dir/client-components-tree-shaking/app/server-only-dep/client.js new file mode 100644 index 0000000000000..e3c6e1956327c --- /dev/null +++ b/test/production/app-dir/client-components-tree-shaking/app/server-only-dep/client.js @@ -0,0 +1,5 @@ +'use client' + +export const UnusedClientComponent = () => { + return

better not include me!

+} diff --git a/test/production/app-dir/client-components-tree-shaking/app/server-only-dep/page.js b/test/production/app-dir/client-components-tree-shaking/app/server-only-dep/page.js new file mode 100644 index 0000000000000..53034ee8bd67d --- /dev/null +++ b/test/production/app-dir/client-components-tree-shaking/app/server-only-dep/page.js @@ -0,0 +1,6 @@ +import { serverOnlyObject } from './server-only' + +export default function Page() { + console.log(serverOnlyObject) + return

server only

+} diff --git a/test/production/app-dir/client-components-tree-shaking/app/server-only-dep/server-only.js b/test/production/app-dir/client-components-tree-shaking/app/server-only-dep/server-only.js new file mode 100644 index 0000000000000..23dc910eabe4c --- /dev/null +++ b/test/production/app-dir/client-components-tree-shaking/app/server-only-dep/server-only.js @@ -0,0 +1,5 @@ +import { UnusedClientComponent } from './client' + +export const serverOnlyObject = { + client: UnusedClientComponent, +} diff --git a/test/production/app-dir/client-components-tree-shaking/index.test.ts b/test/production/app-dir/client-components-tree-shaking/index.test.ts index 1043a52e2e91d..fc198bec306ca 100644 --- a/test/production/app-dir/client-components-tree-shaking/index.test.ts +++ b/test/production/app-dir/client-components-tree-shaking/index.test.ts @@ -41,6 +41,31 @@ createNextDescribe( ).toBe(false) }) + it('should not add any client dependencies resulting from modules defined in experimental.serverOnlyDependencies', async () => { + const clientChunksDir = join( + next.testDir, + '.next', + 'static', + 'chunks', + 'app', + 'server-only-dep' + ) + const staticChunksDirents = fs.readdirSync(clientChunksDir, { + withFileTypes: true, + }) + const chunkContents = staticChunksDirents + .filter((dirent) => dirent.isFile()) + .map((chunkDirent) => + fs.readFileSync(join(chunkDirent.path, chunkDirent.name), 'utf8') + ) + + expect( + chunkContents.every((content) => { + return !content.includes('better not include me!') + }) + ).toBe(true) + }) + it('should only include imported components 3rd party package in browser bundle with direct imports', async () => { const clientChunksDir = join( next.testDir, diff --git a/test/production/app-dir/client-components-tree-shaking/next.config.js b/test/production/app-dir/client-components-tree-shaking/next.config.js new file mode 100644 index 0000000000000..f6107fbfa47de --- /dev/null +++ b/test/production/app-dir/client-components-tree-shaking/next.config.js @@ -0,0 +1,9 @@ +const path = require('path') + +module.exports = { + experimental: { + serverOnlyDependencies: [ + path.resolve(__dirname, './app/server-only-dep/server-only.js'), + ], + }, +}