From 0dd0ef226cd88a66dca94aa5ce4e55abd1750f1a Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 26 Jun 2023 13:44:29 +0200 Subject: [PATCH] Fix tree-shaking for metadata image functions on the Edge runtime (#51762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes tree-shaking for the metadata image generation module (e.g. `opengraph-image.js` and other conventions) when the page has `runtime = 'edge'`. ## Details The first step of this fix is to change this from the loader: ```js import * as exported from "./opengraph-image.js" ``` to be necessary fields only (so the `default` export can potentially be removed): ```js import { alt, size } from "./opengraph-image.js" ``` To know which fields are exported, we need to load the module first via Webpack loader's `loadModule` API and check its `HarmonyExportSpecifierDependency` dependencies. This is the first step to make it tree-shakable. Since we have `./opengraph-image.js` used in another entry, the actual image API route `opengraph-image/route.js`: ```js import * as image from "./opengraph-image.js" ``` Webpack still treats both as the same module and generates one chunk for it. We want to "fork" it into two modules. The technique here is to add a noop resource query and make it: ```js import { alt, size } from "./opengraph-image.js?__next_metadata_image_meta__" ``` So it won't be shared in the chunk (as it's a different request), and can be concatenated inline. However that's not enough, the inlined result will still have all imports from our `opengraph-image.js`, including `import { ImageResponse } from 'next/server'`. Because we can't simply add `"sideEffects": false` in Next.js' package.json, we need a way to mark this import as side-effect free. I went through https://github.com/webpack/webpack/blob/main/lib/optimize/SideEffectsFlagPlugin.js and used the same method to mark that module with `module.factoryMeta.sideEffectFree = true`. With all these added, the page bundle will no longer contain the `ImageResponse` instance. ## Result The difference is quite amazing, for the new added test (an empty Edge runtime page with an opengrah image file) here're the before/after metrics for the `page.js` server bundle: Edge bundle size: 892kB → 500kB. Build time: 26.792s → 8.830s. --- packages/next/src/build/webpack-config.ts | 6 +++ .../loaders/next-metadata-image-loader.ts | 49 ++++++++++++++++++- packages/next/src/lib/constants.ts | 1 + test/e2e/app-dir/metadata-edge/app/layout.tsx | 22 +++++++++ .../metadata-edge/app/opengraph-image.tsx | 23 +++++++++ test/e2e/app-dir/metadata-edge/app/page.tsx | 11 +++++ test/e2e/app-dir/metadata-edge/index.test.ts | 30 ++++++++++++ test/e2e/app-dir/metadata-edge/next.config.js | 9 ++++ 8 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 test/e2e/app-dir/metadata-edge/app/layout.tsx create mode 100644 test/e2e/app-dir/metadata-edge/app/opengraph-image.tsx create mode 100644 test/e2e/app-dir/metadata-edge/app/page.tsx create mode 100644 test/e2e/app-dir/metadata-edge/index.test.ts create mode 100644 test/e2e/app-dir/metadata-edge/next.config.js diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 61731448fa523..cf0306d4d6dc8 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -2272,6 +2272,12 @@ export default async function getBaseWebpackConfig( "'server-only' cannot be imported from a Client Component module. It should only be used from a Server Component.", }, }, + { + // Mark `image-response.js` as side-effects free to make sure we can + // tree-shake it if not used. + test: /[\\/]next[\\/]dist[\\/](esm[\\/])?server[\\/]web[\\/]exports[\\/]image-response\.js/, + sideEffects: false, + }, ].filter(Boolean), }, plugins: [ diff --git a/packages/next/src/build/webpack/loaders/next-metadata-image-loader.ts b/packages/next/src/build/webpack/loaders/next-metadata-image-loader.ts index abe5e3e0f0673..8f72e823f7446 100644 --- a/packages/next/src/build/webpack/loaders/next-metadata-image-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-metadata-image-loader.ts @@ -2,6 +2,7 @@ * This loader is responsible for extracting the metadata image info for rendering in html */ +import type webpack from 'webpack' import type { MetadataImageModule, PossibleImageFileNameConvention, @@ -12,6 +13,7 @@ import loaderUtils from 'next/dist/compiled/loader-utils3' import { getImageSize } from '../../../server/image-optimizer' import { imageExtMimeTypeMap } from '../../../lib/mime-type' import { fileExists } from '../../../lib/file-exists' +import { WEBPACK_RESOURCE_QUERIES } from '../../../lib/constants' interface Options { segment: string @@ -52,12 +54,55 @@ async function nextMetadataImageLoader(this: any, content: Buffer) { const pathnamePrefix = path.join(basePath, segment) if (isDynamicResource) { + const mod = await new Promise((res, rej) => { + this.loadModule( + resourcePath, + (err: null | Error, _source: any, _sourceMap: any, module: any) => { + if (err) { + return rej(err) + } + res(module) + } + ) + }) + + const exportedFieldsExcludingDefault = + mod.dependencies + ?.filter((dep) => { + return ( + [ + 'HarmonyExportImportedSpecifierDependency', + 'HarmonyExportSpecifierDependency', + ].includes(dep.constructor.name) && + 'name' in dep && + dep.name !== 'default' + ) + }) + .map((dep: any) => { + return dep.name + }) || [] // re-export and spread as `exportedImageData` to avoid non-exported error return `\ - import * as exported from ${JSON.stringify(resourcePath)} + import { + ${exportedFieldsExcludingDefault + .map((field) => `${field} as _${field}`) + .join(',')} + } from ${JSON.stringify( + // This is an arbitrary resource query to ensure it's a new request, instead + // of sharing the same module with next-metadata-route-loader. + // Since here we only need export fields such as `size`, `alt` and + // `generateImageMetadata`, avoid sharing the same module can make this entry + // smaller. + resourcePath + '?' + WEBPACK_RESOURCE_QUERIES.metadataImageMeta + )} import { fillMetadataSegment } from 'next/dist/lib/metadata/get-metadata-route' - const imageModule = { ...exported } + const imageModule = { + ${exportedFieldsExcludingDefault + .map((field) => `${field}: _${field}`) + .join(',')} + } + export default async function (props) { const { __metadata_id__: _, ...params } = props.params const imageUrl = fillMetadataSegment(${JSON.stringify( diff --git a/packages/next/src/lib/constants.ts b/packages/next/src/lib/constants.ts index c4f4cf2563414..df3278eeb7ea2 100644 --- a/packages/next/src/lib/constants.ts +++ b/packages/next/src/lib/constants.ts @@ -100,4 +100,5 @@ export const WEBPACK_LAYERS = { export const WEBPACK_RESOURCE_QUERIES = { edgeSSREntry: '__next_edge_ssr_entry__', metadata: '__next_metadata__', + metadataImageMeta: '__next_metadata_image_meta__', } diff --git a/test/e2e/app-dir/metadata-edge/app/layout.tsx b/test/e2e/app-dir/metadata-edge/app/layout.tsx new file mode 100644 index 0000000000000..9b1a363d19bf0 --- /dev/null +++ b/test/e2e/app-dir/metadata-edge/app/layout.tsx @@ -0,0 +1,22 @@ +export default function Layout({ children }) { + return ( + + + {children} + + ) +} + +export const metadata = { + metadataBase: new URL('https://mydomain.com'), + title: 'Next.js App', + description: 'This is a Next.js App', + twitter: { + cardType: 'summary_large_image', + title: 'Twitter - Next.js App', + description: 'Twitter - This is a Next.js App', + }, + alternates: { + canonical: './', + }, +} diff --git a/test/e2e/app-dir/metadata-edge/app/opengraph-image.tsx b/test/e2e/app-dir/metadata-edge/app/opengraph-image.tsx new file mode 100644 index 0000000000000..06458b4ab5074 --- /dev/null +++ b/test/e2e/app-dir/metadata-edge/app/opengraph-image.tsx @@ -0,0 +1,23 @@ +import { ImageResponse } from 'next/server' + +export const alt = 'Open Graph' + +export default function og() { + return new ImageResponse( + ( +
+ Open Graph +
+ ) + ) +} diff --git a/test/e2e/app-dir/metadata-edge/app/page.tsx b/test/e2e/app-dir/metadata-edge/app/page.tsx new file mode 100644 index 0000000000000..8d88e2420b68f --- /dev/null +++ b/test/e2e/app-dir/metadata-edge/app/page.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +export default function Page() { + return <>hello index +} + +export const metadata = { + title: 'index page', +} + +export const runtime = 'edge' diff --git a/test/e2e/app-dir/metadata-edge/index.test.ts b/test/e2e/app-dir/metadata-edge/index.test.ts new file mode 100644 index 0000000000000..966f898989c9e --- /dev/null +++ b/test/e2e/app-dir/metadata-edge/index.test.ts @@ -0,0 +1,30 @@ +import { createNextDescribe } from 'e2e-utils' +import imageSize from 'image-size' + +createNextDescribe( + 'app dir - Metadata API on the Edge runtime', + { + files: __dirname, + }, + ({ next, isNextStart }) => { + describe('OG image route', () => { + if (isNextStart) { + it('should not bundle `ImageResponse` into the page worker', async () => { + const pageBundle = await next.readFile( + '.next/server/middleware-manifest.json' + ) + expect(pageBundle).not.toContain('ImageResponse') + }) + } + }) + + it('should render OpenGraph image meta tag correctly', async () => { + const html$ = await next.render$('/') + const ogUrl = new URL(html$('meta[property="og:image"]').attr('content')) + const imageBuffer = await (await next.fetch(ogUrl.pathname)).buffer() + + const size = imageSize(imageBuffer) + expect([size.width, size.height]).toEqual([1200, 630]) + }) + } +) diff --git a/test/e2e/app-dir/metadata-edge/next.config.js b/test/e2e/app-dir/metadata-edge/next.config.js new file mode 100644 index 0000000000000..dc0c3a93b03ec --- /dev/null +++ b/test/e2e/app-dir/metadata-edge/next.config.js @@ -0,0 +1,9 @@ +module.exports = {} + +// For development: analyze the bundled chunks for stats app +if (process.env.ANALYZE) { + const withBundleAnalyzer = require('@next/bundle-analyzer')({ + enabled: true, + }) + module.exports = withBundleAnalyzer(module.exports) +}