diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 6a231114fc8312f..fcde56b2d4b3c3c 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -6,6 +6,7 @@ import React, { useCallback, useContext, useMemo, + useState, forwardRef, } from 'react' import Head from '../shared/lib/head' @@ -148,6 +149,8 @@ type ImageElementProps = Omit & { placeholder: PlaceholderValue onLoadRef: React.MutableRefObject onLoadingCompleteRef: React.MutableRefObject + setBlurComplete: (b: boolean) => void + setShowAltText: (b: boolean) => void } function getWidths( @@ -261,8 +264,10 @@ function getInt(x: unknown): number | undefined { function handleLoading( img: ImgElementWithDataProp, src: string, + placeholder: PlaceholderValue, onLoadRef: React.MutableRefObject, onLoadingCompleteRef: React.MutableRefObject, + setBlurComplete: (b: boolean) => void, unoptimized: boolean ) { if (!img || img['data-loaded-src'] === src) { @@ -279,6 +284,9 @@ function handleLoading( // - decode() completes return } + if (placeholder === 'blur') { + setBlurComplete(true) + } if (onLoadRef?.current) { // Since we don't have the SyntheticEvent here, // we must create one with the same shape. @@ -375,6 +383,8 @@ const ImageElement = forwardRef( loader, onLoadRef, onLoadingCompleteRef, + setBlurComplete, + setShowAltText, onLoad, onError, ...rest @@ -431,16 +441,20 @@ const ImageElement = forwardRef( handleLoading( img, srcString, + placeholder, onLoadRef, onLoadingCompleteRef, + setBlurComplete, unoptimized ) } }, [ srcString, + placeholder, onLoadRef, onLoadingCompleteRef, + setBlurComplete, onError, unoptimized, forwardedRef, @@ -451,30 +465,20 @@ const ImageElement = forwardRef( handleLoading( img, srcString, + placeholder, onLoadRef, onLoadingCompleteRef, + setBlurComplete, unoptimized ) }} onError={(event) => { - // Note: We removed React.useState() in the error case here - // because it was causing Safari to become very slow when - // there were many images on the same page. - const { style } = event.currentTarget - - if (style.color === 'transparent') { - // If src image fails to load, this will ensure "alt" is visible - style.color = '' - } - - if (placeholder === 'blur' && style.backgroundImage) { - // If src image fails to load, this will ensure the placeholder is removed - style.backgroundSize = '' - style.backgroundPosition = '' - style.backgroundRepeat = '' - style.backgroundImage = '' + // if the real image fails to load, this will ensure "alt" is visible + setShowAltText(true) + if (placeholder === 'blur') { + // If the real image fails to load, this will still remove the placeholder. + setBlurComplete(true) } - if (onError) { onError(event) } @@ -622,6 +626,9 @@ const Image = forwardRef( unoptimized = true } + const [blurComplete, setBlurComplete] = useState(false) + const [showAltText, setShowAltText] = useState(false) + const qualityInt = getInt(quality) if (process.env.NODE_ENV !== 'production') { @@ -803,12 +810,12 @@ const Image = forwardRef( objectPosition, } : {}, - { color: 'transparent' }, + showAltText ? {} : { color: 'transparent' }, style ) const blurStyle = - placeholder === 'blur' && blurDataURL + placeholder === 'blur' && blurDataURL && !blurComplete ? { backgroundSize: imgStyle.objectFit || 'cover', backgroundPosition: imgStyle.objectPosition || '50% 50%', @@ -898,6 +905,8 @@ const Image = forwardRef( srcString, onLoadRef, onLoadingCompleteRef, + setBlurComplete, + setShowAltText, ...rest, } return ( diff --git a/test/integration/next-image-new/default/pages/blurry-placeholder.js b/test/integration/next-image-new/default/pages/blurry-placeholder.js new file mode 100644 index 000000000000000..30583ad0a20a2ce --- /dev/null +++ b/test/integration/next-image-new/default/pages/blurry-placeholder.js @@ -0,0 +1,31 @@ +import React from 'react' +import Image from 'next/image' + +export default function Page() { + return ( +
+

Blurry Placeholder

+ + + +
+ + +
+ ) +} diff --git a/test/integration/next-image-new/default/test/index.test.ts b/test/integration/next-image-new/default/test/index.test.ts index 9e4ff54778a7cc2..65892992a95c015 100644 --- a/test/integration/next-image-new/default/test/index.test.ts +++ b/test/integration/next-image-new/default/test/index.test.ts @@ -599,8 +599,8 @@ function runTests(mode) { 'lazy' ) expect(await browser.elementById('blur1').getAttribute('sizes')).toBeNull() - expect(await browser.elementById('blur1').getAttribute('style')).toMatch( - 'color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;' + expect(await browser.elementById('blur1').getAttribute('style')).toBe( + 'color: transparent;' ) expect(await browser.elementById('blur1').getAttribute('height')).toBe( '400' @@ -646,8 +646,8 @@ function runTests(mode) { expect(await browser.elementById('blur2').getAttribute('loading')).toBe( 'lazy' ) - expect(await browser.elementById('blur2').getAttribute('style')).toMatch( - 'color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat' + expect(await browser.elementById('blur2').getAttribute('style')).toBe( + 'color: transparent;' ) expect(await browser.elementById('blur2').getAttribute('height')).toBe( '400' @@ -1084,6 +1084,15 @@ function runTests(mode) { expect(await getComputedStyle(browser, 'img-blur', 'filter')).toBe( 'opacity(0.5)' ) + expect(await getComputedStyle(browser, 'img-blur', 'background-size')).toBe( + '30%' + ) + expect( + await getComputedStyle(browser, 'img-blur', 'background-image') + ).toMatch('iVBORw0KGgo=') + expect( + await getComputedStyle(browser, 'img-blur', 'background-position') + ).toBe('1px 2px') }) describe('Fill-mode tests', () => { let browser @@ -1178,6 +1187,55 @@ function runTests(mode) { }) } + it('should have blurry placeholder when enabled', async () => { + const html = await renderViaHTTP(appPort, '/blurry-placeholder') + const $html = cheerio.load(html) + + $html('noscript > img').attr('id', 'unused') + + expect($html('#blurry-placeholder-raw')[0].attribs.style).toContain( + `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 400 400'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage preserveAspectRatio='none' filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8P4nhDwAGuAKPn6cicwAAAABJRU5ErkJggg=='/%3E%3C/svg%3E")` + ) + + expect($html('#blurry-placeholder-with-lazy')[0].attribs.style).toContain( + `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 400 400'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage preserveAspectRatio='none' filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO0/8/wBwAE/wI85bEJ6gAAAABJRU5ErkJggg=='/%3E%3C/svg%3E")` + ) + }) + + it('should remove blurry placeholder after image loads', async () => { + const browser = await webdriver(appPort, '/blurry-placeholder') + await check( + async () => + await getComputedStyle( + browser, + 'blurry-placeholder-raw', + 'background-image' + ), + 'none' + ) + expect( + await getComputedStyle( + browser, + 'blurry-placeholder-with-lazy', + 'background-image' + ) + ).toBe( + `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 400 400'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage preserveAspectRatio='none' filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO0/8/wBwAE/wI85bEJ6gAAAABJRU5ErkJggg=='/%3E%3C/svg%3E")` + ) + + await browser.eval('document.getElementById("spacer").remove()') + + await check( + async () => + await getComputedStyle( + browser, + 'blurry-placeholder-with-lazy', + 'background-image' + ), + 'none' + ) + }) + it('should be valid HTML', async () => { let browser try { diff --git a/test/integration/production/test/index.test.js b/test/integration/production/test/index.test.js index c47d51ea0614bc2..2650bd523cabb2b 100644 --- a/test/integration/production/test/index.test.js +++ b/test/integration/production/test/index.test.js @@ -1301,7 +1301,7 @@ describe('Production Usage', () => { }) } - it('should loading next/image correctly', async () => { + it('should remove placeholder for next/image correctly', async () => { const browser = await webdriver(context.appPort, '/') await browser.eval(`(function() { @@ -1312,16 +1312,10 @@ describe('Production Usage', () => { expect(await browser.eval('window.beforeNav')).toBe(1) - await check(async () => { - const result = await browser.eval( - `document.getElementById('static-image').width` - ) - if (result === 0) { - throw new Error('Incorrectly loaded image') - } - - return 'result-correct' - }, /result-correct/) + await check( + () => browser.elementByCss('img').getComputedCss('background-image'), + 'none' + ) await browser.eval(`(function() { window.beforeNav = 1 @@ -1338,16 +1332,22 @@ describe('Production Usage', () => { expect(await browser.eval('window.beforeNav')).toBe(1) - await check(async () => { - const result = await browser.eval( - `document.getElementById('static-image').width` - ) - if (result === 0) { - throw new Error('Incorrectly loaded image') - } + await check( + () => + browser + .elementByCss('#static-image') + .getComputedCss('background-image'), + 'none' + ) - return 'result-correct' - }, /result-correct/) + for (let i = 0; i < 5; i++) { + expect( + await browser + .elementByCss('#static-image') + .getComputedCss('background-image') + ).toBe('none') + await waitFor(500) + } }) dynamicImportTests(context, (p, q) => renderViaHTTP(context.appPort, p, q))