From 9a0388f0107caeb5160417601f4e30177f27cd95 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 21 May 2021 10:32:36 +0100 Subject: [PATCH 1/2] Support picture tag in next/image --- packages/next/build/webpack-config.ts | 1 + packages/next/client/image.tsx | 99 +++++++++++++++---- .../next/next-server/server/image-config.ts | 6 ++ .../next-server/server/image-optimizer.ts | 12 ++- .../image-component/picture/next.config.js | 5 + .../picture/pages/client-side.js | 31 ++++++ .../image-component/picture/pages/index.js | 35 +++++++ .../picture/test/index.test.js | 78 +++++++++++++++ .../image-optimizer/test/index.test.js | 22 +++++ 9 files changed, 267 insertions(+), 22 deletions(-) create mode 100644 test/integration/image-component/picture/next.config.js create mode 100644 test/integration/image-component/picture/pages/client-side.js create mode 100644 test/integration/image-component/picture/pages/index.js create mode 100644 test/integration/image-component/picture/test/index.test.js diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 139a06abf8687..cc4c8a91564b5 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1102,6 +1102,7 @@ export default async function getBaseWebpackConfig( } : {}), enableBlurryPlaceholder: config.experimental.enableBlurryPlaceholder, + 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 eea353be48980..a83fe03383e35 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 } @@ -90,6 +93,7 @@ const { path: configPath, domains: configDomains, enableBlurryPlaceholder: configEnableBlurryPlaceholder, + formats: configFormats, } = ((process.env.__NEXT_IMAGE_OPTS as any) as ImageConfig) || imageConfigDefault // sort smallest to largest @@ -97,6 +101,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, @@ -154,12 +163,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({ @@ -170,6 +181,7 @@ function generateImgAttrs({ quality, sizes, loader, + format = 'auto', }: GenImgAttrsData): GenImgAttrsResult { if (unoptimized) { return { src, srcSet: undefined, sizes: undefined } @@ -179,11 +191,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}` ) @@ -195,7 +208,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 }), } } @@ -442,6 +455,8 @@ export default function Image({ } } + const usePictureTag = !unoptimized && imageFormats.length + let imgAttributes: GenImgAttrsResult = { src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', @@ -461,6 +476,20 @@ export default function Image({ }) } + const img = ( + { + setRef(element) + removePlaceholder(element, placeholder) + }} + style={imgStyle} + /> + ) + if (unsized) { wrapperStyle = undefined sizerStyle = undefined @@ -508,23 +537,38 @@ 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 d8b63b85a937b..8f1fae8de4b2f 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' From f33152293b13d5df7585046dc6fbdf1c73fd649b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 2 Jun 2021 10:21:22 +0100 Subject: [PATCH 2/2] Add docs --- docs/basic-features/image-optimization.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index 52a248861c40c..d699165d90ab6 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -138,6 +138,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: