feat(next/image): add support for images.qualities in next.config (#74257)

This PR adds support for `images.qualities` configuration which is an
allowlist of qualities that can be used with the quality prop on the
Image component.

- Depends on https://github.com/vercel/vercel/pull/12792
This commit is contained in:
Steven
2025-01-03 20:43:29 +00:00
committed by GitHub
parent 0e4cff4687
commit d70595e5b6
25 changed files with 397 additions and 10 deletions
@@ -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
@@ -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.
@@ -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. |
+2
View File
@@ -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,
},
@@ -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)
+2 -1
View File
@@ -620,5 +620,6 @@
"619": "Page not found",
"620": "A required parameter (%s) was not provided as %s received %s in getStaticPaths for %s",
"621": "Required root params (%s) were not provided in generateStaticParams for %s, please provide at least one value for each.",
"622": "A required root parameter (%s) was not provided in generateStaticParams for %s, please provide at least one value."
"622": "A required root parameter (%s) was not provided in generateStaticParams for %s, please provide at least one value.",
"623": "Invalid quality prop (%s) on \\`next/image\\` does not match \\`images.qualities\\` configured in your \\`next.config.js\\`\\nSee more info: https://nextjs.org/docs/messages/next-image-unconfigured-qualities"
}
@@ -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,
+2 -1
View File
@@ -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
+18 -3
View File
@@ -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<
@@ -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.
@@ -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<
@@ -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
@@ -540,6 +540,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
@@ -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
@@ -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(
@@ -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') {
@@ -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
}
@@ -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,
}
+17 -3
View File
@@ -1,5 +1,7 @@
import type { ImageLoaderPropsWithConfig } from './image-config'
const DEFAULT_Q = 75
function defaultLoader({
config,
src,
@@ -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}`
: ''
@@ -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
@@ -80,6 +81,7 @@ export function eventCliSession(
| 'imageDomainsCount'
| 'imageRemotePatternsCount'
| 'imageLocalPatternsCount'
| 'imageQualities'
| 'imageSizes'
| 'imageLoader'
| 'imageFormats'
@@ -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,
@@ -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 */ }',
@@ -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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -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
@@ -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>
)
}
@@ -0,0 +1,16 @@
import Image from 'next/image'
import src from './images/test.png'
const Page = () => {
return (
<main>
<Image alt="q-undefined" id="q-undefined" src={src} />
<Image alt="q-42" id="q-42" quality={42} src={src} />
<Image alt="q-69" id="q-69" quality={69} src={src} />
<Image alt="q-88" id="q-88" quality={88} src={src} />
</main>
)
}
export default Page
@@ -0,0 +1,5 @@
module.exports = {
images: {
qualities: [42, 69, 88],
},
}
@@ -0,0 +1,154 @@
/* eslint-env jest */
import {
assertHasRedbox,
assertNoRedbox,
fetchViaHTTP,
findPort,
getImagesManifest,
getRedboxHeader,
killApp,
launchApp,
nextBuild,
nextStart,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'
const appDir = join(__dirname, '../')
let appPort: number
let app: Awaited<ReturnType<typeof launchApp>>
async function getSrc(
browser: Awaited<ReturnType<typeof webdriver>>,
id: string
) {
const src = await browser.elementById(id).getAttribute('src')
if (src) {
const url = new URL(src, `http://localhost:${appPort}`)
return url.href.slice(url.origin.length)
}
}
function runTests(mode: 'dev' | 'server') {
it('should load img when quality is undefined', async () => {
const browser = await webdriver(appPort, '/')
if (mode === 'dev') {
await assertNoRedbox(browser)
}
const url = await getSrc(browser, 'q-undefined')
const res = await fetchViaHTTP(appPort, url)
expect(res.status).toStrictEqual(200)
expect(url).toContain('&q=69') // default to closest to 75
})
it('should load img when quality 42', async () => {
const browser = await webdriver(appPort, '/')
if (mode === 'dev') {
await assertNoRedbox(browser)
}
const url = await getSrc(browser, 'q-42')
const res = await fetchViaHTTP(appPort, url)
expect(res.status).toStrictEqual(200)
})
it('should load img when quality 69', async () => {
const browser = await webdriver(appPort, '/')
if (mode === 'dev') {
await assertNoRedbox(browser)
}
const url = await getSrc(browser, 'q-69')
const res = await fetchViaHTTP(appPort, url)
expect(res.status).toStrictEqual(200)
})
it('should load img when quality 88', async () => {
const browser = await webdriver(appPort, '/')
if (mode === 'dev') {
await assertNoRedbox(browser)
}
const url = await getSrc(browser, 'q-88')
const res = await fetchViaHTTP(appPort, url)
expect(res.status).toStrictEqual(200)
})
it('should fail to load img when quality is 100', async () => {
const page = '/invalid-quality'
const browser = await webdriver(appPort, page)
if (mode === 'dev') {
await assertHasRedbox(browser)
expect(await getRedboxHeader(browser)).toMatch(
/Invalid quality prop (.+) on `next\/image` does not match `images.qualities` configured/g
)
} else {
const url = await getSrc(browser, 'q-100')
const res = await fetchViaHTTP(appPort, url)
expect(res.status).toBe(400)
}
})
if (mode === 'server') {
it('should build correct images-manifest.json', async () => {
const manifest = getImagesManifest(appDir)
expect(manifest).toEqual({
version: 1,
images: {
contentDispositionType: 'attachment',
contentSecurityPolicy:
"script-src 'none'; frame-src 'none'; sandbox;",
dangerouslyAllowSVG: false,
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
disableStaticImages: false,
domains: [],
formats: ['image/webp'],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
loader: 'default',
loaderFile: '',
remotePatterns: [],
localPatterns: undefined,
minimumCacheTTL: 60,
path: '/_next/image',
qualities: [42, 69, 88],
sizes: [
640, 750, 828, 1080, 1200, 1920, 2048, 3840, 16, 32, 48, 64, 96,
128, 256, 384,
],
unoptimized: false,
},
})
})
}
}
describe('Image localPatterns config', () => {
;(process.env.TURBOPACK_BUILD ? describe.skip : describe)(
'development mode',
() => {
beforeAll(async () => {
appPort = await findPort()
app = await launchApp(appDir, appPort)
})
afterAll(async () => {
await killApp(app)
})
runTests('dev')
}
)
;(process.env.TURBOPACK_DEV ? describe.skip : describe)(
'production mode',
() => {
beforeAll(async () => {
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
})
afterAll(async () => {
await killApp(app)
})
runTests('server')
}
)
})
@@ -1623,6 +1623,7 @@ function runTests(mode: 'dev' | 'server') {
localPatterns: undefined,
minimumCacheTTL: 60,
path: '/_next/image',
qualities: undefined,
sizes: [
640, 750, 828, 1080, 1200, 1920, 2048, 3840, 16, 32, 48, 64, 96,
128, 256, 384,
@@ -112,6 +112,7 @@ function runTests(url: string, mode: 'dev' | 'server') {
localPatterns: undefined,
minimumCacheTTL: 60,
path: '/_next/image',
qualities: undefined,
sizes: [
640, 750, 828, 1080, 1200, 1920, 2048, 3840, 16, 32, 48, 64, 96,
128, 256, 384,
@@ -6,6 +6,7 @@ module.exports = phase => {
domains: ['example.com', 'another.com'],
remotePatterns: [{ protocol: 'https', hostname: '**.example.com' }],
localPatterns: [{ pathname: '/assets/**', search: '' }],
qualities: [25, 50, 75],
},
i18n: {
locales: ['en','nl','fr'],
@@ -77,6 +77,7 @@ describe('config telemetry', () => {
expect(event1).toMatch(/"imageRemotePatternsCount": 1/)
expect(event1).toMatch(/"imageLocalPatternsCount": 2/)
expect(event1).toMatch(/"imageSizes": "64,128,256,512,1024"/)
expect(event1).toMatch(/"imageQualities": "25,50,75"/)
expect(event1).toMatch(/"imageFormats": "image\/avif,image\/webp"/)
expect(event1).toMatch(/"nextConfigOutput": null/)
expect(event1).toMatch(/"trailingSlashEnabled": false/)
@@ -123,6 +124,7 @@ describe('config telemetry', () => {
expect(event2).toMatch(/"imageDomainsCount": 2/)
expect(event2).toMatch(/"imageRemotePatternsCount": 1/)
expect(event2).toMatch(/"imageLocalPatternsCount": 2/)
expect(event2).toMatch(/"imageQualities": "25,50,75"/)
expect(event2).toMatch(/"imageSizes": "64,128,256,512,1024"/)
expect(event2).toMatch(/"nextConfigOutput": null/)
expect(event2).toMatch(/"trailingSlashEnabled": false/)