Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix next/future/image blur-up placeholder #39785

Merged
merged 11 commits into from Aug 23, 2022
25 changes: 16 additions & 9 deletions packages/next/build/webpack/loaders/next-image-loader.js
Expand Up @@ -28,6 +28,8 @@ function nextImageLoader(content) {
getImageSize(content, extension)
)
let blurDataURL
let blurWidth
let blurHeight

if (VALID_BLUR_EXT.includes(extension)) {
if (isDev) {
Expand All @@ -39,18 +41,21 @@ function nextImageLoader(content) {
blurDataURL = url.href.slice(prefix.length)
} else {
// Shrink the image's largest dimension
const dimension =
imageSize.width >= imageSize.height ? 'width' : 'height'
if (imageSize.width >= imageSize.height) {
blurWidth = BLUR_IMG_SIZE
blurHeight = Math.round(
(imageSize.height / imageSize.width) * BLUR_IMG_SIZE
)
} else {
blurWidth = Math.round(
(imageSize.width / imageSize.height) * BLUR_IMG_SIZE
)
blurHeight = BLUR_IMG_SIZE
}

const resizeImageSpan = imageLoaderSpan.traceChild('image-resize')
const resizedImage = await resizeImageSpan.traceAsyncFn(() =>
resizeImage(
content,
dimension,
BLUR_IMG_SIZE,
extension,
BLUR_QUALITY
)
resizeImage(content, blurWidth, blurHeight, extension, BLUR_QUALITY)
)
const blurDataURLSpan = imageLoaderSpan.traceChild(
'image-base64-tostring'
Expand All @@ -70,6 +75,8 @@ function nextImageLoader(content) {
height: imageSize.height,
width: imageSize.width,
blurDataURL,
blurWidth,
blurHeight,
})
)

Expand Down
24 changes: 19 additions & 5 deletions packages/next/client/future/image.tsx
Expand Up @@ -67,6 +67,8 @@ export interface StaticImageData {
height: number
width: number
blurDataURL?: string
blurWidth?: number
blurHeight?: number
}

interface StaticRequire {
Expand Down Expand Up @@ -578,6 +580,8 @@ export default function Image({
}

let staticSrc = ''
let blurWidth: number | undefined
let blurHeight: number | undefined
if (isStaticImport(src)) {
const staticImageData = isStaticRequire(src) ? src.default : src

Expand All @@ -588,6 +592,8 @@ export default function Image({
)}`
)
}
blurWidth = staticImageData.blurWidth
blurHeight = staticImageData.blurHeight
blurDataURL = blurDataURL || staticImageData.blurDataURL
staticSrc = staticImageData.src

Expand Down Expand Up @@ -785,16 +791,24 @@ export default function Image({
bottom: 0,
}
: {},
showAltText || placeholder === 'blur' ? {} : { color: 'transparent' },
showAltText ? {} : { color: 'transparent' },
style
)
const svgBlurPlaceholder = `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 ${widthInt} ${heightInt}'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='50'/%3E%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='${blurDataURL}'/%3E%3C/svg%3E")`
const std = blurWidth && blurHeight ? '1' : '20'
const svgWidth = blurWidth || widthInt
const svgHeight = blurHeight || heightInt
const feComponentTransfer = blurDataURL?.startsWith('data:image/jpeg')
? `%3CfeComponentTransfer%3E%3CfeFuncA type='discrete' tableValues='1 1'/%3E%3C/feComponentTransfer%3E%`
: ''
const svgBlurPlaceholder = `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 ${svgWidth} ${svgHeight}'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E${feComponentTransfer}%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='${blurDataURL}'/%3E%3C/svg%3E")`

const blurStyle =
placeholder === 'blur' && !blurComplete
placeholder === 'blur' && blurDataURL && !blurComplete
? {
backgroundSize: imgStyle.objectFit || 'cover',
backgroundPosition: imgStyle.objectPosition || '0% 0%',
...(blurDataURL?.startsWith('data:image')
backgroundPosition: imgStyle.objectPosition || '50% 50%',
backgroundRepeat: 'no-repeat',
...(blurDataURL.startsWith('data:image') && svgWidth && svgHeight
? {
backgroundImage: svgBlurPlaceholder,
}
Expand Down
15 changes: 4 additions & 11 deletions packages/next/server/image-optimizer.ts
Expand Up @@ -719,8 +719,8 @@ export function sendResponse(

export async function resizeImage(
content: Buffer,
dimension: 'width' | 'height',
size: number,
width: number,
height: number,
// Should match VALID_BLUR_EXT
extension: 'avif' | 'webp' | 'png' | 'jpeg',
quality: number
Expand Down Expand Up @@ -748,18 +748,11 @@ export async function resizeImage(
} else if (extension === 'jpeg') {
transformer.jpeg({ quality })
}
if (dimension === 'width') {
transformer.resize(size)
} else {
transformer.resize(null, size)
}
transformer.resize(width, height)
const buf = await transformer.toBuffer()
return buf
} else {
const resizeOperationOpts: Operation =
dimension === 'width'
? { type: 'resize', width: size }
: { type: 'resize', height: size }
const resizeOperationOpts: Operation = { type: 'resize', width, height }
const buf = await processBuffer(
content,
[resizeOperationOpts],
Expand Down
1 change: 1 addition & 0 deletions packages/next/server/lib/squoosh/impl.ts
Expand Up @@ -62,6 +62,7 @@ export async function rotate(
type ResizeOpts = { image: ImageData } & (
| { width: number; height?: never }
| { height: number; width?: never }
| { height: number; width: number }
)

export async function resize({ image, width, height }: ResizeOpts) {
Expand Down
24 changes: 14 additions & 10 deletions packages/next/server/lib/squoosh/main.ts
Expand Up @@ -9,7 +9,11 @@ type RotateOperation = {
}
type ResizeOperation = {
type: 'resize'
} & ({ width: number; height?: never } | { height: number; width?: never })
} & (
| { width: number; height?: never }
| { height: number; width?: never }
| { width: number; height: number }
)
export type Operation = RotateOperation | ResizeOperation
export type Encoding = 'jpeg' | 'png' | 'webp' | 'avif'

Expand Down Expand Up @@ -37,24 +41,24 @@ export async function processBuffer(
if (operation.type === 'rotate') {
imageData = await worker.rotate(imageData, operation.numRotations)
} else if (operation.type === 'resize') {
const opt = { image: imageData, width: 0, height: 0 }
if (
operation.width &&
imageData.width &&
imageData.width > operation.width
) {
imageData = await worker.resize({
image: imageData,
width: operation.width,
})
} else if (
opt.width = operation.width
}
if (
operation.height &&
imageData.height &&
imageData.height > operation.height
) {
imageData = await worker.resize({
image: imageData,
height: operation.height,
})
opt.height = operation.height
}

if (opt.width > 0 || opt.height > 0) {
imageData = await worker.resize(opt)
}
}
}
Expand Down
133 changes: 91 additions & 42 deletions test/integration/image-future/base-path/test/static.test.js
Expand Up @@ -5,20 +5,23 @@ import {
nextStart,
renderViaHTTP,
File,
launchApp,
waitFor,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
import cheerio from 'cheerio'
import { join } from 'path'

const appDir = join(__dirname, '../')
let appPort
let app
let browser
let html
let $

const indexPage = new File(join(appDir, 'pages/static-img.js'))

const runTests = () => {
const runTests = (isDev) => {
it('Should allow an image with a static src to omit height and width', async () => {
expect(await browser.elementById('basic-static')).toBeTruthy()
expect(await browser.elementById('blur-png')).toBeTruthy()
Expand All @@ -31,42 +34,71 @@ const runTests = () => {
expect(await browser.elementById('static-ico')).toBeTruthy()
expect(await browser.elementById('static-unoptimized')).toBeTruthy()
})
it('Should use immutable cache-control header for static import', async () => {
await browser.eval(
`document.getElementById("basic-static").scrollIntoView()`
)
await waitFor(1000)
const url = await browser.eval(
`document.getElementById("basic-static").src`
)
const res = await fetch(url)
expect(res.headers.get('cache-control')).toBe(
'public, max-age=315360000, immutable'
)
})
it('Should use immutable cache-control header even when unoptimized', async () => {
await browser.eval(
`document.getElementById("static-unoptimized").scrollIntoView()`
)
await waitFor(1000)
const url = await browser.eval(
`document.getElementById("static-unoptimized").src`
)
const res = await fetch(url)
expect(res.headers.get('cache-control')).toBe(
'public, max-age=31536000, immutable'
)
})
if (!isDev) {
// cache-control is set to "0, no-store" in dev mode
it('Should use immutable cache-control header for static import', async () => {
await browser.eval(
`document.getElementById("basic-static").scrollIntoView()`
)
await waitFor(1000)
const url = await browser.eval(
`document.getElementById("basic-static").src`
)
const res = await fetch(url)
expect(res.headers.get('cache-control')).toBe(
'public, max-age=315360000, immutable'
)
})

it('Should use immutable cache-control header even when unoptimized', async () => {
await browser.eval(
`document.getElementById("static-unoptimized").scrollIntoView()`
)
await waitFor(1000)
const url = await browser.eval(
`document.getElementById("static-unoptimized").src`
)
const res = await fetch(url)
expect(res.headers.get('cache-control')).toBe(
'public, max-age=31536000, immutable'
)
})
}
it('Should automatically provide an image height and width', async () => {
expect(html).toContain('width="400" height="300"')
const img = $('#basic-non-static')
expect(img.attr('width')).toBe('400')
expect(img.attr('height')).toBe('300')
})
it('Should allow provided width and height to override intrinsic', async () => {
expect(html).toContain('width="150" height="150"')
const img = $('#defined-size-static')
expect(img.attr('width')).toBe('150')
expect(img.attr('height')).toBe('150')
})
it('Should add a blur to a statically imported image', async () => {
expect(html).toContain(
`style="background-size:cover;background-position:0% 0%;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 400 300'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='50'/%3E%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/sBCgoKCgoKCwwMCw8QDhAPFhQTExQWIhgaGBoYIjMgJSAgJSAzLTcsKSw3LVFAODhAUV5PSk9ecWVlcY+Ij7u7+//CABEIAAYACAMBIgACEQEDEQH/xAAnAAEBAAAAAAAAAAAAAAAAAAAABwEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEAMQAAAAmgP/xAAcEAACAQUBAAAAAAAAAAAAAAASFBMAAQMFERX/2gAIAQEAAT8AZ1HjrKZX55JysIc4Ff/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Af//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Af//Z'/%3E%3C/svg%3E")`
)

it('Should add a blur placeholder a statically imported jpg', async () => {
const style = $('#basic-static').attr('style')
if (isDev) {
expect(style).toBe(
`color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;filter:blur(20px);background-image:url("/docs/_next/image?url=%2Fdocs%2F_next%2Fstatic%2Fmedia%2Ftest-rect.f323a148.jpg&w=8&q=70")`
)
} else {
expect(style).toBe(
`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 8 6'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='discrete' tableValues='1 1'/%3E%3C/feComponentTransfer%3E%%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/sBCgoKCgoKCwwMCw8QDhAPFhQTExQWIhgaGBoYIjMgJSAgJSAzLTcsKSw3LVFAODhAUV5PSk9ecWVlcY+Ij7u7+//CABEIAAYACAMBIgACEQEDEQH/xAAnAAEBAAAAAAAAAAAAAAAAAAAABwEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEAMQAAAAmgP/xAAcEAACAQUBAAAAAAAAAAAAAAASFBMAAQMFERX/2gAIAQEAAT8AZ1HjrKZX55JysIc4Ff/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Af//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Af//Z'/%3E%3C/svg%3E")`
)
}
})

it('Should add a blur placeholder a statically imported png', async () => {
const style = $('#blur-png').attr('style')
if (isDev) {
expect(style).toBe(
`color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;filter:blur(20px);background-image:url("/docs/_next/image?url=%2Fdocs%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=8&q=70")`
)
} else {
expect(style).toBe(
`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 8 8'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAAAAADhZOFXAAAAOklEQVR42iWGsQkAIBDE0iuIdiLOJjiGIzjiL/Meb4okiNYIlLjK3hJMzCQG1/0qmXXOUkjAV+m9wAMe3QiV6Ne8VgAAAABJRU5ErkJggg=='/%3E%3C/svg%3E")`
)
}
})
}

Expand All @@ -90,15 +122,32 @@ describe('Build Error Tests', () => {
})
})
describe('Future Static Image Component Tests for basePath', () => {
beforeAll(async () => {
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
html = await renderViaHTTP(appPort, '/docs/static-img')
browser = await webdriver(appPort, '/docs/static-img')
describe('production mode', () => {
beforeAll(async () => {
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
html = await renderViaHTTP(appPort, '/docs/static-img')
$ = cheerio.load(html)
browser = await webdriver(appPort, '/docs/static-img')
})
afterAll(() => {
killApp(app)
})
runTests(false)
})
afterAll(() => {
killApp(app)

describe('dev mode', () => {
beforeAll(async () => {
appPort = await findPort()
app = await launchApp(appDir, appPort)
html = await renderViaHTTP(appPort, '/docs/static-img')
$ = cheerio.load(html)
browser = await webdriver(appPort, '/docs/static-img')
})
afterAll(() => {
killApp(app)
})
runTests(true)
})
runTests()
})
7 changes: 7 additions & 0 deletions test/integration/image-future/default/pages/static-img.js
Expand Up @@ -32,15 +32,22 @@ const Page = () => {
<Image id="blur-jpg" src={testJPG} placeholder="blur" />
<Image id="blur-webp" src={testWEBP} placeholder="blur" />
<Image id="blur-avif" src={testAVIF} placeholder="blur" />
<br />
<Image id="static-svg" src={testSVG} />
<Image id="static-gif" src={testGIF} />
<Image id="static-bmp" src={testBMP} />
<Image id="static-ico" src={testICO} />
<br />
<Image id="static-svg-fill" src={testSVG} fill />
<Image id="static-gif-fill" src={testGIF} fill />
<Image id="static-bmp-fill" src={testBMP} fill />
<Image id="static-ico-fill" src={testICO} fill />
<br />
<Image id="blur-png-fill" src={testPNG} placeholder="blur" fill />
<Image id="blur-jpg-fill" src={testJPG} placeholder="blur" fill />
<Image id="blur-webp-fill" src={testWEBP} placeholder="blur" fill />
<Image id="blur-avif-fill" src={testAVIF} placeholder="blur" fill />
<br />
<Image id="static-unoptimized" src={testJPG} unoptimized />
</div>
)
Expand Down