Skip to content

Commit

Permalink
Fix tree-shaking for metadata image functions on the Edge runtime (#5…
Browse files Browse the repository at this point in the history
…1762)

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.
  • Loading branch information
shuding committed Jun 26, 2023
1 parent 70aad4a commit 0dd0ef2
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 2 deletions.
6 changes: 6 additions & 0 deletions packages/next/src/build/webpack-config.ts
Expand Up @@ -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: [
Expand Down
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -52,12 +54,55 @@ async function nextMetadataImageLoader(this: any, content: Buffer) {
const pathnamePrefix = path.join(basePath, segment)

if (isDynamicResource) {
const mod = await new Promise<webpack.NormalModule>((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(
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/lib/constants.ts
Expand Up @@ -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__',
}
22 changes: 22 additions & 0 deletions test/e2e/app-dir/metadata-edge/app/layout.tsx
@@ -0,0 +1,22 @@
export default function Layout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}

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: './',
},
}
23 changes: 23 additions & 0 deletions 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(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 128,
background: 'lavender',
}}
>
Open Graph
</div>
)
)
}
11 changes: 11 additions & 0 deletions 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'
30 changes: 30 additions & 0 deletions 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])
})
}
)
9 changes: 9 additions & 0 deletions 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)
}

0 comments on commit 0dd0ef2

Please sign in to comment.