From c76170e82929bcf960ae577280eb5cb81f9919bd Mon Sep 17 00:00:00 2001 From: Joon Park Date: Fri, 30 Apr 2021 12:05:03 -0500 Subject: [PATCH] Add experimental blurry placeholder to image component (#24153) This is the image component implementation of the blurry placeholder as described in #24004. The matching server side implementation is currently planned. ## Feature - [x] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [x] Related issue #18858 - [x] Integration tests added (Documentation and telemetry to follow after server side is implemented) --- packages/next/build/webpack-config.ts | 1 + packages/next/client/image.tsx | 54 ++++++++++++++++++- .../next/next-server/server/config-shared.ts | 2 + .../next/next-server/server/image-config.ts | 2 + .../default/pages/blurry-placeholder.js | 19 +++++++ .../default/test/index.test.js | 28 +++++++++- 6 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 test/integration/image-component/default/pages/blurry-placeholder.js diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index e7bc6042d1fa..04fda183c55b 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1084,6 +1084,7 @@ export default async function getBaseWebpackConfig( domains: config.images.domains, } : {}), + enableBlurryPlaceholder: config.experimental.enableBlurryPlaceholder, }), 'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath), 'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites), diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 4028e8787f6b..eea353be4898 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -45,6 +45,8 @@ const VALID_LAYOUT_VALUES = [ ] as const type LayoutValue = typeof VALID_LAYOUT_VALUES[number] +type PlaceholderValue = 'blur' | 'empty' + type ImgElementStyle = NonNullable export type ImageProps = Omit< @@ -72,6 +74,13 @@ export type ImageProps = Omit< height: number | string layout?: Exclude } + ) & + ( + | { + placeholder?: Exclude + blurDataURL?: never + } + | { placeholder: 'blur'; blurDataURL: string } ) const { @@ -80,6 +89,7 @@ const { loader: configLoader, path: configPath, domains: configDomains, + enableBlurryPlaceholder: configEnableBlurryPlaceholder, } = ((process.env.__NEXT_IMAGE_OPTS as any) as ImageConfig) || imageConfigDefault // sort smallest to largest @@ -211,6 +221,26 @@ function defaultImageLoader(loaderProps: ImageLoaderProps) { ) } +// See https://stackoverflow.com/q/39777833/266535 for why we use this ref +// handler instead of the img's onLoad attribute. +function removePlaceholder( + element: HTMLImageElement | null, + placeholder: PlaceholderValue +) { + if (placeholder === 'blur' && element) { + if (element.complete) { + // If the real image fails to load, this will still remove the placeholder. + // This is the desired behavior for now, and will be revisited when error + // handling is worked on for the image component itself. + element.style.backgroundImage = 'none' + } else { + element.onload = () => { + element.style.backgroundImage = 'none' + } + } + } +} + export default function Image({ src, sizes, @@ -224,6 +254,8 @@ export default function Image({ objectFit, objectPosition, loader = defaultImageLoader, + placeholder = 'empty', + blurDataURL, ...all }: ImageProps) { let rest: Partial = all @@ -241,6 +273,10 @@ export default function Image({ delete rest['layout'] } + if (!configEnableBlurryPlaceholder) { + placeholder = 'empty' + } + if (process.env.NODE_ENV !== 'production') { if (!src) { throw new Error( @@ -293,6 +329,12 @@ export default function Image({ const heightInt = getInt(height) const qualityInt = getInt(quality) + const MIN_IMG_SIZE_FOR_PLACEHOLDER = 5000 + const tooSmallForBlurryPlaceholder = + widthInt && heightInt && widthInt * heightInt < MIN_IMG_SIZE_FOR_PLACEHOLDER + const shouldShowBlurryPlaceholder = + placeholder === 'blur' && !tooSmallForBlurryPlaceholder + let wrapperStyle: JSX.IntrinsicElements['div']['style'] | undefined let sizerStyle: JSX.IntrinsicElements['div']['style'] | undefined let sizerSvg: string | undefined @@ -318,6 +360,13 @@ export default function Image({ objectFit, objectPosition, + + ...(shouldShowBlurryPlaceholder + ? { + backgroundSize: 'cover', + backgroundImage: `url("${blurDataURL}")`, + } + : undefined), } if ( typeof widthInt !== 'undefined' && @@ -464,7 +513,10 @@ export default function Image({ {...imgAttributes} decoding="async" className={className} - ref={setRef} + ref={(element) => { + setRef(element) + removePlaceholder(element, placeholder) + }} style={imgStyle} /> {priority ? ( diff --git a/packages/next/next-server/server/config-shared.ts b/packages/next/next-server/server/config-shared.ts index 7cbaa86e6a22..c1e38ee000e5 100644 --- a/packages/next/next-server/server/config-shared.ts +++ b/packages/next/next-server/server/config-shared.ts @@ -61,6 +61,7 @@ export type NextConfig = { [key: string]: any } & { turboMode: boolean eslint?: boolean reactRoot: boolean + enableBlurryPlaceholder: boolean } } @@ -117,6 +118,7 @@ export const defaultConfig: NextConfig = { turboMode: false, eslint: false, reactRoot: Number(process.env.NEXT_PRIVATE_REACT_ROOT) > 0, + enableBlurryPlaceholder: false, }, future: { strictPostcssConfiguration: false, diff --git a/packages/next/next-server/server/image-config.ts b/packages/next/next-server/server/image-config.ts index 5e60093067dd..db88d2c516b6 100644 --- a/packages/next/next-server/server/image-config.ts +++ b/packages/next/next-server/server/image-config.ts @@ -13,6 +13,7 @@ export type ImageConfig = { loader: LoaderValue path: string domains?: string[] + enableBlurryPlaceholder: boolean } export const imageConfigDefault: ImageConfig = { @@ -21,4 +22,5 @@ export const imageConfigDefault: ImageConfig = { path: '/_next/image', loader: 'default', domains: [], + enableBlurryPlaceholder: false, } diff --git a/test/integration/image-component/default/pages/blurry-placeholder.js b/test/integration/image-component/default/pages/blurry-placeholder.js new file mode 100644 index 000000000000..d23c21799cd4 --- /dev/null +++ b/test/integration/image-component/default/pages/blurry-placeholder.js @@ -0,0 +1,19 @@ +import React from 'react' +import Image from 'next/image' + +export default function Page() { + return ( +
+

Blurry Placeholder

+ +
+ ) +} diff --git a/test/integration/image-component/default/test/index.test.js b/test/integration/image-component/default/test/index.test.js index cff2cc2508e6..6460efa45ac2 100644 --- a/test/integration/image-component/default/test/index.test.js +++ b/test/integration/image-component/default/test/index.test.js @@ -587,7 +587,10 @@ describe('Image Component Tests', () => { nextConfig, ` module.exports = { - target: 'serverless' + target: 'serverless', + experimental: { + enableBlurryPlaceholder: true, + }, } ` ) @@ -600,6 +603,29 @@ describe('Image Component Tests', () => { await killApp(app) }) + it('should have blurry placeholder when enabled', async () => { + const html = await renderViaHTTP(appPort, '/blurry-placeholder') + expect(html).toContain( + 'background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'%3E%3Cfilter id='blur' filterUnits='userSpaceOnUse' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20' edgeMode='duplicate' /%3E%3CfeComponentTransfer%3E%3CfeFuncA type='discrete' tableValues='1 1' /%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Cimage filter='url(%23blur)' href='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMDAwMDAwQEBAQFBQUFBQcHBgYHBwsICQgJCAsRCwwLCwwLEQ8SDw4PEg8bFRMTFRsfGhkaHyYiIiYwLTA+PlT/wAALCAAKAAoBAREA/8QAMwABAQEAAAAAAAAAAAAAAAAAAAcJEAABAwUAAwAAAAAAAAAAAAAFAAYRAQMEEyEVMlH/2gAIAQEAAD8Az1bLPaxhiuk0QdeCOLDtHixN2dmd2bsc5FPX7VTREX//2Q==' x='0' y='0' height='100%25' width='100%25'/%3E%3C/svg%3E")' + ) + }) + + it('should remove blurry placeholder after image loads', async () => { + let browser + try { + browser = await webdriver(appPort, '/blurry-placeholder') + const id = 'blurry-placeholder' + const backgroundImage = await browser.eval( + `window.getComputedStyle(document.getElementById('${id}')).getPropertyValue('background-image')` + ) + expect(backgroundImage).toBe('none') + } finally { + if (browser) { + await browser.close() + } + } + }) + runTests('serverless') }) })