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

Separate config into deviceSizes and iconSizes #18267

Merged
merged 13 commits into from Oct 26, 2020
20 changes: 15 additions & 5 deletions docs/basic-features/image-optimization.md
Expand Up @@ -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],
},
}
```
Expand Down Expand Up @@ -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:
Expand All @@ -107,4 +118,3 @@ For more information on what to do next, we recommend the following sections:
<small>See all available properties for the Image component</small>
</a>
</div>

6 changes: 5 additions & 1 deletion packages/next/build/index.ts
Expand Up @@ -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'
)
Expand Down
3 changes: 2 additions & 1 deletion packages/next/build/webpack-config.ts
Expand Up @@ -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
Expand Down
25 changes: 17 additions & 8 deletions packages/next/client/image.tsx
Expand Up @@ -14,7 +14,8 @@ const loaders = new Map<LoaderKey, (props: LoaderProps) => string>([
type LoaderKey = 'imgix' | 'cloudinary' | 'akamai' | 'default'

type ImageData = {
sizes: number[]
deviceSizes: number[]
iconSizes: number[]
loader: LoaderKey
path: string
domains?: string[]
Expand All @@ -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 =
Expand Down Expand Up @@ -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
Expand All @@ -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 })
}
Expand Down Expand Up @@ -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(', ')
}
Expand Down
44 changes: 36 additions & 8 deletions packages/next/next-server/server/config.ts
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
', '
)})`
)
Expand Down
12 changes: 11 additions & 1 deletion packages/next/next-server/server/image-optimizer.ts
Expand Up @@ -23,14 +23,24 @@ 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,
res: ServerResponse,
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)
Expand Down
3 changes: 2 additions & 1 deletion 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',
},
Expand Down
14 changes: 14 additions & 0 deletions test/integration/image-component/basic/pages/client-side.js
Expand Up @@ -53,6 +53,20 @@ const ClientSide = () => {
width={300}
height={400}
/>
<Image
id="icon-image-64"
src="/icon.png"
loading="eager"
width={64}
height={64}
/>
<Image
id="icon-image-16"
src="/icon.png"
loading="eager"
width={16}
height={16}
/>
<Link href="/errors">
<a id="errorslink">Errors</a>
</Link>
Expand Down
14 changes: 14 additions & 0 deletions test/integration/image-component/basic/pages/index.js
Expand Up @@ -70,6 +70,20 @@ const Page = () => {
width={300}
height={400}
/>
<Image
id="icon-image-64"
src="/icon.png"
loading="eager"
width={64}
height={64}
/>
<Image
id="icon-image-16"
src="/icon.png"
loading="eager"
width={16}
height={16}
/>
<Link href="/client-side">
<a id="clientlink">Client Side</a>
</Link>
Expand Down
14 changes: 14 additions & 0 deletions test/integration/image-component/basic/test/index.test.js
Expand Up @@ -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')
Expand Down