diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index f88194d86bb48ab..fbe1ab86c60c08b 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -50,14 +50,26 @@ export default Home You can configure Image Optimization by using the `images` property in `next.config.js`. -### Sizes +### Device Sizes -You can specify a list of image widths to allow using the `sizes` property. Since images maintain their aspect ratio using the `width` and `height` attributes of the source image, there is no need to specify height in `next.config.js` – only the width. You can think of these as breakpoints. +You can specify a list of device width breakpoints using the `deviceSizes` property. Since images maintain their aspect ratio using the `width` and `height` attributes of the source image, there is no need to specify height in `next.config.js` – only the width. These values will be used by the browser to determine which size image should load. ```js module.exports = { images: { - sizes: [320, 420, 768, 1024, 1200], + deviceSizes: [320, 420, 768, 1024, 1200], + }, +} +``` + +### Icon Sizes + +You can specify a list of icon image widths using the `iconSizes` property. These widths should be smaller than the smallest value in `deviceSizes`. The purpose is for images that don't scale with the browser window, such as icons or badges. If `iconSizes` is not defined, then `deviceSizes` will be used. + +```js +module.exports = { + images: { + iconSizes: [16, 32, 64], }, } ``` @@ -96,7 +108,6 @@ The following Image Optimization cloud providers are supported: - [Cloudinary](https://cloudinary.com): `loader: 'cloudinary'` - [Akamai](https://www.akamai.com): `loader: 'akamai'` - ## Related For more information on what to do next, we recommend the following sections: @@ -107,4 +118,3 @@ For more information on what to do next, we recommend the following sections: See all available properties for the Image component - diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 81256b9875275d4..67cdd21d3b35373 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -1110,11 +1110,15 @@ export default async function build( ) } + const images = { ...config.images } + const { deviceSizes, iconSizes } = images + images.sizes = [...deviceSizes, ...iconSizes] + await promises.writeFile( path.join(distDir, IMAGES_MANIFEST), JSON.stringify({ version: 1, - images: config.images, + images, }), 'utf8' ) diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index d174521b7ad9ad5..3559fb5f955c5e6 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -991,7 +991,8 @@ export default async function getBaseWebpackConfig( config.experimental.scrollRestoration ), 'process.env.__NEXT_IMAGE_OPTS': JSON.stringify({ - sizes: config.images.sizes, + deviceSizes: config.images.deviceSizes, + iconSizes: config.images.iconSizes, path: config.images.path, loader: config.images.loader, ...(dev diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 457b2211d1869ef..d938348a338aa59 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -14,7 +14,8 @@ const loaders = new Map string>([ type LoaderKey = 'imgix' | 'cloudinary' | 'akamai' | 'default' type ImageData = { - sizes: number[] + deviceSizes: number[] + iconSizes: number[] loader: LoaderKey path: string domains?: string[] @@ -36,12 +37,15 @@ type ImageProps = Omit< const imageData: ImageData = process.env.__NEXT_IMAGE_OPTS as any const { - sizes: configSizes, + deviceSizes: configDeviceSizes, + iconSizes: configIconSizes, loader: configLoader, path: configPath, domains: configDomains, } = imageData -configSizes.sort((a, b) => a - b) // smallest to largest +// sort smallest to largest +configDeviceSizes.sort((a, b) => a - b) +configIconSizes.sort((a, b) => a - b) let cachedObserver: IntersectionObserver const IntersectionObserver = @@ -79,12 +83,16 @@ function getObserver(): IntersectionObserver | undefined { )) } -function getWidthsFromConfig(width: number | undefined) { +function getDeviceSizes(width: number | undefined): number[] { if (typeof width !== 'number') { - return configSizes + return configDeviceSizes + } + const smallest = configDeviceSizes[0] + if (width < smallest && configIconSizes.includes(width)) { + return [width] } const widths: number[] = [] - for (let size of configSizes) { + for (let size of configDeviceSizes) { widths.push(size) if (size >= width) { break @@ -102,7 +110,7 @@ function computeSrc( if (unoptimized) { return src } - const widths = getWidthsFromConfig(width) + const widths = getDeviceSizes(width) const largest = widths[widths.length - 1] return callLoader({ src, width: largest, quality }) } @@ -136,7 +144,8 @@ function generateSrcSet({ if (unoptimized) { return undefined } - return getWidthsFromConfig(width) + + return getDeviceSizes(width) .map((w) => `${callLoader({ src, width: w, quality })} ${w}w`) .join(', ') } diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index a489544e3b8ea76..1940286b801c18b 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -24,7 +24,8 @@ const defaultConfig: { [key: string]: any } = { poweredByHeader: true, compress: true, images: { - sizes: [320, 420, 768, 1024, 1200], + deviceSizes: [320, 420, 768, 1024, 1200], + iconSizes: [], domains: [], path: '/_next/image', loader: 'default', @@ -253,26 +254,53 @@ function assignDefaults(userConfig: { [key: string]: any }) { ) } } - if (images.sizes) { - if (!Array.isArray(images.sizes)) { + if (images.deviceSizes) { + const { deviceSizes } = images + if (!Array.isArray(deviceSizes)) { throw new Error( - `Specified images.sizes should be an Array received ${typeof images.sizes}` + `Specified images.deviceSizes should be an Array received ${typeof deviceSizes}` ) } - if (images.sizes.length > 50) { + if (deviceSizes.length > 25) { throw new Error( - `Specified images.sizes exceeds length of 50, received length (${images.sizes.length}), please reduce the length of the array to continue` + `Specified images.deviceSizes exceeds length of 25, received length (${deviceSizes.length}), please reduce the length of the array to continue` ) } - const invalid = images.sizes.filter((d: unknown) => { + const invalid = deviceSizes.filter((d: unknown) => { return typeof d !== 'number' || d < 1 || d > 10000 }) if (invalid.length > 0) { throw new Error( - `Specified images.sizes should be an Array of numbers that are between 1 and 10000, received invalid values (${invalid.join( + `Specified images.deviceSizes should be an Array of numbers that are between 1 and 10000, received invalid values (${invalid.join( + ', ' + )})` + ) + } + } + if (images.iconSizes) { + const { iconSizes } = images + if (!Array.isArray(iconSizes)) { + throw new Error( + `Specified images.iconSizes should be an Array received ${typeof iconSizes}` + ) + } + + if (iconSizes.length > 25) { + throw new Error( + `Specified images.iconSizes exceeds length of 25, received length (${iconSizes.length}), please reduce the length of the array to continue` + ) + } + + const invalid = iconSizes.filter((d: unknown) => { + return typeof d !== 'number' || d < 1 || d > 10000 + }) + + if (invalid.length > 0) { + throw new Error( + `Specified images.iconSizes should be an Array of numbers that are between 1 and 10000, received invalid values (${invalid.join( ', ' )})` ) diff --git a/packages/next/next-server/server/image-optimizer.ts b/packages/next/next-server/server/image-optimizer.ts index c0c2e023d7b01fc..d1825f7b2f5e6d1 100644 --- a/packages/next/next-server/server/image-optimizer.ts +++ b/packages/next/next-server/server/image-optimizer.ts @@ -23,6 +23,14 @@ const CACHE_VERSION = 1 const ANIMATABLE_TYPES = [WEBP, PNG, GIF] const VECTOR_TYPES = [SVG] +type ImageData = { + deviceSizes: number[] + iconSizes: number[] + loader: string + path: string + domains?: string[] +} + export async function imageOptimizer( server: Server, req: IncomingMessage, @@ -30,7 +38,9 @@ export async function imageOptimizer( parsedUrl: UrlWithParsedQuery ) { const { nextConfig, distDir } = server - const { sizes = [], domains = [], loader } = nextConfig?.images || {} + const imageData: ImageData = nextConfig.images + const { deviceSizes = [], iconSizes = [], domains = [], loader } = imageData + const sizes = [...deviceSizes, ...iconSizes] if (loader !== 'default') { await server.render404(req, res, parsedUrl) diff --git a/test/integration/image-component/basic/next.config.js b/test/integration/image-component/basic/next.config.js index 6adf797d1c2b1e5..617de0a68d69b42 100644 --- a/test/integration/image-component/basic/next.config.js +++ b/test/integration/image-component/basic/next.config.js @@ -1,6 +1,7 @@ module.exports = { images: { - sizes: [480, 1024, 1600, 2000], + deviceSizes: [480, 1024, 1600, 2000], + iconSizes: [16, 64], path: 'https://example.com/myaccount/', loader: 'imgix', }, diff --git a/test/integration/image-component/basic/pages/client-side.js b/test/integration/image-component/basic/pages/client-side.js index 99f97951f2bdbd4..ddd00fd2fe3e9d5 100644 --- a/test/integration/image-component/basic/pages/client-side.js +++ b/test/integration/image-component/basic/pages/client-side.js @@ -53,6 +53,20 @@ const ClientSide = () => { width={300} height={400} /> + + Errors diff --git a/test/integration/image-component/basic/pages/index.js b/test/integration/image-component/basic/pages/index.js index a930e0ef1ef8019..c08992abfb47602 100644 --- a/test/integration/image-component/basic/pages/index.js +++ b/test/integration/image-component/basic/pages/index.js @@ -70,6 +70,20 @@ const Page = () => { width={300} height={400} /> + + Client Side diff --git a/test/integration/image-component/basic/test/index.test.js b/test/integration/image-component/basic/test/index.test.js index 9e9198cdac42b0f..ff369bda69b8d1f 100644 --- a/test/integration/image-component/basic/test/index.test.js +++ b/test/integration/image-component/basic/test/index.test.js @@ -51,6 +51,20 @@ function runTests() { await browser.elementById('preceding-slash-image').getAttribute('srcset') ).toBe('https://example.com/myaccount/fooslash.jpg?auto=format&w=480 480w') }) + it('should use iconSizes when width matches, not deviceSizes from next.config.js', async () => { + expect(await browser.elementById('icon-image-16').getAttribute('src')).toBe( + 'https://example.com/myaccount/icon.png?auto=format&w=16' + ) + expect( + await browser.elementById('icon-image-16').getAttribute('srcset') + ).toBe('https://example.com/myaccount/icon.png?auto=format&w=16 16w') + expect(await browser.elementById('icon-image-64').getAttribute('src')).toBe( + 'https://example.com/myaccount/icon.png?auto=format&w=64' + ) + expect( + await browser.elementById('icon-image-64').getAttribute('srcset') + ).toBe('https://example.com/myaccount/icon.png?auto=format&w=64 64w') + }) it('should support the unoptimized attribute', async () => { expect( await browser.elementById('unoptimized-image').getAttribute('src') diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 8f457d326a6b15f..3428ffd359b08c8 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -348,12 +348,12 @@ describe('Image Optimizer', () => { ) }) - it('should error when sizes length exceeds 50', async () => { + it('should error when sizes length exceeds 25', async () => { await nextConfig.replace( '{ /* replaceme */ }', JSON.stringify({ images: { - sizes: new Array(51).fill(1024), + deviceSizes: new Array(51).fill(1024), }, }) ) @@ -369,16 +369,16 @@ describe('Image Optimizer', () => { await nextConfig.restore() expect(stderr).toContain( - 'Specified images.sizes exceeds length of 50, received length (51), please reduce the length of the array to continue' + 'Specified images.deviceSizes exceeds length of 25, received length (51), please reduce the length of the array to continue' ) }) - it('should error when sizes contains invalid sizes', async () => { + it('should error when deviceSizes contains invalid widths', async () => { await nextConfig.replace( '{ /* replaceme */ }', JSON.stringify({ images: { - sizes: [0, 12000, 64, 128, 256], + deviceSizes: [0, 12000, 64, 128, 256], }, }) ) @@ -394,7 +394,32 @@ describe('Image Optimizer', () => { await nextConfig.restore() expect(stderr).toContain( - 'Specified images.sizes should be an Array of numbers that are between 1 and 10000, received invalid values (0, 12000)' + 'Specified images.deviceSizes should be an Array of numbers that are between 1 and 10000, received invalid values (0, 12000)' + ) + }) + + it('should error when iconSizes contains invalid widths', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + iconSizes: [0, 16, 64, 12000], + }, + }) + ) + 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.iconSizes should be an Array of numbers that are between 1 and 10000, received invalid values (0, 12000)' ) }) }) @@ -421,7 +446,8 @@ describe('Image Optimizer', () => { beforeAll(async () => { const json = JSON.stringify({ images: { - sizes: [size, largeSize], + deviceSizes: [largeSize], + iconSizes: [size], domains, }, }) @@ -458,7 +484,7 @@ describe('Image Optimizer', () => { beforeAll(async () => { const json = JSON.stringify({ images: { - sizes: [size, largeSize], + deviceSizes: [size, largeSize], domains, }, }) @@ -482,7 +508,7 @@ describe('Image Optimizer', () => { const json = JSON.stringify({ target: 'experimental-serverless-trace', images: { - sizes: [size, largeSize], + deviceSizes: [size, largeSize], domains, }, })