diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index f7320edbb97a50c..b3cb2726751f998 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -16,6 +16,7 @@ description: Enable Image Optimization with the built-in Image component. | Version | Changes | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `v13.0.6` | `ref` prop added. | | `v13.0.0` | `` wrapper removed. `layout`, `objectFit`, `objectPosition`, `lazyBoundary`, `lazyRoot` props removed. `alt` is required. `onLoadingComplete` receives reference to `img` element. Built-in loader config removed. | | `v12.3.0` | `remotePatterns` and `unoptimized` configuration is stable. | | `v12.2.0` | Experimental `remotePatterns` and experimental `unoptimized` configuration added. `layout="raw"` removed. | @@ -284,7 +285,6 @@ Other properties on the `` component will be passed to the underlying `img` element with the exception of the following: - `srcSet`. Use [Device Sizes](#device-sizes) instead. -- `ref`. Use [`onLoadingComplete`](#onloadingcomplete) instead. - `decoding`. It is always `"async"`. ## Configuration Options diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 80c0df3bf63a5a8..fcde56b2d4b3c3c 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -7,6 +7,7 @@ import React, { useContext, useMemo, useState, + forwardRef, } from 'react' import Head from '../shared/lib/head' import { getImageBlurSvg } from '../shared/lib/image-blur-svg' @@ -362,332 +363,353 @@ function handleLoading( }) } -const ImageElement = ({ - imgAttributes, - heightInt, - widthInt, - qualityInt, - className, - imgStyle, - blurStyle, - isLazy, - fill, - placeholder, - loading, - srcString, - config, - unoptimized, - loader, - onLoadRef, - onLoadingCompleteRef, - setBlurComplete, - setShowAltText, - onLoad, - onError, - ...rest -}: ImageElementProps) => { - loading = isLazy ? 'lazy' : loading - return ( - <> - { - if (!img) { - return - } - if (onError) { - // If the image has an error before react hydrates, then the error is lost. - // The workaround is to wait until the image is mounted which is after hydration, - // then we set the src again to trigger the error handler (if there was an error). - // eslint-disable-next-line no-self-assign - img.src = img.src - } - if (process.env.NODE_ENV !== 'production') { - if (!srcString) { - console.error(`Image is missing required "src" property:`, img) +const ImageElement = forwardRef( + ( + { + imgAttributes, + heightInt, + widthInt, + qualityInt, + className, + imgStyle, + blurStyle, + isLazy, + fill, + placeholder, + loading, + srcString, + config, + unoptimized, + loader, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + setShowAltText, + onLoad, + onError, + ...rest + }, + forwardedRef + ) => { + loading = isLazy ? 'lazy' : loading + return ( + <> + { + if (forwardedRef) { + if (typeof forwardedRef === 'function') forwardedRef(img) + else if (typeof forwardedRef === 'object') { + // @ts-ignore - .current is read only it's usually assigned by react internally + forwardedRef.current = img + } + } + if (!img) { + return + } + if (onError) { + // If the image has an error before react hydrates, then the error is lost. + // The workaround is to wait until the image is mounted which is after hydration, + // then we set the src again to trigger the error handler (if there was an error). + // eslint-disable-next-line no-self-assign + img.src = img.src } - if (img.getAttribute('alt') === null) { - console.error( - `Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.` + if (process.env.NODE_ENV !== 'production') { + if (!srcString) { + console.error( + `Image is missing required "src" property:`, + img + ) + } + if (img.getAttribute('alt') === null) { + console.error( + `Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.` + ) + } + } + if (img.complete) { + handleLoading( + img, + srcString, + placeholder, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + unoptimized ) } + }, + [ + srcString, + placeholder, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + onError, + unoptimized, + forwardedRef, + ] + )} + onLoad={(event) => { + const img = event.currentTarget as ImgElementWithDataProp + handleLoading( + img, + srcString, + placeholder, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + unoptimized + ) + }} + onError={(event) => { + // 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 (img.complete) { - handleLoading( - img, - srcString, - placeholder, - onLoadRef, - onLoadingCompleteRef, - setBlurComplete, - unoptimized - ) + if (onError) { + onError(event) } - }, - [ - srcString, - placeholder, - onLoadRef, - onLoadingCompleteRef, - setBlurComplete, - onError, - unoptimized, - ] - )} - onLoad={(event) => { - const img = event.currentTarget as ImgElementWithDataProp - handleLoading( - img, - srcString, - placeholder, - onLoadRef, - onLoadingCompleteRef, - setBlurComplete, - unoptimized - ) - }} - onError={(event) => { - // 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) - } - }} - /> - - ) -} - -export default function Image({ - src, - sizes, - unoptimized = false, - priority = false, - loading, - className, - quality, - width, - height, - fill, - style, - onLoad, - onLoadingComplete, - placeholder = 'empty', - blurDataURL, - layout, - objectFit, - objectPosition, - lazyBoundary, - lazyRoot, - ...all -}: ImageProps) { - const configContext = useContext(ImageConfigContext) - const config: ImageConfig = useMemo(() => { - const c = configEnv || configContext || imageConfigDefault - const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b) - const deviceSizes = c.deviceSizes.sort((a, b) => a - b) - return { ...c, allSizes, deviceSizes } - }, [configContext]) - - let rest: Partial = all - let loader: ImageLoaderWithConfig = rest.loader || defaultLoader - // Remove property so it's not spread on element - delete rest.loader - - if ('__next_img_default' in loader) { - // This special value indicates that the user - // didn't define a "loader" prop or config. - if (config.loader === 'custom') { - throw new Error( - `Image with src "${src}" is missing "loader" prop.` + - `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader` - ) - } - } else { - // The user defined a "loader" prop or config. - // Since the config object is internal only, we - // must not pass it to the user-defined "loader". - const customImageLoader = loader as ImageLoader - loader = (obj) => { - const { config: _, ...opts } = obj - return customImageLoader(opts) - } - } - - if (layout) { - if (layout === 'fill') { - fill = true - } - const layoutToStyle: Record | undefined> = { - intrinsic: { maxWidth: '100%', height: 'auto' }, - responsive: { width: '100%', height: 'auto' }, - } - const layoutToSizes: Record = { - responsive: '100vw', - fill: '100vw', - } - const layoutStyle = layoutToStyle[layout] - if (layoutStyle) { - style = { ...style, ...layoutStyle } - } - const layoutSizes = layoutToSizes[layout] - if (layoutSizes && !sizes) { - sizes = layoutSizes - } + }} + /> + + ) } - - let staticSrc = '' - let widthInt = getInt(width) - let heightInt = getInt(height) - let blurWidth: number | undefined - let blurHeight: number | undefined - if (isStaticImport(src)) { - const staticImageData = isStaticRequire(src) ? src.default : src - - if (!staticImageData.src) { - throw new Error( - `An object should only be passed to the image component src parameter if it comes from a static image import. It must include src. Received ${JSON.stringify( - staticImageData - )}` - ) - } - if (!staticImageData.height || !staticImageData.width) { - throw new Error( - `An object should only be passed to the image component src parameter if it comes from a static image import. It must include height and width. Received ${JSON.stringify( - staticImageData - )}` - ) +) + +const Image = forwardRef( + ( + { + src, + sizes, + unoptimized = false, + priority = false, + loading, + className, + quality, + width, + height, + fill, + style, + onLoad, + onLoadingComplete, + placeholder = 'empty', + blurDataURL, + layout, + objectFit, + objectPosition, + lazyBoundary, + lazyRoot, + ...all + }, + forwardedRef + ) => { + const configContext = useContext(ImageConfigContext) + const config: ImageConfig = useMemo(() => { + const c = configEnv || configContext || imageConfigDefault + const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b) + const deviceSizes = c.deviceSizes.sort((a, b) => a - b) + return { ...c, allSizes, deviceSizes } + }, [configContext]) + + let rest: Partial = all + let loader: ImageLoaderWithConfig = rest.loader || defaultLoader + // Remove property so it's not spread on element + delete rest.loader + + if ('__next_img_default' in loader) { + // This special value indicates that the user + // didn't define a "loader" prop or config. + if (config.loader === 'custom') { + throw new Error( + `Image with src "${src}" is missing "loader" prop.` + + `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader` + ) + } + } else { + // The user defined a "loader" prop or config. + // Since the config object is internal only, we + // must not pass it to the user-defined "loader". + const customImageLoader = loader as ImageLoader + loader = (obj) => { + const { config: _, ...opts } = obj + return customImageLoader(opts) + } } - blurWidth = staticImageData.blurWidth - blurHeight = staticImageData.blurHeight - blurDataURL = blurDataURL || staticImageData.blurDataURL - staticSrc = staticImageData.src - - if (!fill) { - if (!widthInt && !heightInt) { - widthInt = staticImageData.width - heightInt = staticImageData.height - } else if (widthInt && !heightInt) { - const ratio = widthInt / staticImageData.width - heightInt = Math.round(staticImageData.height * ratio) - } else if (!widthInt && heightInt) { - const ratio = heightInt / staticImageData.height - widthInt = Math.round(staticImageData.width * ratio) + if (layout) { + if (layout === 'fill') { + fill = true + } + const layoutToStyle: Record | undefined> = + { + intrinsic: { maxWidth: '100%', height: 'auto' }, + responsive: { width: '100%', height: 'auto' }, + } + const layoutToSizes: Record = { + responsive: '100vw', + fill: '100vw', + } + const layoutStyle = layoutToStyle[layout] + if (layoutStyle) { + style = { ...style, ...layoutStyle } + } + const layoutSizes = layoutToSizes[layout] + if (layoutSizes && !sizes) { + sizes = layoutSizes } } - } - src = typeof src === 'string' ? src : staticSrc - - let isLazy = - !priority && (loading === 'lazy' || typeof loading === 'undefined') - if (src.startsWith('data:') || src.startsWith('blob:')) { - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs - unoptimized = true - isLazy = false - } - if (config.unoptimized) { - unoptimized = true - } - const [blurComplete, setBlurComplete] = useState(false) - const [showAltText, setShowAltText] = useState(false) + let staticSrc = '' + let widthInt = getInt(width) + let heightInt = getInt(height) + let blurWidth: number | undefined + let blurHeight: number | undefined + if (isStaticImport(src)) { + const staticImageData = isStaticRequire(src) ? src.default : src - const qualityInt = getInt(quality) + if (!staticImageData.src) { + throw new Error( + `An object should only be passed to the image component src parameter if it comes from a static image import. It must include src. Received ${JSON.stringify( + staticImageData + )}` + ) + } + if (!staticImageData.height || !staticImageData.width) { + throw new Error( + `An object should only be passed to the image component src parameter if it comes from a static image import. It must include height and width. Received ${JSON.stringify( + staticImageData + )}` + ) + } - if (process.env.NODE_ENV !== 'production') { - if (!src) { - // React doesn't show the stack trace and there's - // no `src` to help identify which image, so we - // instead console.error(ref) during mount. - unoptimized = true - } else { - if (fill) { - if (width) { - throw new Error( - `Image with src "${src}" has both "width" and "fill" properties. Only one should be used.` - ) - } - if (height) { - throw new Error( - `Image with src "${src}" has both "height" and "fill" properties. Only one should be used.` - ) - } - if (style?.position && style.position !== 'absolute') { - throw new Error( - `Image with src "${src}" has both "fill" and "style.position" properties. Images with "fill" always use position absolute - it cannot be modified.` - ) - } - if (style?.width && style.width !== '100%') { - throw new Error( - `Image with src "${src}" has both "fill" and "style.width" properties. Images with "fill" always use width 100% - it cannot be modified.` - ) - } - if (style?.height && style.height !== '100%') { - throw new Error( - `Image with src "${src}" has both "fill" and "style.height" properties. Images with "fill" always use height 100% - it cannot be modified.` - ) - } - } else { - if (typeof widthInt === 'undefined') { - throw new Error( - `Image with src "${src}" is missing required "width" property.` - ) - } else if (isNaN(widthInt)) { - throw new Error( - `Image with src "${src}" has invalid "width" property. Expected a numeric value in pixels but received "${width}".` - ) - } - if (typeof heightInt === 'undefined') { - throw new Error( - `Image with src "${src}" is missing required "height" property.` - ) - } else if (isNaN(heightInt)) { - throw new Error( - `Image with src "${src}" has invalid "height" property. Expected a numeric value in pixels but received "${height}".` - ) + blurWidth = staticImageData.blurWidth + blurHeight = staticImageData.blurHeight + blurDataURL = blurDataURL || staticImageData.blurDataURL + staticSrc = staticImageData.src + + if (!fill) { + if (!widthInt && !heightInt) { + widthInt = staticImageData.width + heightInt = staticImageData.height + } else if (widthInt && !heightInt) { + const ratio = widthInt / staticImageData.width + heightInt = Math.round(staticImageData.height * ratio) + } else if (!widthInt && heightInt) { + const ratio = heightInt / staticImageData.height + widthInt = Math.round(staticImageData.width * ratio) } } } - 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(',')}.` - ) + src = typeof src === 'string' ? src : staticSrc + + let isLazy = + !priority && (loading === 'lazy' || typeof loading === 'undefined') + if (src.startsWith('data:') || src.startsWith('blob:')) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs + unoptimized = true + isLazy = false } - if (priority && loading === 'lazy') { - throw new Error( - `Image with src "${src}" has both "priority" and "loading='lazy'" properties. Only one should be used.` - ) + if (config.unoptimized) { + unoptimized = true } - if (placeholder === 'blur') { - if (widthInt && heightInt && widthInt * heightInt < 1600) { - warnOnce( - `Image with src "${src}" is smaller than 40x40. Consider removing the "placeholder='blur'" property to improve performance.` + const [blurComplete, setBlurComplete] = useState(false) + const [showAltText, setShowAltText] = useState(false) + + const qualityInt = getInt(quality) + + if (process.env.NODE_ENV !== 'production') { + if (!src) { + // React doesn't show the stack trace and there's + // no `src` to help identify which image, so we + // instead console.error(ref) during mount. + unoptimized = true + } else { + if (fill) { + if (width) { + throw new Error( + `Image with src "${src}" has both "width" and "fill" properties. Only one should be used.` + ) + } + if (height) { + throw new Error( + `Image with src "${src}" has both "height" and "fill" properties. Only one should be used.` + ) + } + if (style?.position && style.position !== 'absolute') { + throw new Error( + `Image with src "${src}" has both "fill" and "style.position" properties. Images with "fill" always use position absolute - it cannot be modified.` + ) + } + if (style?.width && style.width !== '100%') { + throw new Error( + `Image with src "${src}" has both "fill" and "style.width" properties. Images with "fill" always use width 100% - it cannot be modified.` + ) + } + if (style?.height && style.height !== '100%') { + throw new Error( + `Image with src "${src}" has both "fill" and "style.height" properties. Images with "fill" always use height 100% - it cannot be modified.` + ) + } + } else { + if (typeof widthInt === 'undefined') { + throw new Error( + `Image with src "${src}" is missing required "width" property.` + ) + } else if (isNaN(widthInt)) { + throw new Error( + `Image with src "${src}" has invalid "width" property. Expected a numeric value in pixels but received "${width}".` + ) + } + if (typeof heightInt === 'undefined') { + throw new Error( + `Image with src "${src}" is missing required "height" property.` + ) + } else if (isNaN(heightInt)) { + throw new Error( + `Image with src "${src}" has invalid "height" property. Expected a numeric value in pixels but received "${height}".` + ) + } + } + } + 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 (!blurDataURL) { - const VALID_BLUR_EXT = ['jpeg', 'png', 'webp', 'avif'] // should match next-image-loader + if (placeholder === 'blur') { + if (widthInt && heightInt && widthInt * heightInt < 1600) { + warnOnce( + `Image with src "${src}" is smaller than 40x40. Consider removing the "placeholder='blur'" property to improve performance.` + ) + } + + if (!blurDataURL) { + const VALID_BLUR_EXT = ['jpeg', 'png', 'webp', 'avif'] // should match next-image-loader - throw new Error( - `Image with src "${src}" has "placeholder='blur'" property but is missing the "blurDataURL" property. + throw new Error( + `Image with src "${src}" has "placeholder='blur'" property but is missing the "blurDataURL" property. Possible solutions: - Add a "blurDataURL" property, the contents should be a small Data URL to represent the image - Change the "src" property to a static import with one of the supported file types: ${VALID_BLUR_EXT.join( @@ -695,222 +717,225 @@ export default function Image({ )} - Remove the "placeholder" property, effectively no blur effect Read more: https://nextjs.org/docs/messages/placeholder-blur-data-url` - ) + ) + } } - } - if ('ref' in rest) { - warnOnce( - `Image with src "${src}" is using unsupported "ref" property. Consider using the "onLoadingComplete" property instead.` - ) - } - - if (!unoptimized && loader !== defaultLoader) { - const urlStr = loader({ - config, - src, - width: widthInt || 400, - quality: qualityInt || 75, - }) - let url: URL | undefined - try { - url = new URL(urlStr) - } catch (err) {} - if (urlStr === src || (url && url.pathname === src && !url.search)) { + if ('ref' in rest) { warnOnce( - `Image with src "${src}" has a "loader" property that does not implement width. Please implement it or use the "unoptimized" property instead.` + - `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader-width` + `Image with src "${src}" is using unsupported "ref" property. Consider using the "onLoadingComplete" property instead.` ) } - } - for (const [legacyKey, legacyValue] of Object.entries({ - layout, - objectFit, - objectPosition, - lazyBoundary, - lazyRoot, - })) { - if (legacyValue) { - warnOnce( - `Image with src "${src}" has legacy prop "${legacyKey}". Did you forget to run the codemod?` + - `\nRead more: https://nextjs.org/docs/messages/next-image-upgrade-to-13` - ) + if (!unoptimized && loader !== defaultLoader) { + const urlStr = loader({ + config, + src, + width: widthInt || 400, + quality: qualityInt || 75, + }) + let url: URL | undefined + try { + url = new URL(urlStr) + } catch (err) {} + if (urlStr === src || (url && url.pathname === src && !url.search)) { + warnOnce( + `Image with src "${src}" has a "loader" property that does not implement width. Please implement it or use the "unoptimized" property instead.` + + `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader-width` + ) + } } - } - if ( - typeof window !== 'undefined' && - !perfObserver && - window.PerformanceObserver - ) { - perfObserver = new PerformanceObserver((entryList) => { - for (const entry of entryList.getEntries()) { - // @ts-ignore - missing "LargestContentfulPaint" class with "element" prop - const imgSrc = entry?.element?.src || '' - const lcpImage = allImgs.get(imgSrc) - if ( - lcpImage && - !lcpImage.priority && - lcpImage.placeholder !== 'blur' && - !lcpImage.src.startsWith('data:') && - !lcpImage.src.startsWith('blob:') - ) { - // https://web.dev/lcp/#measure-lcp-in-javascript - warnOnce( - `Image with src "${lcpImage.src}" was detected as the Largest Contentful Paint (LCP). Please add the "priority" property if this image is above the fold.` + - `\nRead more: https://nextjs.org/docs/api-reference/next/image#priority` - ) - } + for (const [legacyKey, legacyValue] of Object.entries({ + layout, + objectFit, + objectPosition, + lazyBoundary, + lazyRoot, + })) { + if (legacyValue) { + warnOnce( + `Image with src "${src}" has legacy prop "${legacyKey}". Did you forget to run the codemod?` + + `\nRead more: https://nextjs.org/docs/messages/next-image-upgrade-to-13` + ) } - }) - try { - perfObserver.observe({ - type: 'largest-contentful-paint', - buffered: true, - }) - } catch (err) { - // Log error but don't crash the app - console.error(err) } - } - } - const imgStyle = Object.assign( - fill - ? { - position: 'absolute', - height: '100%', - width: '100%', - left: 0, - top: 0, - right: 0, - bottom: 0, - objectFit, - objectPosition, - } - : {}, - showAltText ? {} : { color: 'transparent' }, - style - ) - const blurStyle = - placeholder === 'blur' && blurDataURL && !blurComplete - ? { - backgroundSize: imgStyle.objectFit || 'cover', - backgroundPosition: imgStyle.objectPosition || '50% 50%', - backgroundRepeat: 'no-repeat', - backgroundImage: `url("data:image/svg+xml;charset=utf-8,${getImageBlurSvg( - { - widthInt, - heightInt, - blurWidth, - blurHeight, - blurDataURL, + if ( + typeof window !== 'undefined' && + !perfObserver && + window.PerformanceObserver + ) { + perfObserver = new PerformanceObserver((entryList) => { + for (const entry of entryList.getEntries()) { + // @ts-ignore - missing "LargestContentfulPaint" class with "element" prop + const imgSrc = entry?.element?.src || '' + const lcpImage = allImgs.get(imgSrc) + if ( + lcpImage && + !lcpImage.priority && + lcpImage.placeholder !== 'blur' && + !lcpImage.src.startsWith('data:') && + !lcpImage.src.startsWith('blob:') + ) { + // https://web.dev/lcp/#measure-lcp-in-javascript + warnOnce( + `Image with src "${lcpImage.src}" was detected as the Largest Contentful Paint (LCP). Please add the "priority" property if this image is above the fold.` + + `\nRead more: https://nextjs.org/docs/api-reference/next/image#priority` + ) } - )}")`, + } + }) + try { + perfObserver.observe({ + type: 'largest-contentful-paint', + buffered: true, + }) + } catch (err) { + // Log error but don't crash the app + console.error(err) } - : {} - - if (process.env.NODE_ENV === 'development') { - if (blurStyle.backgroundImage && blurDataURL?.startsWith('/')) { - // During `next dev`, we don't want to generate blur placeholders with webpack - // because it can delay starting the dev server. Instead, `next-image-loader.js` - // will inline a special url to lazily generate the blur placeholder at request time. - blurStyle.backgroundImage = `url("${blurDataURL}")` + } + } + const imgStyle = Object.assign( + fill + ? { + position: 'absolute', + height: '100%', + width: '100%', + left: 0, + top: 0, + right: 0, + bottom: 0, + objectFit, + objectPosition, + } + : {}, + showAltText ? {} : { color: 'transparent' }, + style + ) + + const blurStyle = + placeholder === 'blur' && blurDataURL && !blurComplete + ? { + backgroundSize: imgStyle.objectFit || 'cover', + backgroundPosition: imgStyle.objectPosition || '50% 50%', + backgroundRepeat: 'no-repeat', + backgroundImage: `url("data:image/svg+xml;charset=utf-8,${getImageBlurSvg( + { + widthInt, + heightInt, + blurWidth, + blurHeight, + blurDataURL, + } + )}")`, + } + : {} + + if (process.env.NODE_ENV === 'development') { + if (blurStyle.backgroundImage && blurDataURL?.startsWith('/')) { + // During `next dev`, we don't want to generate blur placeholders with webpack + // because it can delay starting the dev server. Instead, `next-image-loader.js` + // will inline a special url to lazily generate the blur placeholder at request time. + blurStyle.backgroundImage = `url("${blurDataURL}")` + } } - } - const imgAttributes = generateImgAttrs({ - config, - src, - unoptimized, - width: widthInt, - quality: qualityInt, - sizes, - loader, - }) + const imgAttributes = generateImgAttrs({ + config, + src, + unoptimized, + width: widthInt, + quality: qualityInt, + sizes, + loader, + }) - let srcString: string = src + let srcString: string = src - if (process.env.NODE_ENV !== 'production') { - if (typeof window !== 'undefined') { - let fullUrl: URL - try { - fullUrl = new URL(imgAttributes.src) - } catch (e) { - fullUrl = new URL(imgAttributes.src, window.location.href) + if (process.env.NODE_ENV !== 'production') { + if (typeof window !== 'undefined') { + let fullUrl: URL + try { + fullUrl = new URL(imgAttributes.src) + } catch (e) { + fullUrl = new URL(imgAttributes.src, window.location.href) + } + allImgs.set(fullUrl.href, { src, priority, placeholder }) } - allImgs.set(fullUrl.href, { src, priority, placeholder }) } - } - const linkProps: React.DetailedHTMLProps< - React.LinkHTMLAttributes, - HTMLLinkElement - > = { - // @ts-expect-error upgrade react types to react 18 - imageSrcSet: imgAttributes.srcSet, - imageSizes: imgAttributes.sizes, - crossOrigin: rest.crossOrigin, - } + const linkProps: React.DetailedHTMLProps< + React.LinkHTMLAttributes, + HTMLLinkElement + > = { + // @ts-expect-error upgrade react types to react 18 + imageSrcSet: imgAttributes.srcSet, + imageSizes: imgAttributes.sizes, + crossOrigin: rest.crossOrigin, + } - const onLoadRef = useRef(onLoad) - - useEffect(() => { - onLoadRef.current = onLoad - }, [onLoad]) - - const onLoadingCompleteRef = useRef(onLoadingComplete) - - useEffect(() => { - onLoadingCompleteRef.current = onLoadingComplete - }, [onLoadingComplete]) - - const imgElementArgs: ImageElementProps = { - isLazy, - imgAttributes, - heightInt, - widthInt, - qualityInt, - className, - imgStyle, - blurStyle, - loading, - config, - fill, - unoptimized, - placeholder, - loader, - srcString, - onLoadRef, - onLoadingCompleteRef, - setBlurComplete, - setShowAltText, - ...rest, + const onLoadRef = useRef(onLoad) + + useEffect(() => { + onLoadRef.current = onLoad + }, [onLoad]) + + const onLoadingCompleteRef = useRef(onLoadingComplete) + + useEffect(() => { + onLoadingCompleteRef.current = onLoadingComplete + }, [onLoadingComplete]) + + const imgElementArgs: ImageElementProps = { + isLazy, + imgAttributes, + heightInt, + widthInt, + qualityInt, + className, + imgStyle, + blurStyle, + loading, + config, + fill, + unoptimized, + placeholder, + loader, + srcString, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + setShowAltText, + ...rest, + } + return ( + <> + {} + {priority ? ( + // 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 + + + + ) : null} + + ) } - return ( - <> - {} - {priority ? ( - // 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 - - - - ) : null} - - ) -} +) + +export default Image diff --git a/test/e2e/next-image-forward-ref/app/images/test.png b/test/e2e/next-image-forward-ref/app/images/test.png new file mode 100644 index 000000000000000..e14fafc5cf3bc63 Binary files /dev/null and b/test/e2e/next-image-forward-ref/app/images/test.png differ diff --git a/test/e2e/next-image-forward-ref/app/pages/framer-motion.js b/test/e2e/next-image-forward-ref/app/pages/framer-motion.js new file mode 100644 index 000000000000000..9edba39ae0b3553 --- /dev/null +++ b/test/e2e/next-image-forward-ref/app/pages/framer-motion.js @@ -0,0 +1,30 @@ +import React, { useState } from 'react' +import Image from 'next/image' +import { motion } from 'framer-motion' +import testPng from '../images/test.png' + +const CustomImage = React.forwardRef((props, ref) => ( + test img +)) + +const MotionImage = motion(CustomImage) + +export default function Page() { + const [clicked, setClicked] = useState(false) + return ( + setClicked(true)} + initial={{ opacity: 1 }} + animate={{ opacity: clicked ? 0 : 1 }} + transition={{ duration: 0.5 }} + /> + ) +} diff --git a/test/e2e/next-image-forward-ref/index.test.ts b/test/e2e/next-image-forward-ref/index.test.ts new file mode 100644 index 000000000000000..9ac2a98e7291380 --- /dev/null +++ b/test/e2e/next-image-forward-ref/index.test.ts @@ -0,0 +1,33 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { waitFor } from 'next-test-utils' +import path from 'path' +import webdriver from 'next-webdriver' + +describe('next-image-forward-ref', () => { + let next: NextInstance + + const appDir = path.join(__dirname, 'app') + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(appDir), + dependencies: { + 'framer-motion': '7.6.9', + }, + }) + }) + afterAll(() => next.destroy()) + + it('allows framer-motion to animate opacity', async () => { + const browser = await webdriver(next.url, '/framer-motion') + expect( + Number(await browser.elementById('img').getComputedCss('opacity')) + ).toBeCloseTo(1) + browser.elementById('img').click() + await waitFor(1000) + expect( + Number(await browser.elementById('img').getComputedCss('opacity')) + ).toBeCloseTo(0) + }) +})