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) +}