diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index 431177760454..fffaa8698044 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -16,6 +16,7 @@ description: Enable Image Optimization with the built-in Image component. | Version | Changes | | --------- | ------------------------------------------------------------------------------------------------- | +| `v12.0.0` | `formats` configuration added as well as AVIF support. | | `v11.1.0` | `onLoadingComplete` and `lazyBoundary` props added. | | `v11.0.0` | `src` prop support for static import.
`placeholder` prop added.
`blurDataURL` prop added. | | `v10.0.5` | `loader` prop added. | @@ -141,7 +142,7 @@ Should only be used when the image is visible above the fold. Defaults to `false A placeholder to use while the image is loading. Possible values are `blur` or `empty`. Defaults to `empty`. -When `blur`, the [`blurDataURL`](#blurdataurl) property will be used as the placeholder. If `src` is an object from a [static import](#local-images) and the imported image is `.jpg`, `.png`, or `.webp`, then `blurDataURL` will be automatically populated. +When `blur`, the [`blurDataURL`](#blurdataurl) property will be used as the placeholder. If `src` is an object from a [static import](#local-images) and the imported image is `.jpg`, `.png`, `.webp`, or `.avif`, then `blurDataURL` will be automatically populated. For dynamic images, you must provide the [`blurDataURL`](#blurdataurl) property. Solutions such as [Plaiceholder](https://github.com/joe-bell/plaiceholder) can help with `base64` generation. @@ -322,6 +323,7 @@ The expiration (or rather Max Age) is defined by the upstream server's `Cache-Co - If `s-maxage` is found in `Cache-Control`, it is used. If no `s-maxage` is found, then `max-age` is used. If no `max-age` is found, then [`minimumCacheTTL`](#minimum-cache-ttl) is used. - You can configure [`minimumCacheTTL`](#minimum-cache-ttl) to increase the cache duration when the upstream image does not include `max-age`. - You can also configure [`deviceSizes`](#device-sizes) and [`imageSizes`](#device-sizes) to reduce the total number of possible generated images. +- You can also configure [formats](/docs/basic-features/image-optimization.md#acceptable-formats) to disable multiple formats in favor of a single image format. You can configure the Time to Live (TTL) in seconds for cached optimized images. In many cases, it's better to use a [Static Image Import](/docs/basic-features/image-optimization.md#local-images) which will automatically hash the file contents and cache the image forever with a `Cache-Control` header of `immutable`. @@ -351,6 +353,22 @@ module.exports = { } ``` +### Acceptable Formats + +The default [Image Optimization API](#loader-configuration) will automatically detect the browser's supported image formats via the request's `Accept` header. + +If the `Accept` matches more than one of the configured formats, the first match in the array is used. Therefore, the array order matters. If there is no match, the Image Optimization API will fallback to the original image's format. + +If no configuration is provided, the default below is used. + +```js +module.exports = { + images: { + formats: ['image/avif', 'image/webp'], + }, +} +``` + ## Related For an overview of the Image component features and usage guidelines, see: diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index 74512c02a4fe..150e1382167e 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -38,7 +38,7 @@ To use a local image, `import` your `.jpg`, `.png`, or `.webp` files: import profilePic from '../public/me.png' ``` -Dynamic `await import()` or `require()` are _not_ supported. The `import` must be static. Also note that static image support requires Webpack 5, which is enabled by default in Next.js applications. +Dynamic `await import()` or `require()` are _not_ supported. The `import` must be static so it can be analyzed at build time. Next.js will automatically determine the `width` and `height` of your image based on the imported file. These values are used to prevent [Cumulative Layout Shift](https://nextjs.org/learn/seo/web-performance/cls) while your image is loading. diff --git a/docs/testing.md b/docs/testing.md index dffd78325bfc..5e9a37ff33ea 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -180,7 +180,8 @@ module.exports = { /* Handle image imports https://jestjs.io/docs/webpack#handling-static-assets */ - '^.+\\.(jpg|jpeg|png|gif|webp|svg)$': '/__mocks__/fileMock.js', + '^.+\\.(jpg|jpeg|png|gif|webp|avif|svg)$': + '/__mocks__/fileMock.js', }, testPathIgnorePatterns: ['/node_modules/', '/.next/'], testEnvironment: 'jsdom', diff --git a/errors/invalid-images-config.md b/errors/invalid-images-config.md index febbc5ee4ada..506288d294e5 100644 --- a/errors/invalid-images-config.md +++ b/errors/invalid-images-config.md @@ -17,6 +17,7 @@ module.exports = { imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], // limit of 50 domains values domains: [], + // path prefix for Image Optimization API, useful with `loader` path: '/_next/image', // loader can be 'default', 'imgix', 'cloudinary', 'akamai', or 'custom' loader: 'default', @@ -24,6 +25,8 @@ module.exports = { disableStaticImages: false, // minimumCacheTTL is in seconds, must be integer 0 or more minimumCacheTTL: 60, + // ordered list of acceptable optimized image formats (mime types) + formats: ['image/avif', 'image/webp'], }, } ``` @@ -31,3 +34,4 @@ module.exports = { ### Useful Links - [Image Optimization Documentation](https://nextjs.org/docs/basic-features/image-optimization) +- [`next/image` Documentation](https://nextjs.org/docs/api-reference/next/image) diff --git a/errors/manifest.json b/errors/manifest.json index 7af12e799e27..067a2908b5fb 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -447,6 +447,10 @@ "title": "sharp-missing-in-production", "path": "/errors/sharp-missing-in-production.md" }, + { + "title": "sharp-version-avif", + "path": "/errors/sharp-version-avif.md" + }, { "title": "script-in-document-page", "path": "/errors/no-script-in-document-page.md" diff --git a/errors/placeholder-blur-data-url.md b/errors/placeholder-blur-data-url.md index 2fc53099c983..9c9a76819dd7 100644 --- a/errors/placeholder-blur-data-url.md +++ b/errors/placeholder-blur-data-url.md @@ -6,7 +6,7 @@ You are attempting use the `next/image` component with `placeholder=blur` proper The `blurDataURL` might be missing because you're using a string for `src` instead of a static import. -Or `blurDataURL` might be missing because the static import is an unsupported image format. Only jpg, png, and webp are supported at this time. +Or `blurDataURL` might be missing because the static import is an unsupported image format. Only jpg, png, webp, and avif are supported at this time. #### Possible Ways to Fix It diff --git a/errors/sharp-missing-in-production.md b/errors/sharp-missing-in-production.md index 02face5fc054..96903efad9ca 100644 --- a/errors/sharp-missing-in-production.md +++ b/errors/sharp-missing-in-production.md @@ -16,3 +16,4 @@ You are seeing this error because Image Optimization in production mode (`next s ### Useful Links - [Image Optimization Documentation](https://nextjs.org/docs/basic-features/image-optimization) +- [`next/image` Documentation](https://nextjs.org/docs/api-reference/next/image) diff --git a/errors/sharp-version-avif.md b/errors/sharp-version-avif.md new file mode 100644 index 000000000000..dcd90aea3662 --- /dev/null +++ b/errors/sharp-version-avif.md @@ -0,0 +1,24 @@ +# Sharp Version Does Not Support AVIF + +#### Why This Error Occurred + +The `next/image` component's default loader uses [`sharp`](https://www.npmjs.com/package/sharp) if its installed. + +You are seeing this error because you have an outdated version of [`sharp`](https://www.npmjs.com/package/sharp) installed that does not support the AVIF image format. + +AVIF support was added to [`sharp`](https://www.npmjs.com/package/sharp) in version 0.27.0 (December 2020) so your installed version is likely older. + +#### Possible Ways to Fix It + +- Install the latest version of `sharp` by running `yarn add sharp@latest` in your project directory +- If you're using the `NEXT_SHARP_PATH` environment variable, then update the `sharp` install referenced in that path, for example `cd "$NEXT_SHARP_PATH/../" && yarn add sharp@latest` +- If you cannot upgrade `sharp`, you can instead disable AVIF by configuring [`formats`](https://nextjs.org/docs/api-reference/next/image#image-formats) in your `next.config.js` + +After choosing an option above, reboot the server by running either `next dev` or `next start` for development or production respectively. + +> Note: This is not necessary for Vercel deployments, since `sharp` is installed automatically for you. + +### Useful Links + +- [Image Optimization Documentation](https://nextjs.org/docs/basic-features/image-optimization) +- [`next/image` Documentation](https://nextjs.org/docs/api-reference/next/image) diff --git a/examples/with-jest/jest.config.js b/examples/with-jest/jest.config.js index 15c4717b8e12..eacc5d367f56 100644 --- a/examples/with-jest/jest.config.js +++ b/examples/with-jest/jest.config.js @@ -14,7 +14,7 @@ module.exports = { // Handle image imports // https://jestjs.io/docs/webpack#handling-static-assets - '^.+\\.(jpg|jpeg|png|gif|webp|svg)$': `/__mocks__/fileMock.js`, + '^.+\\.(jpg|jpeg|png|gif|webp|avif|svg)$': `/__mocks__/fileMock.js`, // Handle module aliases '^@/components/(.*)$': '/components/$1', diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 007dc34f2737..8f7e032ea424 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1051,7 +1051,7 @@ export default async function getBaseWebpackConfig( ...(!config.images.disableStaticImages ? [ { - test: /\.(png|jpg|jpeg|gif|webp|ico|bmp|svg)$/i, + test: /\.(png|jpg|jpeg|gif|webp|avif|ico|bmp|svg)$/i, loader: 'next-image-loader', issuer: { not: regexLikeCss }, dependency: { not: ['url'] }, @@ -1512,7 +1512,7 @@ export default async function getBaseWebpackConfig( // Exclude svg if the user already defined it in custom // webpack config such as `@svgr/webpack` plugin or // the `babel-plugin-inline-react-svg` plugin. - nextImageRule.test = /\.(png|jpg|jpeg|gif|webp|ico|bmp)$/i + nextImageRule.test = /\.(png|jpg|jpeg|gif|webp|avif|ico|bmp)$/i } } diff --git a/packages/next/build/webpack/config/blocks/images/index.ts b/packages/next/build/webpack/config/blocks/images/index.ts index 6ea422e5af9d..8ffdad64333c 100644 --- a/packages/next/build/webpack/config/blocks/images/index.ts +++ b/packages/next/build/webpack/config/blocks/images/index.ts @@ -12,7 +12,7 @@ export const images = curry(async function images( loader({ oneOf: [ { - test: /\.(png|jpg|jpeg|gif|webp|ico|bmp|svg)$/i, + test: /\.(png|jpg|jpeg|gif|webp|avif|ico|bmp|svg)$/i, use: { loader: 'error-loader', options: { diff --git a/packages/next/build/webpack/loaders/next-image-loader.js b/packages/next/build/webpack/loaders/next-image-loader.js index 44507115a18e..281d00a6171a 100644 --- a/packages/next/build/webpack/loaders/next-image-loader.js +++ b/packages/next/build/webpack/loaders/next-image-loader.js @@ -1,10 +1,9 @@ import loaderUtils from 'next/dist/compiled/loader-utils' -import sizeOf from 'image-size' -import { resizeImage } from '../../../server/image-optimizer' +import { resizeImage, getImageSize } from '../../../server/image-optimizer' const BLUR_IMG_SIZE = 8 const BLUR_QUALITY = 70 -const VALID_BLUR_EXT = ['jpeg', 'png', 'webp'] +const VALID_BLUR_EXT = ['jpeg', 'png', 'webp', 'avif'] // should match next/client/image.tsx function nextImageLoader(content) { const imageLoaderSpan = this.currentTraceSpan.traceChild('next-image-loader') @@ -26,7 +25,9 @@ function nextImageLoader(content) { } const imageSizeSpan = imageLoaderSpan.traceChild('image-size-calculation') - const imageSize = imageSizeSpan.traceFn(() => sizeOf(content)) + const imageSize = await imageSizeSpan.traceAsyncFn(() => + getImageSize(content, extension) + ) let blurDataURL if (VALID_BLUR_EXT.includes(extension)) { diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 15fb89f5097c..cd317c58f21c 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -416,7 +416,7 @@ export default function Image({ ) } if (!blurDataURL) { - const VALID_BLUR_EXT = ['jpeg', 'png', 'webp'] // should match next-image-loader + const VALID_BLUR_EXT = ['jpeg', 'png', 'webp', 'avif'] // should match next-image-loader throw new Error( `Image with src "${src}" has "placeholder='blur'" property but is missing the "blurDataURL" property. diff --git a/packages/next/image-types/global.d.ts b/packages/next/image-types/global.d.ts index a41a6c6a5a04..1a1c9642b8b3 100644 --- a/packages/next/image-types/global.d.ts +++ b/packages/next/image-types/global.d.ts @@ -49,6 +49,12 @@ declare module '*.webp' { export default content } +declare module '*.avif' { + const content: StaticImageData + + export default content +} + declare module '*.ico' { const content: StaticImageData diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index f0ac31ab67f2..8e7224fb7b42 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -312,6 +312,32 @@ function assignDefaults(userConfig: { [key: string]: any }) { )}), received (${images.minimumCacheTTL}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` ) } + + if (images.formats) { + const { formats } = images + if (!Array.isArray(formats)) { + throw new Error( + `Specified images.formats should be an Array received ${typeof formats}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } + if (formats.length < 1 || formats.length > 2) { + throw new Error( + `Specified images.formats must be length 1 or 2, received length (${formats.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } + + const invalid = formats.filter((f) => { + return f !== 'image/avif' && f !== 'image/webp' + }) + + if (invalid.length > 0) { + throw new Error( + `Specified images.formats should be an Array of mime type strings, received invalid values (${invalid.join( + ', ' + )}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } + } } if (result.webpack5 === false) { diff --git a/packages/next/server/image-config.ts b/packages/next/server/image-config.ts index 910298658833..a3de1f259c38 100644 --- a/packages/next/server/image-config.ts +++ b/packages/next/server/image-config.ts @@ -8,6 +8,8 @@ export const VALID_LOADERS = [ export type LoaderValue = typeof VALID_LOADERS[number] +type ImageFormat = 'image/avif' | 'image/webp' + export type ImageConfigComplete = { deviceSizes: number[] imageSizes: number[] @@ -16,6 +18,7 @@ export type ImageConfigComplete = { domains?: string[] disableStaticImages?: boolean minimumCacheTTL?: number + formats?: ImageFormat[] } export type ImageConfig = Partial @@ -28,4 +31,5 @@ export const imageConfigDefault: ImageConfigComplete = { domains: [], disableStaticImages: false, minimumCacheTTL: 60, + formats: ['image/avif', 'image/webp'], } diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 4a4227aeeb1f..d66c3719d607 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -2,6 +2,7 @@ import { mediaType } from '@hapi/accept' import { createHash } from 'crypto' import { createReadStream, promises } from 'fs' import { getOrientation, Orientation } from 'get-orientation' +import imageSizeOf from 'image-size' import { IncomingMessage, ServerResponse } from 'http' // @ts-ignore no types for is-animated import isAnimated from 'next/dist/compiled/is-animated' @@ -11,20 +12,19 @@ import nodeUrl, { UrlWithParsedQuery } from 'url' import { NextConfig } from './config-shared' import { fileExists } from '../lib/file-exists' import { ImageConfig, imageConfigDefault } from './image-config' -import { processBuffer, Operation } from './lib/squoosh/main' +import { processBuffer, decodeBuffer, Operation } from './lib/squoosh/main' import Server from './next-server' import { sendEtagResponse } from './send-payload' import { getContentType, getExtension } from './serve-static' import chalk from 'chalk' -//const AVIF = 'image/avif' +const AVIF = 'image/avif' const WEBP = 'image/webp' const PNG = 'image/png' const JPEG = 'image/jpeg' const GIF = 'image/gif' const SVG = 'image/svg+xml' const CACHE_VERSION = 3 -const MODERN_TYPES = [/* AVIF, */ WEBP] const ANIMATABLE_TYPES = [WEBP, PNG, GIF] const VECTOR_TYPES = [SVG] const BLUR_IMG_SIZE = 8 // should match `next-image-loader` @@ -43,7 +43,7 @@ try { // Sharp not present on the server, Squoosh fallback will be used } -let shouldShowSharpWarning = process.env.NODE_ENV === 'production' +let showSharpMissingWarning = process.env.NODE_ENV === 'production' export async function imageOptimizer( server: Server, @@ -61,6 +61,7 @@ export async function imageOptimizer( domains = [], loader, minimumCacheTTL = 60, + formats = ['image/avif', 'image/webp'], } = imageData if (loader !== 'default') { @@ -70,7 +71,7 @@ export async function imageOptimizer( const { headers } = req const { url, w, q } = parsedUrl.query - const mimeType = getSupportedMimeType(MODERN_TYPES, headers.accept) + const mimeType = getSupportedMimeType(formats, headers.accept) let href: string if (!url) { @@ -359,7 +360,18 @@ export async function imageOptimizer( transformer.resize(width) } - if (contentType === WEBP) { + if (contentType === AVIF) { + if (transformer.avif) { + transformer.avif({ quality }) + } else { + console.warn( + chalk.yellow.bold('Warning: ') + + `Your installed version of the 'sharp' package does not support AVIF images. Run 'yarn add sharp@latest' to upgrade to the latest version.\n` + + 'Read more: https://nextjs.org/docs/messages/sharp-version-avif' + ) + transformer.webp({ quality }) + } + } else if (contentType === WEBP) { transformer.webp({ quality }) } else if (contentType === PNG) { transformer.png({ quality }) @@ -371,13 +383,13 @@ export async function imageOptimizer( // End sharp transformation logic } else { // Show sharp warning in production once - if (shouldShowSharpWarning) { + if (showSharpMissingWarning) { console.warn( chalk.yellow.bold('Warning: ') + `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended. Run 'yarn add sharp', and Next.js will use it automatically for Image Optimization.\n` + 'Read more: https://nextjs.org/docs/messages/sharp-missing-in-production' ) - shouldShowSharpWarning = false + showSharpMissingWarning = false } // Begin Squoosh transformation logic @@ -399,9 +411,14 @@ export async function imageOptimizer( operations.push({ type: 'resize', width }) - //if (contentType === AVIF) { - //} else - if (contentType === WEBP) { + if (contentType === AVIF) { + optimizedBuffer = await processBuffer( + upstreamBuffer, + operations, + 'avif', + quality + ) + } else if (contentType === WEBP) { optimizedBuffer = await processBuffer( upstreamBuffer, operations, @@ -620,6 +637,13 @@ export function detectContentType(buffer: Buffer) { if ([0x3c, 0x3f, 0x78, 0x6d, 0x6c].every((b, i) => buffer[i] === b)) { return SVG } + if ( + [0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66].every( + (b, i) => !b || buffer[i] === b + ) + ) { + return AVIF + } return null } @@ -642,13 +666,25 @@ export async function resizeImage( content: Buffer, dimension: 'width' | 'height', size: number, - extension: 'webp' | 'png' | 'jpeg', + // Should match VALID_BLUR_EXT + extension: 'avif' | 'webp' | 'png' | 'jpeg', quality: number ): Promise { if (sharp) { const transformer = sharp(content) - if (extension === 'webp') { + if (extension === 'avif') { + if (transformer.avif) { + transformer.avif({ quality }) + } else { + console.warn( + chalk.yellow.bold('Warning: ') + + `Your installed version of the 'sharp' package does not support AVIF images. Run 'yarn add sharp@latest' to upgrade to the latest version.\n` + + 'Read more: https://nextjs.org/docs/messages/sharp-version-avif' + ) + transformer.webp({ quality }) + } + } else if (extension === 'webp') { transformer.webp({ quality }) } else if (extension === 'png') { transformer.png({ quality }) @@ -676,3 +712,28 @@ export async function resizeImage( return buf } } + +export async function getImageSize( + buffer: Buffer, + // Should match VALID_BLUR_EXT + extension: 'avif' | 'webp' | 'png' | 'jpeg' +): Promise<{ + width?: number + height?: number +}> { + // TODO: upgrade "image-size" package to support AVIF + // See https://github.com/image-size/image-size/issues/348 + if (extension === 'avif') { + if (sharp) { + const transformer = sharp(buffer) + const { width, height } = await transformer.metadata() + return { width, height } + } else { + const { width, height } = await decodeBuffer(buffer) + return { width, height } + } + } + + const { width, height } = imageSizeOf(buffer) + return { width, height } +} diff --git a/packages/next/server/lib/squoosh/main.ts b/packages/next/server/lib/squoosh/main.ts index 519e57245680..3b9077ea6e29 100644 --- a/packages/next/server/lib/squoosh/main.ts +++ b/packages/next/server/lib/squoosh/main.ts @@ -72,3 +72,9 @@ export async function processBuffer( throw Error(`Unsupported encoding format`) } } + +export async function decodeBuffer(buffer: Buffer) { + const worker: typeof import('./impl') = getWorker() as any + const imageData = await worker.decodeBuffer(buffer) + return imageData +} diff --git a/packages/next/server/serve-static.ts b/packages/next/server/serve-static.ts index 52826b92e5f7..7c2a441c6b75 100644 --- a/packages/next/server/serve-static.ts +++ b/packages/next/server/serve-static.ts @@ -21,6 +21,10 @@ export function serveStatic( } export function getContentType(extWithoutDot: string): string | null { + if (extWithoutDot === 'avif') { + // TODO: update "mime" package + return 'image/avif' + } const { mime } = send if ('getType' in mime) { // 2.0 @@ -31,6 +35,10 @@ export function getContentType(extWithoutDot: string): string | null { } export function getExtension(contentType: string): string | null { + if (contentType === 'image/avif') { + // TODO: update "mime" package + return 'avif' + } const { mime } = send if ('getExtension' in mime) { // 2.0 diff --git a/test/integration/image-component/base-path/pages/static-img.js b/test/integration/image-component/base-path/pages/static-img.js index 29912c6defc2..ee07521ec8ac 100644 --- a/test/integration/image-component/base-path/pages/static-img.js +++ b/test/integration/image-component/base-path/pages/static-img.js @@ -5,6 +5,7 @@ import Image from 'next/image' import testJPG from '../public/test.jpg' import testPNG from '../public/test.png' import testWEBP from '../public/test.webp' +import testAVIF from '../public/test.avif' import testSVG from '../public/test.svg' import testGIF from '../public/test.gif' import testBMP from '../public/test.bmp' @@ -41,6 +42,7 @@ const Page = () => { + diff --git a/test/integration/image-component/base-path/public/test.avif b/test/integration/image-component/base-path/public/test.avif new file mode 100644 index 000000000000..e2c8170a6833 Binary files /dev/null and b/test/integration/image-component/base-path/public/test.avif differ diff --git a/test/integration/image-component/base-path/test/static.test.js b/test/integration/image-component/base-path/test/static.test.js index 2940de398455..42f6d197c034 100644 --- a/test/integration/image-component/base-path/test/static.test.js +++ b/test/integration/image-component/base-path/test/static.test.js @@ -24,6 +24,7 @@ const runTests = (isDev = false) => { expect(await browser.elementById('basic-static')).toBeTruthy() expect(await browser.elementById('blur-png')).toBeTruthy() expect(await browser.elementById('blur-webp')).toBeTruthy() + expect(await browser.elementById('blur-avif')).toBeTruthy() expect(await browser.elementById('blur-jpg')).toBeTruthy() expect(await browser.elementById('static-svg')).toBeTruthy() expect(await browser.elementById('static-gif')).toBeTruthy() diff --git a/test/integration/image-component/default/pages/static-img.js b/test/integration/image-component/default/pages/static-img.js index 29912c6defc2..ee07521ec8ac 100644 --- a/test/integration/image-component/default/pages/static-img.js +++ b/test/integration/image-component/default/pages/static-img.js @@ -5,6 +5,7 @@ import Image from 'next/image' import testJPG from '../public/test.jpg' import testPNG from '../public/test.png' import testWEBP from '../public/test.webp' +import testAVIF from '../public/test.avif' import testSVG from '../public/test.svg' import testGIF from '../public/test.gif' import testBMP from '../public/test.bmp' @@ -41,6 +42,7 @@ const Page = () => { + diff --git a/test/integration/image-component/default/public/test.avif b/test/integration/image-component/default/public/test.avif new file mode 100644 index 000000000000..e2c8170a6833 Binary files /dev/null and b/test/integration/image-component/default/public/test.avif differ diff --git a/test/integration/image-component/default/test/static.test.js b/test/integration/image-component/default/test/static.test.js index 32ef25450440..55c7802d958a 100644 --- a/test/integration/image-component/default/test/static.test.js +++ b/test/integration/image-component/default/test/static.test.js @@ -23,6 +23,7 @@ const runTests = () => { expect(await browser.elementById('basic-static')).toBeTruthy() expect(await browser.elementById('blur-png')).toBeTruthy() expect(await browser.elementById('blur-webp')).toBeTruthy() + expect(await browser.elementById('blur-avif')).toBeTruthy() expect(await browser.elementById('blur-jpg')).toBeTruthy() expect(await browser.elementById('static-svg')).toBeTruthy() expect(await browser.elementById('static-gif')).toBeTruthy() diff --git a/test/integration/image-component/typescript/pages/valid.tsx b/test/integration/image-component/typescript/pages/valid.tsx index 09f4e287e298..8747c8e81369 100644 --- a/test/integration/image-component/typescript/pages/valid.tsx +++ b/test/integration/image-component/typescript/pages/valid.tsx @@ -2,6 +2,7 @@ import React from 'react' import Image from 'next/image' import testTall from '../public/tall.png' import svg from '../public/test.svg' +import avif from '../public/test.avif' import { ImageCard } from '../components/image-card' import { DynamicSrcImage } from '../components/image-dynamic-src' @@ -77,6 +78,7 @@ const Page = () => { placeholder="blur" /> + { const res = await fetchViaHTTP(appPort, '/', null, {}) expect(await res.text()).toMatch(/Image Optimizer Home/m) @@ -359,10 +360,10 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { // FIXME: await expectWidth(res, w) }) - it('should resize relative url and Chrome accept header as webp', async () => { + it('should resize relative url and old Chrome accept header as webp', async () => { const query = { url: '/test.png', w, q: 80 } const opts = { - headers: { accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8' }, + headers: { accept: 'image/webp,image/apng,image/*,*/*;q=0.8' }, } const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res.status).toBe(200) @@ -378,6 +379,27 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { await expectWidth(res, w) }) + it('should resize relative url and new Chrome accept header as avif', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { + headers: { accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8' }, + } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/avif') + expect(res.headers.get('Cache-Control')).toBe( + `public, max-age=0, must-revalidate` + ) + expect(res.headers.get('Vary')).toBe('Accept') + expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.avif"` + ) + // TODO: upgrade "image-size" package to support AVIF + // See https://github.com/image-size/image-size/issues/348 + //await expectWidth(res, w) + }) + if (domains.includes('localhost')) { it('should resize absolute url from localhost', async () => { const url = `http://localhost:${appPort}/test.png` @@ -733,6 +755,16 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { expect(nextOutput).toContain(sharpMissingText) }) } + + if (isSharp && isOutdatedSharp) { + it('should have sharp outdated warning', () => { + expect(nextOutput).toContain(sharpOutdatedText) + }) + } else { + it('should not have sharp outdated warning', () => { + expect(nextOutput).not.toContain(sharpOutdatedText) + }) + } } describe('Image Optimizer', () => { @@ -888,6 +920,31 @@ describe('Image Optimizer', () => { /Error: Image with src "(.+)" is missing "loader" prop/ ) }) + + it('should error when images.formats contains invalid values', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + formats: ['image/avif', 'jpeg'], + }, + }) + ) + 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( + `Specified images.formats should be an Array of mime type strings, received invalid values (jpeg)` + ) + }) }) // domains for testing @@ -1077,7 +1134,7 @@ describe('Image Optimizer', () => { }) }) - const setupTests = (isSharp = false) => { + const setupTests = ({ isSharp = false, isOutdatedSharp = false }) => { describe('dev support w/o next.config.js', () => { const size = 384 // defaults defined in server/config.ts beforeAll(async () => { @@ -1087,6 +1144,11 @@ describe('Image Optimizer', () => { onStderr(msg) { nextOutput += msg }, + env: { + NEXT_SHARP_PATH: isSharp + ? join(appDir, 'node_modules', 'sharp') + : '', + }, cwd: appDir, }) }) @@ -1095,7 +1157,7 @@ describe('Image Optimizer', () => { await fs.remove(imagesDir) }) - runTests({ w: size, isDev: true, domains: [], isSharp }) + runTests({ w: size, isDev: true, domains: [], isSharp, isOutdatedSharp }) }) describe('dev support with next.config.js', () => { @@ -1115,6 +1177,11 @@ describe('Image Optimizer', () => { onStderr(msg) { nextOutput += msg }, + env: { + NEXT_SHARP_PATH: isSharp + ? join(appDir, 'node_modules', 'sharp') + : '', + }, cwd: appDir, }) }) @@ -1124,7 +1191,7 @@ describe('Image Optimizer', () => { await fs.remove(imagesDir) }) - runTests({ w: size, isDev: true, domains, isSharp }) + runTests({ w: size, isDev: true, domains, isSharp, isOutdatedSharp }) }) describe('Server support w/o next.config.js', () => { @@ -1139,9 +1206,7 @@ describe('Image Optimizer', () => { }, env: { NEXT_SHARP_PATH: isSharp - ? require.resolve('sharp', { - paths: [join(appDir, 'node_modules')], - }) + ? join(appDir, 'node_modules', 'sharp') : '', }, cwd: appDir, @@ -1152,7 +1217,7 @@ describe('Image Optimizer', () => { await fs.remove(imagesDir) }) - runTests({ w: size, isDev: false, domains: [], isSharp }) + runTests({ w: size, isDev: false, domains: [], isSharp, isOutdatedSharp }) }) describe('Server support with next.config.js', () => { @@ -1174,9 +1239,7 @@ describe('Image Optimizer', () => { }, env: { NEXT_SHARP_PATH: isSharp - ? require.resolve('sharp', { - paths: [join(appDir, 'node_modules')], - }) + ? join(appDir, 'node_modules', 'sharp') : '', }, cwd: appDir, @@ -1188,15 +1251,15 @@ describe('Image Optimizer', () => { await fs.remove(imagesDir) }) - runTests({ w: size, isDev: false, domains, isSharp }) + runTests({ w: size, isDev: false, domains, isSharp, isOutdatedSharp }) }) } describe('with squoosh', () => { - setupTests() + setupTests({ isSharp: false, isOutdatedSharp: false }) }) - describe('with sharp', () => { + describe('with latest sharp', () => { beforeAll(async () => { await execa('yarn', ['init', '-y'], { cwd: appDir, @@ -1213,6 +1276,26 @@ describe('Image Optimizer', () => { await fs.remove(join(appDir, 'package.json')) }) - setupTests(true) + setupTests({ isSharp: true, isOutdatedSharp: false }) + }) + + describe('with outdated sharp', () => { + beforeAll(async () => { + await execa('yarn', ['init', '-y'], { + cwd: appDir, + stdio: 'inherit', + }) + await execa('yarn', ['add', 'sharp@0.26.3'], { + cwd: appDir, + stdio: 'inherit', + }) + }) + afterAll(async () => { + await fs.remove(join(appDir, 'node_modules')) + await fs.remove(join(appDir, 'yarn.lock')) + await fs.remove(join(appDir, 'package.json')) + }) + + setupTests({ isSharp: true, isOutdatedSharp: true }) }) }) diff --git a/test/unit/image-optimizer/detect-content-type.test.ts b/test/unit/image-optimizer/detect-content-type.test.ts index b94d613fbbba..6455a3313b01 100644 --- a/test/unit/image-optimizer/detect-content-type.test.ts +++ b/test/unit/image-optimizer/detect-content-type.test.ts @@ -22,4 +22,8 @@ describe('detectContentType', () => { const buffer = await getImage('./images/test.svg') expect(detectContentType(buffer)).toBe('image/svg+xml') }) + it('should return avif', async () => { + const buffer = await getImage('./images/test.avif') + expect(detectContentType(buffer)).toBe('image/avif') + }) }) diff --git a/test/unit/image-optimizer/images/test.avif b/test/unit/image-optimizer/images/test.avif new file mode 100644 index 000000000000..e2c8170a6833 Binary files /dev/null and b/test/unit/image-optimizer/images/test.avif differ