rsnext/packages/next/client/image.tsx
Joe Haddad c8fa284854
Control <Image /> prefetching with React (#18904)
This pull request fixes `<Image />` not updating when new props are passed by removing external DOM mutations and relying on React to do it instead.

As an added bonus, I've extracted the intersection observer from both the `<Image />` and `<Link />` component, as their instance can be shared!

The increase in size is minor (+3B), and actually a decrease for apps using both `<Image />` and `<Link />`.

---

Fixes #18698
Fixes #18369
2020-11-06 23:03:15 +00:00

543 lines
14 KiB
TypeScript

import React, { ReactElement } from 'react'
import Head from '../next-server/lib/head'
import { useIntersection } from './use-intersection'
const VALID_LOADING_VALUES = ['lazy', 'eager', undefined] as const
type LoadingValue = typeof VALID_LOADING_VALUES[number]
const loaders = new Map<LoaderKey, (props: LoaderProps) => string>([
['imgix', imgixLoader],
['cloudinary', cloudinaryLoader],
['akamai', akamaiLoader],
['default', defaultLoader],
])
type LoaderKey = 'imgix' | 'cloudinary' | 'akamai' | 'default'
const VALID_LAYOUT_VALUES = [
'fill',
'fixed',
'intrinsic',
'responsive',
undefined,
] as const
type LayoutValue = typeof VALID_LAYOUT_VALUES[number]
type ImageData = {
deviceSizes: number[]
imageSizes: number[]
loader: LoaderKey
path: string
domains?: string[]
}
type ImgElementStyle = NonNullable<JSX.IntrinsicElements['img']['style']>
type ImageProps = Omit<
JSX.IntrinsicElements['img'],
'src' | 'srcSet' | 'ref' | 'width' | 'height' | 'loading' | 'style'
> & {
src: string
quality?: number | string
priority?: boolean
loading?: LoadingValue
unoptimized?: boolean
objectFit?: ImgElementStyle['objectFit']
objectPosition?: ImgElementStyle['objectPosition']
} & (
| {
width?: never
height?: never
/** @deprecated Use `layout="fill"` instead */
unsized: true
}
| { width?: never; height?: never; layout: 'fill' }
| {
width: number | string
height: number | string
layout?: Exclude<LayoutValue, 'fill'>
}
)
const imageData: ImageData = process.env.__NEXT_IMAGE_OPTS as any
const {
deviceSizes: configDeviceSizes,
imageSizes: configImageSizes,
loader: configLoader,
path: configPath,
domains: configDomains,
} = imageData
// sort smallest to largest
const allSizes = [...configDeviceSizes, ...configImageSizes]
configDeviceSizes.sort((a, b) => a - b)
allSizes.sort((a, b) => a - b)
function getSizes(
width: number | undefined,
layout: LayoutValue
): { sizes: number[]; kind: 'w' | 'x' } {
if (
typeof width !== 'number' ||
layout === 'fill' ||
layout === 'responsive'
) {
return { sizes: configDeviceSizes, kind: 'w' }
}
const sizes = [
...new Set(
[width, width * 2, width * 3].map(
(w) => allSizes.find((p) => p >= w) || allSizes[allSizes.length - 1]
)
),
]
return { sizes, kind: 'x' }
}
function computeSrc(
src: string,
unoptimized: boolean,
layout: LayoutValue,
width?: number,
quality?: number
): string {
if (unoptimized) {
return src
}
const { sizes } = getSizes(width, layout)
const largest = sizes[sizes.length - 1]
return callLoader({ src, width: largest, quality })
}
type CallLoaderProps = {
src: string
width: number
quality?: number
}
function callLoader(loaderProps: CallLoaderProps) {
const load = loaders.get(configLoader) || defaultLoader
return load({ root: configPath, ...loaderProps })
}
type SrcSetData = {
src: string
unoptimized: boolean
layout: LayoutValue
width?: number
quality?: number
}
function generateSrcSet({
src,
unoptimized,
layout,
width,
quality,
}: SrcSetData): string | undefined {
// At each breakpoint, generate an image url using the loader, such as:
// ' www.example.com/foo.jpg?w=480 480w, '
if (unoptimized) {
return undefined
}
const { sizes, kind } = getSizes(width, layout)
return sizes
.map(
(size, i) =>
`${callLoader({ src, width: size, quality })} ${
kind === 'w' ? size : i + 1
}${kind}`
)
.join(', ')
}
type PreloadData = {
src: string
unoptimized: boolean
layout: LayoutValue
width: number | undefined
sizes?: string
quality?: number
}
function generatePreload({
src,
unoptimized = false,
layout,
width,
sizes,
quality,
}: PreloadData): ReactElement {
// This function generates an image preload that makes use of the "imagesrcset" and "imagesizes"
// attributes for preloading responsive images. They're still experimental, but fully backward
// compatible, as the link tag includes all necessary attributes, even if the final two are ignored.
// See: https://web.dev/preload-responsive-images/
return (
<Head>
<link
rel="preload"
as="image"
href={computeSrc(src, unoptimized, layout, width, quality)}
// @ts-ignore: imagesrcset and imagesizes not yet in the link element type
imagesrcset={generateSrcSet({
src,
unoptimized,
layout,
width,
quality,
})}
imagesizes={sizes}
/>
</Head>
)
}
function getInt(x: unknown): number | undefined {
if (typeof x === 'number') {
return x
}
if (typeof x === 'string') {
return parseInt(x, 10)
}
return undefined
}
export default function Image({
src,
sizes,
unoptimized = false,
priority = false,
loading,
className,
quality,
width,
height,
objectFit,
objectPosition,
...all
}: ImageProps) {
let rest: Partial<ImageProps> = all
let layout: NonNullable<LayoutValue> = sizes ? 'responsive' : 'intrinsic'
let unsized = false
if ('unsized' in rest) {
unsized = Boolean(rest.unsized)
// Remove property so it's not spread into image:
delete rest['unsized']
} else if ('layout' in rest) {
// Override default layout if the user specified one:
if (rest.layout) layout = rest.layout
// Remove property so it's not spread into image:
delete rest['layout']
}
if (process.env.NODE_ENV !== 'production') {
if (!src) {
throw new Error(
`Image is missing required "src" property. Make sure you pass "src" in props to the \`next/image\` component. Received: ${JSON.stringify(
{ width, height, quality }
)}`
)
}
if (!VALID_LAYOUT_VALUES.includes(layout)) {
throw new Error(
`Image with src "${src}" has invalid "layout" property. Provided "${layout}" should be one of ${VALID_LAYOUT_VALUES.map(
String
).join(',')}.`
)
}
if (!VALID_LOADING_VALUES.includes(loading)) {
throw new Error(
`Image with src "${src}" has invalid "loading" property. Provided "${loading}" should be one of ${VALID_LOADING_VALUES.map(
String
).join(',')}.`
)
}
if (priority && loading === 'lazy') {
throw new Error(
`Image with src "${src}" has both "priority" and "loading='lazy'" properties. Only one should be used.`
)
}
if (unsized) {
throw new Error(
`Image with src "${src}" has deprecated "unsized" property, which was removed in favor of the "layout='fill'" property`
)
}
}
let isLazy =
!priority && (loading === 'lazy' || typeof loading === 'undefined')
if (src && src.startsWith('data:')) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
unoptimized = true
isLazy = false
}
const [setRef, isIntersected] = useIntersection<HTMLImageElement>({
rootMargin: '200px',
disabled: !isLazy,
})
const isVisible = !isLazy || isIntersected
const widthInt = getInt(width)
const heightInt = getInt(height)
const qualityInt = getInt(quality)
let wrapperStyle: JSX.IntrinsicElements['div']['style'] | undefined
let sizerStyle: JSX.IntrinsicElements['div']['style'] | undefined
let sizerSvg: string | undefined
let imgStyle: ImgElementStyle | undefined = {
visibility: isVisible ? 'visible' : 'hidden',
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
boxSizing: 'border-box',
padding: 0,
border: 'none',
margin: 'auto',
display: 'block',
width: 0,
height: 0,
minWidth: '100%',
maxWidth: '100%',
minHeight: '100%',
maxHeight: '100%',
objectFit,
objectPosition,
}
if (
typeof widthInt !== 'undefined' &&
typeof heightInt !== 'undefined' &&
layout !== 'fill'
) {
// <Image src="i.png" width="100" height="100" />
const quotient = heightInt / widthInt
const paddingTop = isNaN(quotient) ? '100%' : `${quotient * 100}%`
if (layout === 'responsive') {
// <Image src="i.png" width="100" height="100" layout="responsive" />
wrapperStyle = {
display: 'block',
overflow: 'hidden',
position: 'relative',
boxSizing: 'border-box',
margin: 0,
}
sizerStyle = { display: 'block', boxSizing: 'border-box', paddingTop }
} else if (layout === 'intrinsic') {
// <Image src="i.png" width="100" height="100" layout="intrinsic" />
wrapperStyle = {
display: 'inline-block',
maxWidth: '100%',
overflow: 'hidden',
position: 'relative',
boxSizing: 'border-box',
margin: 0,
}
sizerStyle = {
boxSizing: 'border-box',
display: 'block',
maxWidth: '100%',
}
sizerSvg = `<svg width="${widthInt}" height="${heightInt}" xmlns="http://www.w3.org/2000/svg" version="1.1"/>`
} else if (layout === 'fixed') {
// <Image src="i.png" width="100" height="100" layout="fixed" />
wrapperStyle = {
overflow: 'hidden',
boxSizing: 'border-box',
display: 'inline-block',
position: 'relative',
width: widthInt,
height: heightInt,
}
}
} else if (
typeof widthInt === 'undefined' &&
typeof heightInt === 'undefined' &&
layout === 'fill'
) {
// <Image src="i.png" layout="fill" />
wrapperStyle = {
display: 'block',
overflow: 'hidden',
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
boxSizing: 'border-box',
margin: 0,
}
} else {
// <Image src="i.png" />
if (process.env.NODE_ENV !== 'production') {
throw new Error(
`Image with src "${src}" must use "width" and "height" properties or "layout='fill'" property.`
)
}
}
// Generate attribute values
const imgSrc = computeSrc(src, unoptimized, layout, widthInt, qualityInt)
const imgSrcSet = generateSrcSet({
src,
unoptimized,
layout,
width: widthInt,
quality: qualityInt,
})
let imgAttributes:
| Pick<JSX.IntrinsicElements['img'], 'src' | 'srcSet'>
| undefined
if (isVisible) {
imgAttributes = {
src: imgSrc,
}
if (imgSrcSet) {
imgAttributes.srcSet = imgSrcSet
}
}
// No need to add preloads on the client side--by the time the application is hydrated,
// it's too late for preloads
const shouldPreload = priority && typeof window === 'undefined'
if (unsized) {
wrapperStyle = undefined
sizerStyle = undefined
imgStyle = undefined
}
return (
<div style={wrapperStyle}>
{shouldPreload
? generatePreload({
src,
layout,
unoptimized,
width: widthInt,
sizes,
quality: qualityInt,
})
: null}
{sizerStyle ? (
<div style={sizerStyle}>
{sizerSvg ? (
<img
style={{ maxWidth: '100%', display: 'block' }}
alt=""
aria-hidden={true}
role="presentation"
src={`data:image/svg+xml;charset=utf-8,${sizerSvg}`}
/>
) : null}
</div>
) : null}
<img
{...rest}
{...imgAttributes}
decoding="async"
className={className}
sizes={sizes}
ref={setRef}
style={imgStyle}
/>
</div>
)
}
//BUILT IN LOADERS
type LoaderProps = CallLoaderProps & { root: string }
function normalizeSrc(src: string) {
return src[0] === '/' ? src.slice(1) : src
}
function imgixLoader({ root, src, width, quality }: LoaderProps): string {
// Demo: https://static.imgix.net/daisy.png?format=auto&fit=max&w=300
const params = ['auto=format', 'fit=max', 'w=' + width]
let paramsString = ''
if (quality) {
params.push('q=' + quality)
}
if (params.length) {
paramsString = '?' + params.join('&')
}
return `${root}${normalizeSrc(src)}${paramsString}`
}
function akamaiLoader({ root, src, width }: LoaderProps): string {
return `${root}${normalizeSrc(src)}?imwidth=${width}`
}
function cloudinaryLoader({ root, src, width, quality }: LoaderProps): string {
// Demo: https://res.cloudinary.com/demo/image/upload/w_300,c_limit/turtles.jpg
const params = ['f_auto', 'c_limit', 'w_' + width]
let paramsString = ''
if (quality) {
params.push('q_' + quality)
}
if (params.length) {
paramsString = params.join(',') + '/'
}
return `${root}${paramsString}${normalizeSrc(src)}`
}
function defaultLoader({ root, src, width, quality }: LoaderProps): string {
if (process.env.NODE_ENV !== 'production') {
const missingValues = []
// these should always be provided but make sure they are
if (!src) missingValues.push('src')
if (!width) missingValues.push('width')
if (missingValues.length > 0) {
throw new Error(
`Next Image Optimization requires ${missingValues.join(
', '
)} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify(
{ src, width, quality }
)}`
)
}
if (src.startsWith('//')) {
throw new Error(
`Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)`
)
}
if (!src.startsWith('/') && configDomains) {
let parsedSrc: URL
try {
parsedSrc = new URL(src)
} catch (err) {
console.error(err)
throw new Error(
`Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)`
)
}
if (!configDomains.includes(parsedSrc.hostname)) {
throw new Error(
`Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` +
`See more info: https://err.sh/next.js/next-image-unconfigured-host`
)
}
}
}
return `${root}?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}`
}