diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index a38b40e1cafa099..457b2211d1869ef 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -42,7 +42,6 @@ const { domains: configDomains, } = imageData configSizes.sort((a, b) => a - b) // smallest to largest -const largestSize = configSizes[configSizes.length - 1] let cachedObserver: IntersectionObserver const IntersectionObserver = @@ -80,15 +79,32 @@ function getObserver(): IntersectionObserver | undefined { )) } +function getWidthsFromConfig(width: number | undefined) { + if (typeof width !== 'number') { + return configSizes + } + const widths: number[] = [] + for (let size of configSizes) { + widths.push(size) + if (size >= width) { + break + } + } + return widths +} + function computeSrc( src: string, unoptimized: boolean, + width: number | undefined, quality?: string ): string { if (unoptimized) { return src } - return callLoader({ src, width: largestSize, quality }) + const widths = getWidthsFromConfig(width) + const largest = widths[widths.length - 1] + return callLoader({ src, width: largest, quality }) } type CallLoaderProps = { @@ -104,29 +120,38 @@ function callLoader(loaderProps: CallLoaderProps) { type SrcSetData = { src: string - widths: number[] - quality?: string + unoptimized: boolean + width: number | undefined + quality: string | undefined } -function generateSrcSet({ src, widths, quality }: SrcSetData): string { +function generateSrcSet({ + src, + unoptimized, + 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, ' - return widths - .map((width: number) => `${callLoader({ src, width, quality })} ${width}w`) + if (unoptimized) { + return undefined + } + return getWidthsFromConfig(width) + .map((w) => `${callLoader({ src, width: w, quality })} ${w}w`) .join(', ') } type PreloadData = { src: string - widths: number[] + unoptimized: boolean + width: number | undefined sizes?: string - unoptimized?: boolean quality?: string } function generatePreload({ src, - widths, + width, unoptimized = false, sizes, quality, @@ -140,15 +165,25 @@ function generatePreload({ ) } +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, @@ -165,6 +200,13 @@ export default function Image({ const thisEl = useRef(null) 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, unsized } + )}` + ) + } 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( @@ -200,58 +242,23 @@ export default function Image({ } }, [thisEl, lazy]) - // Generate attribute values - const imgSrc = computeSrc(src, unoptimized, quality) - const imgSrcSet = !unoptimized - ? generateSrcSet({ - src, - widths: configSizes, - quality, - }) - : undefined - - let imgAttributes: - | { - src: string - srcSet?: string - } - | { - 'data-src': string - 'data-srcset'?: string - } - if (!lazy) { - imgAttributes = { - src: imgSrc, - } - if (imgSrcSet) { - imgAttributes.srcSet = imgSrcSet - } - } else { - imgAttributes = { - 'data-src': imgSrc, - } - if (imgSrcSet) { - imgAttributes['data-srcset'] = imgSrcSet - } - className = className ? className + ' __lazy' : '__lazy' - } - + let widthInt = getInt(width) + let heightInt = getInt(height) let divStyle: React.CSSProperties | undefined let imgStyle: React.CSSProperties | undefined let wrapperStyle: React.CSSProperties | undefined if ( - typeof height !== 'undefined' && - typeof width !== 'undefined' && + typeof widthInt !== 'undefined' && + typeof heightInt !== 'undefined' && !unsized ) { // // - const quotient = - parseInt(height as string, 10) / parseInt(width as string, 10) + const quotient = heightInt / widthInt const ratio = isNaN(quotient) ? 1 : quotient * 100 wrapperStyle = { maxWidth: '100%', - width, + width: widthInt, } divStyle = { position: 'relative', @@ -266,8 +273,8 @@ export default function Image({ width: '100%', } } else if ( - typeof height === 'undefined' && - typeof width === 'undefined' && + typeof widthInt === 'undefined' && + typeof heightInt === 'undefined' && unsized ) { // @@ -288,6 +295,41 @@ export default function Image({ } } + // Generate attribute values + const imgSrc = computeSrc(src, unoptimized, widthInt, quality) + const imgSrcSet = generateSrcSet({ + src, + width: widthInt, + unoptimized, + quality, + }) + + let imgAttributes: + | { + src: string + srcSet?: string + } + | { + 'data-src': string + 'data-srcset'?: string + } + if (!lazy) { + imgAttributes = { + src: imgSrc, + } + if (imgSrcSet) { + imgAttributes.srcSet = imgSrcSet + } + } else { + imgAttributes = { + 'data-src': imgSrc, + } + if (imgSrcSet) { + imgAttributes['data-srcset'] = imgSrcSet + } + className = className ? className + ' __lazy' : '__lazy' + } + // 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' @@ -298,7 +340,7 @@ export default function Image({ {shouldPreload ? generatePreload({ src, - widths: configSizes, + width: widthInt, unoptimized, sizes, quality, diff --git a/test/integration/image-component/basic/next.config.js b/test/integration/image-component/basic/next.config.js index 8f260dea043fdba..6adf797d1c2b1e5 100644 --- a/test/integration/image-component/basic/next.config.js +++ b/test/integration/image-component/basic/next.config.js @@ -1,6 +1,6 @@ module.exports = { images: { - sizes: [480, 1024, 1600], + sizes: [480, 1024, 1600, 2000], path: 'https://example.com/myaccount/', loader: 'imgix', }, diff --git a/test/integration/image-component/basic/pages/index.js b/test/integration/image-component/basic/pages/index.js index 0ad8d8e1ac445dd..a930e0ef1ef8019 100644 --- a/test/integration/image-component/basic/pages/index.js +++ b/test/integration/image-component/basic/pages/index.js @@ -19,7 +19,7 @@ const Page = () => { data-demo="demo-value" src="bar.jpg" loading="eager" - width={300} + width={1024} height={400} /> { width={300} height={400} /> - { id="lazy-top" src="foo1.jpg" height={400} - width={300} + width={1024} loading="lazy" >
@@ -35,7 +35,7 @@ const Lazy = () => { id="lazy-without-attribute" src="foo4.jpg" height={400} - width={300} + width={800} >
{ src="foo5.jpg" loading="eager" height={400} - width={300} + width={1900} > ) diff --git a/test/integration/image-component/basic/test/index.test.js b/test/integration/image-component/basic/test/index.test.js index bda3f67eea792e2..9e9198cdac42b0f 100644 --- a/test/integration/image-component/basic/test/index.test.js +++ b/test/integration/image-component/basic/test/index.test.js @@ -33,27 +33,23 @@ function runTests() { }) it('should modify src with the loader', async () => { expect(await browser.elementById('basic-image').getAttribute('src')).toBe( - 'https://example.com/myaccount/foo.jpg?auto=format&w=1600&q=60' + 'https://example.com/myaccount/foo.jpg?auto=format&w=480&q=60' ) }) it('should correctly generate src even if preceding slash is included in prop', async () => { expect( await browser.elementById('preceding-slash-image').getAttribute('src') - ).toBe('https://example.com/myaccount/fooslash.jpg?auto=format&w=1600') + ).toBe('https://example.com/myaccount/fooslash.jpg?auto=format&w=480') }) it('should add a srcset based on the loader', async () => { expect( await browser.elementById('basic-image').getAttribute('srcset') - ).toBe( - 'https://example.com/myaccount/foo.jpg?auto=format&w=480&q=60 480w, https://example.com/myaccount/foo.jpg?auto=format&w=1024&q=60 1024w, https://example.com/myaccount/foo.jpg?auto=format&w=1600&q=60 1600w' - ) + ).toBe('https://example.com/myaccount/foo.jpg?auto=format&w=480&q=60 480w') }) it('should add a srcset even with preceding slash in prop', async () => { expect( await browser.elementById('preceding-slash-image').getAttribute('srcset') - ).toBe( - 'https://example.com/myaccount/fooslash.jpg?auto=format&w=480 480w, https://example.com/myaccount/fooslash.jpg?auto=format&w=1024 1024w, https://example.com/myaccount/fooslash.jpg?auto=format&w=1600 1600w' - ) + ).toBe('https://example.com/myaccount/fooslash.jpg?auto=format&w=480 480w') }) it('should support the unoptimized attribute', async () => { expect( @@ -70,10 +66,10 @@ function runTests() { function lazyLoadingTests() { it('should have loaded the first image immediately', async () => { expect(await browser.elementById('lazy-top').getAttribute('src')).toBe( - 'https://example.com/myaccount/foo1.jpg?auto=format&w=1600' + 'https://example.com/myaccount/foo1.jpg?auto=format&w=1024' ) expect(await browser.elementById('lazy-top').getAttribute('srcset')).toBe( - 'https://example.com/myaccount/foo1.jpg?auto=format&w=480 480w, https://example.com/myaccount/foo1.jpg?auto=format&w=1024 1024w, https://example.com/myaccount/foo1.jpg?auto=format&w=1600 1600w' + 'https://example.com/myaccount/foo1.jpg?auto=format&w=480 480w, https://example.com/myaccount/foo1.jpg?auto=format&w=1024 1024w' ) }) it('should not have loaded the second image immediately', async () => { @@ -101,11 +97,11 @@ function lazyLoadingTests() { await check(() => { return browser.elementById('lazy-mid').getAttribute('src') - }, 'https://example.com/myaccount/foo2.jpg?auto=format&w=1600') + }, 'https://example.com/myaccount/foo2.jpg?auto=format&w=480') await check(() => { return browser.elementById('lazy-mid').getAttribute('srcset') - }, 'https://example.com/myaccount/foo2.jpg?auto=format&w=480 480w, https://example.com/myaccount/foo2.jpg?auto=format&w=1024 1024w, https://example.com/myaccount/foo2.jpg?auto=format&w=1600 1600w') + }, 'https://example.com/myaccount/foo2.jpg?auto=format&w=480 480w') }) it('should not have loaded the third image after scrolling down', async () => { expect( @@ -150,15 +146,17 @@ function lazyLoadingTests() { await waitFor(200) expect( await browser.elementById('lazy-without-attribute').getAttribute('src') - ).toBe('https://example.com/myaccount/foo4.jpg?auto=format&w=1600') + ).toBe('https://example.com/myaccount/foo4.jpg?auto=format&w=1024') expect( await browser.elementById('lazy-without-attribute').getAttribute('srcset') - ).toBeTruthy() + ).toBe( + 'https://example.com/myaccount/foo4.jpg?auto=format&w=480 480w, https://example.com/myaccount/foo4.jpg?auto=format&w=1024 1024w' + ) }) it('should load the fifth image eagerly, without scrolling', async () => { expect(await browser.elementById('eager-loading').getAttribute('src')).toBe( - 'https://example.com/myaccount/foo5.jpg?auto=format&w=1600' + 'https://example.com/myaccount/foo5.jpg?auto=format&w=2000' ) expect( await browser.elementById('eager-loading').getAttribute('srcset') @@ -198,14 +196,14 @@ describe('Image Component Tests', () => { it('should add a preload tag for a priority image', async () => { expect( await hasPreloadLinkMatchingUrl( - 'https://example.com/myaccount/withpriority.png?auto=format&w=1600' + 'https://example.com/myaccount/withpriority.png?auto=format&w=480&q=60' ) ).toBe(true) }) it('should add a preload tag for a priority image with preceding slash', async () => { expect( await hasPreloadLinkMatchingUrl( - 'https://example.com/myaccount/fooslash.jpg?auto=format&w=1600' + 'https://example.com/myaccount/fooslash.jpg?auto=format&w=480' ) ).toBe(true) }) @@ -219,7 +217,7 @@ describe('Image Component Tests', () => { it('should add a preload tag for a priority image, with quality', async () => { expect( await hasPreloadLinkMatchingUrl( - 'https://example.com/myaccount/withpriority.png?auto=format&w=1600&q=60' + 'https://example.com/myaccount/withpriority.png?auto=format&w=480&q=60' ) ).toBe(true) }) diff --git a/test/integration/image-component/default/test/index.test.js b/test/integration/image-component/default/test/index.test.js index 8af093b9240c4ec..c2c62f141278025 100644 --- a/test/integration/image-component/default/test/index.test.js +++ b/test/integration/image-component/default/test/index.test.js @@ -88,7 +88,7 @@ function runTests(mode) { await hasRedbox(browser) expect(await getRedboxHeader(browser)).toContain( - 'Next Image Optimization requires src to be provided. Make sure you pass them as props to the `next/image` component. Received: {"width":1200}' + 'Image is missing required "src" property. Make sure you pass "src" in props to the `next/image` component. Received: {"width":200}' ) })