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

Support picture tag in next/image #25683

Closed
wants to merge 9 commits into from
17 changes: 17 additions & 0 deletions docs/basic-features/image-optimization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [`<picture>` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture) with a `<source>` 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 `<picture>` tag, it will fall back to an `<img>` 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:
Expand Down
1 change: 1 addition & 0 deletions packages/next/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
100 changes: 80 additions & 20 deletions packages/next/client/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
imageConfigDefault,
LoaderValue,
VALID_LOADERS,
ImageFormat,
VALID_FORMATS,
} from '../next-server/server/image-config'
import { useIntersection } from './use-intersection'

Expand All @@ -22,6 +24,7 @@ export type ImageLoaderProps = {
src: string
width: number
quality?: number
format?: ImageFormat
}

type DefaultImageLoaderProps = ImageLoaderProps & { root: string }
Expand Down Expand Up @@ -128,13 +131,19 @@ const {
loader: configLoader,
path: configPath,
domains: configDomains,
formats: configFormats,
} =
((process.env.__NEXT_IMAGE_OPTS as any) as ImageConfig) || imageConfigDefault
// sort smallest to largest
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,
Expand Down Expand Up @@ -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({
Expand All @@ -208,6 +219,7 @@ function generateImgAttrs({
quality,
sizes,
loader,
format = 'auto',
}: GenImgAttrsData): GenImgAttrsResult {
if (unoptimized) {
return { src, srcSet: undefined, sizes: undefined }
Expand All @@ -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}`
)
Expand All @@ -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 }),
}
}

Expand Down Expand Up @@ -524,6 +537,8 @@ export default function Image({
}
}

const usePictureTag = !unoptimized && imageFormats.length

let imgAttributes: GenImgAttrsResult = {
src:
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
Expand All @@ -543,6 +558,20 @@ export default function Image({
})
}

const img = (
<img
{...rest}
{...imgAttributes}
decoding="async"
className={className}
ref={(element) => {
setRef(element)
removePlaceholder(element, placeholder)
}}
style={imgStyle}
/>
)

return (
<div style={wrapperStyle}>
{sizerStyle ? (
Expand Down Expand Up @@ -583,23 +612,39 @@ export default function Image({
/>
</noscript>
)}
<img
{...rest}
{...imgAttributes}
decoding="async"
className={className}
ref={(element) => {
setRef(element)
removePlaceholder(element, placeholder)
}}
style={imgStyle}
/>
{priority ? (
{usePictureTag ? (
<picture>
{isVisible &&
imageFormats.map((format) => (
<source
key={format}
{...generateImgAttrs({
// Type narrowing seems to be failing here
src: src as string,
unoptimized,
layout,
width: widthInt,
quality: qualityInt,
sizes,
loader,
format,
})}
/>
))}
{img}
</picture>
) : (
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
<Head>
<link
key={
Expand Down Expand Up @@ -633,14 +678,20 @@ function imgixLoader({
src,
width,
quality,
format = 'auto',
}: DefaultImageLoaderProps): string {
// Demo: https://static.imgix.net/daisy.png?format=auto&fit=max&w=300
const params = ['auto=format', 'fit=max', 'w=' + width]
let paramsString = ''
// Demo: https://static.imgix.net/daisy.png?auto=format&fit=max&w=300
const params = []
if (format === 'auto') {
params.push('auto=format')
} else {
params.push('fm=' + format)
}
params.push('fit=max', 'w=' + width)
if (quality) {
params.push('q=' + quality)
}

let paramsString = ''
if (params.length) {
paramsString = '?' + params.join('&')
}
Expand All @@ -656,9 +707,15 @@ function cloudinaryLoader({
src,
width,
quality,
format = 'auto',
}: DefaultImageLoaderProps): string {
// Demo: https://res.cloudinary.com/demo/image/upload/w_300,c_limit,q_auto/turtles.jpg
const params = ['f_auto', 'c_limit', 'w_' + width, 'q_' + (quality || 'auto')]
const params = [
'f_' + format,
'c_limit',
'w_' + width,
'q_' + (quality || 'auto'),
]
let paramsString = params.join(',') + '/'
return `${root}${paramsString}${normalizeSrc(src)}`
}
Expand All @@ -668,6 +725,7 @@ function defaultLoader({
src,
width,
quality,
format = 'auto',
}: DefaultImageLoaderProps): string {
if (process.env.NODE_ENV !== 'production') {
const missingValues = []
Expand Down Expand Up @@ -712,5 +770,7 @@ function defaultLoader({
}
}

return `${root}?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}`
return `${root}?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}${
format && format !== 'auto' ? `&f=${format}` : ''
}`
}
6 changes: 6 additions & 0 deletions packages/next/next-server/server/image-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ export const VALID_LOADERS = [

export type LoaderValue = typeof VALID_LOADERS[number]

export const VALID_FORMATS = ['webp', 'avif', 'auto'] as const

export type ImageFormat = typeof VALID_FORMATS[number]

export type ImageConfig = {
deviceSizes: number[]
imageSizes: number[]
loader: LoaderValue
path: string
domains?: string[]
formats?: ImageFormat[]
disableStaticImages: boolean
}

Expand All @@ -22,5 +27,6 @@ export const imageConfigDefault: ImageConfig = {
path: '/_next/image',
loader: 'default',
domains: [],
formats: [],
disableStaticImages: false,
}
11 changes: 9 additions & 2 deletions packages/next/next-server/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export async function imageOptimizer(
}

const { headers } = req
const { url, w, q } = parsedUrl.query
const mimeType = getSupportedMimeType(MODERN_TYPES, headers.accept)
const { url, w, q, f } = parsedUrl.query
let mimeType = getSupportedMimeType(MODERN_TYPES, headers.accept)
let href: string

if (!url) {
Expand Down Expand Up @@ -111,6 +111,13 @@ export async function imageOptimizer(
return { finished: true }
}

if (
f &&
!Array.isArray(f) &&
MODERN_TYPES.includes(`image/${f.toLowerCase()}`)
) {
mimeType = `image/${f.toLowerCase()}`
}
// Should match output from next-image-loader
const isStatic = url.startsWith('/_next/static/image')

Expand Down
5 changes: 5 additions & 0 deletions test/integration/image-component/picture/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
images: {
formats: ['webp'],
},
}
31 changes: 31 additions & 0 deletions test/integration/image-component/picture/pages/client-side.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react'
import Image from 'next/image'

const Page = () => {
return (
<div>
<p id="client-side">Image Client Side Test</p>
<div id="basic-image-wrapper">
<Image
id="basic-image"
src="/foo.jpg"
loading="eager"
width={300}
height={400}
quality={60}
/>
</div>
<div id="unoptimized-image-wrapper">
<Image
unoptimized
src="https://arbitraryurl.com/foo.jpg"
loading="eager"
width={300}
height={400}
/>
</div>
</div>
)
}

export default Page
35 changes: 35 additions & 0 deletions test/integration/image-component/picture/pages/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react'
import Image from 'next/image'
import Link from 'next/link'

const Page = () => {
return (
<div>
<p id="ssr">Image SSR Test</p>
<div id="basic-image-wrapper">
<Image
id="basic-image"
src="/foo.jpg"
loading="eager"
width={300}
height={400}
quality={60}
/>
</div>
<div id="unoptimized-image-wrapper">
<Image
unoptimized
src="https://arbitraryurl.com/foo.jpg"
loading="eager"
width={300}
height={400}
/>
</div>
<Link href="/client-side">
<a id="clientlink">Client Side</a>
</Link>
</div>
)
}

export default Page
Loading