diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index 075b66d1b5e2c..bc94c67c1fb5d 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -187,6 +187,23 @@ module.exports = { } ``` +### Image formats + +By default the server uses [content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation) to automatically deliver next generation image formats to browsers that support them. This means that the format is determined by the `Accept` header sent by the browser. In some cases you may prefer to use the URL to specify the format, such as if your host doesn't support content negotiation, you need to cache based on the URL, or you want to include formats that your host doesn't generate by default. In this situation you can add an array of formats in the `formats` property. + +```js +module.exports = { + images: { + formats: ['jxl', 'avif', 'webp'], + loader: 'cloudinary', + }, +} +``` + +If these are included then the `next/image` component generates a [`` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture) with a `` element for each format. The browser will choose a supported format from the available options. For browsers that do not support any next generation formats or that don't support the `` tag, it will fall back to an `` tag. + +The supported formats depend on the chosen loader. By default, the only supported format is `'webp'`, but other loaders will support more. You do not need to specify fallback formats such as `jpeg` or `png`, as these are delivered automatically. + ## Related For more information on what to do next, we recommend the following sections: diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 434e6a71e6c67..410109b53b48a 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1116,6 +1116,7 @@ export default async function getBaseWebpackConfig( domains: config.images.domains, } : {}), + formats: config.images.formats, }), 'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath), 'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites), diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 121ebada700bb..02afc5c57e470 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -6,6 +6,8 @@ import { imageConfigDefault, LoaderValue, VALID_LOADERS, + ImageFormat, + VALID_FORMATS, } from '../next-server/server/image-config' import { useIntersection } from './use-intersection' @@ -22,6 +24,7 @@ export type ImageLoaderProps = { src: string width: number quality?: number + format?: ImageFormat } type DefaultImageLoaderProps = ImageLoaderProps & { root: string } @@ -128,6 +131,7 @@ const { loader: configLoader, path: configPath, domains: configDomains, + formats: configFormats, } = ((process.env.__NEXT_IMAGE_OPTS as any) as ImageConfig) || imageConfigDefault // sort smallest to largest @@ -135,6 +139,11 @@ const allSizes = [...configDeviceSizes, ...configImageSizes] configDeviceSizes.sort((a, b) => a - b) allSizes.sort((a, b) => a - b) +const imageFormats = + configFormats?.filter( + (format) => format !== 'auto' && VALID_FORMATS.includes(format) + ) || [] + function getWidths( width: number | undefined, layout: LayoutValue, @@ -192,12 +201,14 @@ type GenImgAttrsData = { width?: number quality?: number sizes?: string + format?: ImageFormat } type GenImgAttrsResult = { src: string srcSet: string | undefined sizes: string | undefined + type?: string } function generateImgAttrs({ @@ -208,6 +219,7 @@ function generateImgAttrs({ quality, sizes, loader, + format = 'auto', }: GenImgAttrsData): GenImgAttrsResult { if (unoptimized) { return { src, srcSet: undefined, sizes: undefined } @@ -217,11 +229,12 @@ function generateImgAttrs({ const last = widths.length - 1 return { + type: format && format !== 'auto' ? `image/${format}` : undefined, sizes: !sizes && kind === 'w' ? '100vw' : sizes, srcSet: widths .map( (w, i) => - `${loader({ src, quality, width: w })} ${ + `${loader({ src, quality, width: w, format })} ${ kind === 'w' ? w : i + 1 }${kind}` ) @@ -233,7 +246,7 @@ function generateImgAttrs({ // updated by React. That causes multiple unnecessary requests if `srcSet` // and `sizes` are defined. // This bug cannot be reproduced in Chrome or Firefox. - src: loader({ src, quality, width: widths[last] }), + src: loader({ src, quality, width: widths[last], format }), } } @@ -524,6 +537,8 @@ export default function Image({ } } + const usePictureTag = !unoptimized && imageFormats.length + let imgAttributes: GenImgAttrsResult = { src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', @@ -543,6 +558,20 @@ export default function Image({ }) } + const img = ( + { + setRef(element) + removePlaceholder(element, placeholder) + }} + style={imgStyle} + /> + ) + return (
{sizerStyle ? ( @@ -583,23 +612,39 @@ export default function Image({ /> )} - { - setRef(element) - removePlaceholder(element, placeholder) - }} - style={imgStyle} - /> - {priority ? ( + {usePictureTag ? ( + + {isVisible && + imageFormats.map((format) => ( + + ))} + {img} + + ) : ( + img + )} + {priority && !usePictureTag ? ( // Note how we omit the `href` attribute, as it would only be relevant // for browsers that do not support `imagesrcset`, and in those cases // it would likely cause the incorrect image to be preloaded. // // https://html.spec.whatwg.org/multipage/semantics.html#attr-link-imagesrcset + // + // This is also skipped when using a picture tag, because currently there's no + // way to ensure it only downloads the correct format { + return ( +
+

Image Client Side Test

+
+ +
+
+ +
+
+ ) +} + +export default Page diff --git a/test/integration/image-component/picture/pages/index.js b/test/integration/image-component/picture/pages/index.js new file mode 100644 index 0000000000000..02cf1d70ea1aa --- /dev/null +++ b/test/integration/image-component/picture/pages/index.js @@ -0,0 +1,35 @@ +import React from 'react' +import Image from 'next/image' +import Link from 'next/link' + +const Page = () => { + return ( +
+

Image SSR Test

+
+ +
+
+ +
+ + Client Side + +
+ ) +} + +export default Page diff --git a/test/integration/image-component/picture/test/index.test.js b/test/integration/image-component/picture/test/index.test.js new file mode 100644 index 0000000000000..a39f4a96a1545 --- /dev/null +++ b/test/integration/image-component/picture/test/index.test.js @@ -0,0 +1,78 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { killApp, findPort, nextStart, nextBuild } from 'next-test-utils' +import webdriver from 'next-webdriver' + +jest.setTimeout(1000 * 30) + +const appDir = join(__dirname, '../') +let appPort +let app +let browser + +function runTests() { + it('Should include picture tag when formats array is set', async () => { + expect( + await browser + .elementByCss('#basic-image-wrapper picture source') + .getAttribute('srcset') + ).toBe( + '/_next/image?url=%2Ffoo.jpg&w=384&q=60&f=webp 1x, /_next/image?url=%2Ffoo.jpg&w=640&q=60&f=webp 2x' + ) + }) + it('should set the "type" prop on the source element', async () => { + expect( + await browser + .elementByCss('#basic-image-wrapper picture source') + .getAttribute('type') + ).toBe('image/webp') + }) + + it('should include an img tag with default srcset', async () => { + expect( + new URL( + await browser.elementByCss('#basic-image').getAttribute('src') + ).searchParams.get('f') + ).toBeFalsy() + }) + + it('should not use a picture tag when "unoptimized" is set', async () => { + expect( + await browser.hasElementByCssSelector( + '#unoptimized-image-wrapper picture source' + ) + ).toBeFalsy() + }) +} + +describe('Picture Tag Tests', () => { + beforeAll(async () => { + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(() => killApp(app)) + describe('SSR Picture Tag Tests', () => { + beforeAll(async () => { + browser = await webdriver(appPort, '/') + }) + afterAll(async () => { + browser = null + }) + runTests() + }) + describe('Client-side Picture Tag Tests', () => { + beforeAll(async () => { + browser = await webdriver(appPort, '/') + await browser + .elementByCss('#clientlink') + .click() + .waitForElementByCss('#client-side') + }) + afterAll(async () => { + browser = null + }) + runTests() + }) +}) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 2a5d8ecdaab14..c030e11ce3cac 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -123,6 +123,28 @@ function runTests({ w, isDev, domains }) { expect(actual).toMatch(expected) }) + it('should return requested format if valid', async () => { + const query = { w, q: 90, url: '/test.jpg', f: 'webp' } + const res = await fetchViaHTTP(appPort, '/_next/image', query) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toContain('image/webp') + expect(res.headers.get('cache-control')).toBe( + 'public, max-age=0, must-revalidate' + ) + expect(res.headers.get('etag')).toBeTruthy() + }) + + it('should ignore requested format if invalid', async () => { + const query = { w, q: 90, url: '/test.jpg', f: 'bmp' } + const res = await fetchViaHTTP(appPort, '/_next/image', query) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toContain('image/jpeg') + expect(res.headers.get('cache-control')).toBe( + 'public, max-age=0, must-revalidate' + ) + expect(res.headers.get('etag')).toBeTruthy() + }) + it('should maintain jpg format for old Safari', async () => { const accept = 'image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5'