Skip to content

Commit

Permalink
feat(next/image): add support for images.qualities in next.config (#…
Browse files Browse the repository at this point in the history
…74588)

Backport #74257 to 15.1.x
  • Loading branch information
styfle authored Jan 7, 2025
1 parent d253ac5 commit bc6acf6
Show file tree
Hide file tree
Showing 24 changed files with 395 additions and 9 deletions.
19 changes: 19 additions & 0 deletions docs/01-app/03-api-reference/02-components/image.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ quality={75} // {number 1-100}

The quality of the optimized image, an integer between `1` and `100`, where `100` is the best quality and therefore largest file size. Defaults to `75`.

If the [`qualities`](#qualities) configuration is defined in `next.config.js`, the `quality` prop must match one of the values defined in the configuration.

> **Good to know**: If the original source image was already low quality, setting the quality prop too high could cause the resulting optimized image to be larger than the original source image.
### `priority`

```js
Expand Down Expand Up @@ -681,6 +685,20 @@ module.exports = {
}
```

### `qualities`

The default [Image Optimization API](#loader) will automatically allow all qualities from 1 to 100. If you wish to restrict the allowed qualities, you can add configuration to `next.config.js`.

```js filename="next.config.js"
module.exports = {
images: {
qualities: [25, 50, 75],
},
}
```

In this example above, only three qualities are allowed: 25, 50, and 75. If the [`quality`](#quality) prop does not match a value in this array, the image will fail with 400 Bad Request.

### `formats`

The default [Image Optimization API](#loader) will automatically detect the browser's supported image formats via the request's `Accept` header in order to determine the best output format.
Expand Down Expand Up @@ -1076,6 +1094,7 @@ This `next/image` component uses browser native [lazy loading](https://caniuse.c
| Version | Changes |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `v15.0.0` | `contentDispositionType` configuration default changed to `attachment`. |
| `v14.2.23` | `qualities` configuration added. |
| `v14.2.15` | `decoding` prop added and `localPatterns` configuration added. |
| `v14.2.14` | `remotePatterns.search` prop added. |
| `v14.2.0` | `overrideSrc` prop added. |
Expand Down
2 changes: 2 additions & 0 deletions errors/invalid-images-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ module.exports = {
localPatterns: [],
// limit of 50 objects
remotePatterns: [],
// limit of 20 integers
qualities: [25, 50, 75],
// when true, every image will be unoptimized
unoptimized: false,
},
Expand Down
24 changes: 24 additions & 0 deletions errors/next-image-unconfigured-qualities.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
title: '`next/image` Un-configured qualities'
---

## Why This Error Occurred

One of your pages that leverages the `next/image` component, passed a `quality` value that isn't defined in the `images.qualities` property in `next.config.js`.

## Possible Ways to Fix It

Add an entry to `images.qualities` array in `next.config.js` with the expected value. For example:

```js filename="next.config.js"
module.exports = {
images: {
qualities: [25, 50, 75],
},
}
```

## Useful Links

- [Image Optimization Documentation](/docs/pages/building-your-application/optimizing/images)
- [Qualities Config Documentation](/docs/pages/api-reference/components/image#qualities)
3 changes: 2 additions & 1 deletion packages/next/src/build/webpack/plugins/define-env-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,14 @@ function getImageConfig(
'process.env.__NEXT_IMAGE_OPTS': {
deviceSizes: config.images.deviceSizes,
imageSizes: config.images.imageSizes,
qualities: config.images.qualities,
path: config.images.path,
loader: config.images.loader,
dangerouslyAllowSVG: config.images.dangerouslyAllowSVG,
unoptimized: config?.images?.unoptimized,
...(dev
? {
// pass domains in development to allow validating on the client
// additional config in dev to allow validating on the client
domains: config.images.domains,
remotePatterns: config.images?.remotePatterns,
localPatterns: config.images?.localPatterns,
Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/client/image-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,8 @@ export const Image = forwardRef<HTMLImageElement | null, ImageProps>(
const c = configEnv || configContext || imageConfigDefault
const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
return { ...c, allSizes, deviceSizes }
const qualities = c.qualities?.sort((a, b) => a - b)
return { ...c, allSizes, deviceSizes, qualities }
}, [configContext])

const { onLoad, onLoadingComplete } = props
Expand Down
21 changes: 18 additions & 3 deletions packages/next/src/client/legacy/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function normalizeSrc(src: string): string {
}

const supportsFloat = typeof ReactDOM.preload === 'function'

const DEFAULT_Q = 75
const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete
const loadedImageURLs = new Set<string>()
const allImgs = new Map<
Expand Down Expand Up @@ -190,8 +190,22 @@ function defaultLoader({
}
}
}

if (quality && config.qualities && !config.qualities.includes(quality)) {
throw new Error(
`Invalid quality prop (${quality}) on \`next/image\` does not match \`images.qualities\` configured in your \`next.config.js\`\n` +
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-qualities`
)
}
}

const q =
quality ||
config.qualities?.reduce((prev, cur) =>
Math.abs(cur - DEFAULT_Q) < Math.abs(prev - DEFAULT_Q) ? cur : prev
) ||
DEFAULT_Q

if (!config.dangerouslyAllowSVG && src.split('?', 1)[0].endsWith('.svg')) {
// Special case to make svg serve as-is to avoid proxying
// through the built-in Image Optimization API.
Expand All @@ -200,7 +214,7 @@ function defaultLoader({

return `${normalizePathTrailingSlash(config.path)}?url=${encodeURIComponent(
src
)}&w=${width}&q=${quality || 75}`
)}&w=${width}&q=${q}`
}

const loaders = new Map<
Expand Down Expand Up @@ -641,7 +655,8 @@ export default function Image({
const c = configEnv || configContext || imageConfigDefault
const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
return { ...c, allSizes, deviceSizes }
const qualities = c.qualities?.sort((a, b) => a - b)
return { ...c, allSizes, deviceSizes, qualities }
}, [configContext])

let rest: Partial<ImageProps> = all
Expand Down
5 changes: 5 additions & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,11 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
loaderFile: z.string().optional(),
minimumCacheTTL: z.number().int().gte(0).optional(),
path: z.string().optional(),
qualities: z
.array(z.number().int().gte(1).lte(100))
.min(1)
.max(20)
.optional(),
})
.optional(),
logging: z
Expand Down
13 changes: 13 additions & 0 deletions packages/next/src/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export class ImageOptimizerCache {
} = imageData
const remotePatterns = nextConfig.images?.remotePatterns || []
const localPatterns = nextConfig.images?.localPatterns
const qualities = nextConfig.images?.qualities
const { url, w, q } = query
let href: string

Expand Down Expand Up @@ -334,6 +335,18 @@ export class ImageOptimizerCache {
}
}

if (qualities) {
if (isDev) {
qualities.push(BLUR_QUALITY)
}

if (!qualities.includes(quality)) {
return {
errorMessage: `"q" parameter (quality) of ${q} is not allowed`,
}
}
}

const mimeType = getSupportedMimeType(formats || [], req.headers['accept'])

const isStatic = url.startsWith(
Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/shared/lib/get-img-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ export function getImgProps(
} else {
const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
config = { ...c, allSizes, deviceSizes }
const qualities = c.qualities?.sort((a, b) => a - b)
config = { ...c, allSizes, deviceSizes, qualities }
}

if (typeof defaultLoader === 'undefined') {
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/shared/lib/image-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ export type ImageConfigComplete = {
/** @see [Remote Patterns](https://nextjs.org/docs/api-reference/next/image#localPatterns) */
localPatterns: LocalPattern[] | undefined

/** @see [Qualities](https://nextjs.org/docs/api-reference/next/image#qualities) */
qualities: number[] | undefined

/** @see [Unoptimized](https://nextjs.org/docs/api-reference/next/image#unoptimized) */
unoptimized: boolean
}
Expand All @@ -139,5 +142,6 @@ export const imageConfigDefault: ImageConfigComplete = {
contentDispositionType: 'attachment',
localPatterns: undefined, // default: allow all local images
remotePatterns: [], // default: allow no remote images
qualities: undefined, // default: allow all qualities
unoptimized: false,
}
20 changes: 17 additions & 3 deletions packages/next/src/shared/lib/image-loader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ImageLoaderPropsWithConfig } from './image-config'

const DEFAULT_Q = 75

function defaultLoader({
config,
src,
Expand Down Expand Up @@ -72,11 +74,23 @@ function defaultLoader({
}
}
}

if (quality && config.qualities && !config.qualities.includes(quality)) {
throw new Error(
`Invalid quality prop (${quality}) on \`next/image\` does not match \`images.qualities\` configured in your \`next.config.js\`\n` +
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-qualities`
)
}
}

return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${
quality || 75
}${
const q =
quality ||
config.qualities?.reduce((prev, cur) =>
Math.abs(cur - DEFAULT_Q) < Math.abs(prev - DEFAULT_Q) ? cur : prev
) ||
DEFAULT_Q

return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${q}${
src.startsWith('/_next/static/media/') && process.env.NEXT_DEPLOYMENT_ID
? `&dpl=${process.env.NEXT_DEPLOYMENT_ID}`
: ''
Expand Down
3 changes: 3 additions & 0 deletions packages/next/src/telemetry/events/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type EventCliSessionStarted = {
imageDomainsCount: number | null
imageRemotePatternsCount: number | null
imageLocalPatternsCount: number | null
imageQualities: string | null
imageSizes: string | null
imageLoader: string | null
imageFormats: string | null
Expand Down Expand Up @@ -80,6 +81,7 @@ export function eventCliSession(
| 'imageDomainsCount'
| 'imageRemotePatternsCount'
| 'imageLocalPatternsCount'
| 'imageQualities'
| 'imageSizes'
| 'imageLoader'
| 'imageFormats'
Expand Down Expand Up @@ -126,6 +128,7 @@ export function eventCliSession(
? images.localPatterns.length
: null,
imageSizes: images?.imageSizes ? images.imageSizes.join(',') : null,
imageQualities: images?.qualities ? images.qualities.join(',') : null,
imageLoader: images?.loader,
imageFormats: images?.formats ? images.formats.join(',') : null,
nextConfigOutput: nextConfig?.output || null,
Expand Down
78 changes: 78 additions & 0 deletions test/integration/image-optimizer/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,84 @@ describe('Image Optimizer', () => {
)
})

it('should error when qualities length exceeds 20', async () => {
await nextConfig.replace(
'{ /* replaceme */ }',
JSON.stringify({
images: {
qualities: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
20, 21,
],
},
})
)
let stderr = ''

app = await launchApp(appDir, await findPort(), {
onStderr(msg) {
stderr += msg || ''
},
})
await waitFor(1000)
await killApp(app).catch(() => {})
await nextConfig.restore()

expect(stderr).toContain(
`Array must contain at most 20 element(s) at "images.qualities"`
)
})

it('should error when qualities array has a value thats not an integer', async () => {
await nextConfig.replace(
'{ /* replaceme */ }',
JSON.stringify({
images: {
qualities: [1, 2, 3, 9.9],
},
})
)
let stderr = ''

app = await launchApp(appDir, await findPort(), {
onStderr(msg) {
stderr += msg || ''
},
})
await waitFor(1000)
await killApp(app).catch(() => {})
await nextConfig.restore()

expect(stderr).toContain(
`Expected integer, received float at "images.qualities[3]"`
)
})

it('should error when qualities array is empty', async () => {
await nextConfig.replace(
'{ /* replaceme */ }',
JSON.stringify({
images: {
qualities: [],
},
})
)
let stderr = ''

app = await launchApp(appDir, await findPort(), {
onStderr(msg) {
stderr += msg || ''
},
})
await waitFor(1000)
await killApp(app).catch(() => {})
await nextConfig.restore()

expect(stderr).toContain(
`Array must contain at least 1 element(s) at "images.qualities"`
)
})

it('should error when loader contains invalid value', async () => {
await nextConfig.replace(
'{ /* replaceme */ }',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ function runTests(mode: 'dev' | 'server') {
],
minimumCacheTTL: 60,
path: '/_next/image',
qualities: undefined,
sizes: [
640, 750, 828, 1080, 1200, 1920, 2048, 3840, 16, 32, 48, 64, 96,
128, 256, 384,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Image from 'next/image'

import src from '../images/test.png'

const Page = () => {
return (
<main>
<Image alt="q-100" id="q-100" quality={100} src={src} />
</main>
)
}

export default Page
12 changes: 12 additions & 0 deletions test/integration/next-image-new/app-dir-qualities/app/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Loading

0 comments on commit bc6acf6

Please sign in to comment.