Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Draft
wants to merge 1 commit into
base: canary
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
1 change: 1 addition & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ 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)).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
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,
}
2 changes: 1 addition & 1 deletion packages/next/src/shared/lib/image-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function defaultLoader({
}

return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${
quality || 75
quality || config.qualities?.at(-1) || 75 // TODO: is it safe to use .at()?
}${
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
53 changes: 53 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,59 @@ 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(
`Array must be integer elements 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>
)
}
16 changes: 16 additions & 0 deletions test/integration/next-image-new/app-dir-qualities/app/page.js
Original file line number Diff line number Diff line change
@@ -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-100" id="q-100" quality={100} src={src} />
</main>
)
}

export default Page
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
images: {
qualities: [42, 69],
},
}
144 changes: 144 additions & 0 deletions test/integration/next-image-new/app-dir-qualities/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/* 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 highest available quality
})

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 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],
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')
}
)
})
1 change: 1 addition & 0 deletions test/integration/next-image-new/app-dir/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions test/integration/telemetry/next.config.i18n-images
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading
Loading