Change experimental layout=raw to use native img lazy loading (#36985)

This PR changes the experimental `layout=raw` images to use the native lazy loading behavior (as opposed to the IntersectionObserver).

This will (eventually) lead to smaller client bundles and faster image loading since there is no JS needed to load the image.

However, we'll lose the `lazyRoot` and `lazyBoundary` behavior since those are specific to the IntersectionObserver implementation.
This commit is contained in:
Steven 2022-05-18 17:05:15 -04:00 committed by GitHub
parent 81e69e8662
commit 9f0024a5ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 175 additions and 16 deletions

View file

@ -75,13 +75,13 @@ The `<Image />` component accepts a number of additional properties beyond those
The layout behavior of the image as the viewport changes size.
| `layout` | Behavior | `srcSet` | `sizes` | Has wrapper and sizer |
| ---------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------- | --------------------- |
| `intrinsic` (default) | Scale *down* to fit width of container, up to image size | `1x`, `2x` (based on [imageSizes](#image-sizes)) | N/A | yes |
| `fixed` | Sized to `width` and `height` exactly | `1x`, `2x` (based on [imageSizes](#image-sizes)) | N/A | yes |
| `responsive` | Scale to fit width of container | `640w`, `750w`, ... `2048w`, `3840w` (based on [imageSizes](#image-sizes) and [deviceSizes](#device-sizes)) | `100vw` | yes |
| `fill` | Grow in both X and Y axes to fill container | `640w`, `750w`, ... `2048w`, `3840w` (based on [imageSizes](#image-sizes) and [deviceSizes](#device-sizes)) | `100vw` | yes |
| `raw`[\*](#experimental-raw-layout-mode) | Insert the image element with no automatic layout behavior | Behaves like `responsive` if the image has the `sizes` prop, and like `fixed` if it does not | optional | no |
| `layout` | Behavior | `srcSet` | `sizes` | Has wrapper and sizer |
| ---------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------- | --------------------- |
| `intrinsic` (default) | Scale *down* to fit width of container, up to image size | `1x`, `2x` (based on [imageSizes](#image-sizes)) | N/A | yes |
| `fixed` | Sized to `width` and `height` exactly | `1x`, `2x` (based on [imageSizes](#image-sizes)) | N/A | yes |
| `responsive` | Scale to fit width of container | `640w`, `750w`, ... `2048w`, `3840w` (based on [imageSizes](#image-sizes) and [deviceSizes](#device-sizes)) | `100vw` | yes |
| `fill` | Grow in both X and Y axes to fill container | `640w`, `750w`, ... `2048w`, `3840w` (based on [imageSizes](#image-sizes) and [deviceSizes](#device-sizes)) | `100vw` | yes |
| `raw`[\*](#experimental-raw-layout-mode) | Raw `<img>` without styles and native lazy loading | Behaves like `responsive` if the image has the `sizes` prop, and like `fixed` if it does not | optional | no |
- [Demo the `intrinsic` layout (default)](https://image-component.nextjs.gallery/layout-intrinsic)
- When `intrinsic`, the image will scale the dimensions down for smaller viewports, but maintain the original dimensions for larger viewports.

View file

@ -372,7 +372,7 @@ export default function Image({
priority = false,
loading,
lazyRoot = null,
lazyBoundary = '200px',
lazyBoundary,
className,
quality,
width,
@ -464,10 +464,10 @@ export default function Image({
const [setIntersection, isIntersected, resetIntersected] =
useIntersection<HTMLImageElement>({
rootRef: lazyRoot,
rootMargin: lazyBoundary,
rootMargin: lazyBoundary || '200px',
disabled: !isLazy,
})
const isVisible = !isLazy || isIntersected
const isVisible = !isLazy || isIntersected || layout === 'raw'
const wrapperStyle: JSX.IntrinsicElements['span']['style'] = {
boxSizing: 'border-box',
@ -566,10 +566,27 @@ export default function Image({
`Image with src "${src}" has both "priority" and "loading='lazy'" properties. Only one should be used.`
)
}
if (layout === 'raw' && (objectFit || objectPosition)) {
throw new Error(
`Image with src "${src}" has "layout='raw'" and 'objectFit' or 'objectPosition'. For raw images, these and other styles should be specified using the 'style' attribute.`
)
if (layout === 'raw') {
if (objectFit) {
throw new Error(
`Image with src "${src}" has "layout='raw'" and "objectFit='${objectFit}'". For raw images, these and other styles should be specified using the "style" attribute.`
)
}
if (objectPosition) {
throw new Error(
`Image with src "${src}" has "layout='raw'" and "objectPosition='${objectPosition}'". For raw images, these and other styles should be specified using the "style" attribute.`
)
}
if (lazyRoot) {
throw new Error(
`Image with src "${src}" has "layout='raw'" and "lazyRoot='${lazyRoot}'". For raw images, native lazy loading is used so "lazyRoot" cannot be used.`
)
}
if (lazyBoundary) {
throw new Error(
`Image with src "${src}" has "layout='raw'" and "lazyBoundary='${lazyBoundary}'". For raw images, native lazy loading is used so "lazyBoundary" cannot be used.`
)
}
}
if (
sizes &&
@ -882,7 +899,7 @@ const ImageElement = ({
blurStyle,
isLazy,
placeholder,
loading,
loading = 'lazy',
srcString,
config,
unoptimized,
@ -904,6 +921,8 @@ const ImageElement = ({
decoding="async"
data-nimg={layout}
className={className}
// @ts-ignore - TODO: upgrade to `@types/react@17`
loading={layout === 'raw' ? loading : undefined}
style={{ ...imgStyle, ...blurStyle }}
ref={useCallback(
(img: ImgElementWithDataProp) => {
@ -974,7 +993,7 @@ const ImageElement = ({
style={imgStyle}
className={className}
// @ts-ignore - TODO: upgrade to `@types/react@17`
loading={loading || 'lazy'}
loading={loading}
/>
</noscript>
)}

View file

@ -0,0 +1,19 @@
import React from 'react'
import Image from 'next/image'
const Page = () => {
return (
<div>
<Image
id="invalid-raw-lazy-boundary"
layout="raw"
src="/test.jpg"
width={200}
height={200}
lazyBoundary="500px"
/>
</div>
)
}
export default Page

View file

@ -0,0 +1,27 @@
import React from 'react'
import Image from 'next/image'
import testJPG from '../public/test.jpg'
import testPNG from '../public/test.png'
const Page = () => {
return (
<div id="container">
<h1>Layout Raw with Placeholder Blur</h1>
<p>Scroll down...</p>
<div style={{ height: '1000vh' }} />
<Image id="raw1" layout="raw" placeholder="blur" src={testJPG} />
<div style={{ height: '1000vh' }} />
<Image
id="raw2"
layout="raw"
placeholder="blur"
src={testPNG}
sizes="50vw"
/>
<footer>Footer</footer>
</div>
)
}
export default Page

View file

@ -713,6 +713,9 @@ function runTests(mode) {
expect(await browser.elementById('raw1').getAttribute('srcset')).toBe(
`/_next/image?url=%2Fwide.png&w=1200&q=75 1x, /_next/image?url=%2Fwide.png&w=3840&q=75 2x`
)
expect(await browser.elementById('raw1').getAttribute('loading')).toBe(
'eager'
)
expect(await browser.elementById('raw2').getAttribute('style')).toBe(
'padding-left:4rem;width:100%;object-position:30% 30%'
@ -726,6 +729,9 @@ function runTests(mode) {
expect(await browser.elementById('raw2').getAttribute('srcset')).toBe(
`/_next/image?url=%2Fwide.png&w=16&q=75 16w, /_next/image?url=%2Fwide.png&w=32&q=75 32w, /_next/image?url=%2Fwide.png&w=48&q=75 48w, /_next/image?url=%2Fwide.png&w=64&q=75 64w, /_next/image?url=%2Fwide.png&w=96&q=75 96w, /_next/image?url=%2Fwide.png&w=128&q=75 128w, /_next/image?url=%2Fwide.png&w=256&q=75 256w, /_next/image?url=%2Fwide.png&w=384&q=75 384w, /_next/image?url=%2Fwide.png&w=640&q=75 640w, /_next/image?url=%2Fwide.png&w=750&q=75 750w, /_next/image?url=%2Fwide.png&w=828&q=75 828w, /_next/image?url=%2Fwide.png&w=1080&q=75 1080w, /_next/image?url=%2Fwide.png&w=1200&q=75 1200w, /_next/image?url=%2Fwide.png&w=1920&q=75 1920w, /_next/image?url=%2Fwide.png&w=2048&q=75 2048w, /_next/image?url=%2Fwide.png&w=3840&q=75 3840w`
)
expect(await browser.elementById('raw2').getAttribute('loading')).toBe(
'lazy'
)
expect(await browser.elementById('raw3').getAttribute('style')).toBeNull()
expect(await browser.elementById('raw3').getAttribute('srcset')).toBe(
@ -749,6 +755,85 @@ function runTests(mode) {
}
}
})
it('should lazy load layout=raw and placeholder=blur', async () => {
const browser = await webdriver(appPort, '/layout-raw-placeholder-blur')
// raw1
expect(await browser.elementById('raw1').getAttribute('src')).toBe(
'/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.fab2915d.jpg&w=828&q=75'
)
expect(await browser.elementById('raw1').getAttribute('srcset')).toBe(
'/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.fab2915d.jpg&w=640&q=75 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.fab2915d.jpg&w=828&q=75 2x'
)
expect(await browser.elementById('raw1').getAttribute('loading')).toBe(
'lazy'
)
expect(await browser.elementById('raw1').getAttribute('sizes')).toBeNull()
expect(await browser.elementById('raw1').getAttribute('style')).toMatch(
'filter:blur(20px);background-size:cover;'
)
expect(await browser.elementById('raw1').getAttribute('height')).toBe('400')
expect(await browser.elementById('raw1').getAttribute('width')).toBe('400')
await browser.eval(
`document.getElementById("raw1").scrollIntoView({behavior: "smooth"})`
)
await check(
() => browser.eval(`document.getElementById("raw1").currentSrc`),
/test(.*)jpg/
)
expect(await browser.elementById('raw1').getAttribute('src')).toBe(
'/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.fab2915d.jpg&w=828&q=75'
)
expect(await browser.elementById('raw1').getAttribute('srcset')).toBe(
'/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.fab2915d.jpg&w=640&q=75 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.fab2915d.jpg&w=828&q=75 2x'
)
expect(await browser.elementById('raw1').getAttribute('loading')).toBe(
'lazy'
)
expect(await browser.elementById('raw1').getAttribute('sizes')).toBeNull()
expect(await browser.elementById('raw1').getAttribute('style')).toMatch('')
expect(await browser.elementById('raw1').getAttribute('height')).toBe('400')
expect(await browser.elementById('raw1').getAttribute('width')).toBe('400')
// raw2
expect(await browser.elementById('raw2').getAttribute('src')).toBe(
'/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=3840&q=75'
)
expect(await browser.elementById('raw2').getAttribute('srcset')).toBe(
'/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=384&q=75 384w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=640&q=75 640w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=750&q=75 750w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=828&q=75 828w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=1080&q=75 1080w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=1200&q=75 1200w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=1920&q=75 1920w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=2048&q=75 2048w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=3840&q=75 3840w'
)
expect(await browser.elementById('raw2').getAttribute('sizes')).toBe('50vw')
expect(await browser.elementById('raw2').getAttribute('loading')).toBe(
'lazy'
)
expect(await browser.elementById('raw2').getAttribute('style')).toMatch(
'filter:blur(20px);background-size:cover;'
)
expect(await browser.elementById('raw2').getAttribute('height')).toBe('400')
expect(await browser.elementById('raw2').getAttribute('width')).toBe('400')
await browser.eval(
`document.getElementById("raw2").scrollIntoView({behavior: "smooth"})`
)
await check(
() => browser.eval(`document.getElementById("raw2").currentSrc`),
/test(.*)png/
)
expect(await browser.elementById('raw2').getAttribute('src')).toBe(
'/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=3840&q=75'
)
expect(await browser.elementById('raw2').getAttribute('srcset')).toBe(
'/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=384&q=75 384w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=640&q=75 640w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=750&q=75 750w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=828&q=75 828w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=1080&q=75 1080w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=1200&q=75 1200w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=1920&q=75 1920w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=2048&q=75 2048w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=3840&q=75 3840w'
)
expect(await browser.elementById('raw2').getAttribute('sizes')).toBe('50vw')
expect(await browser.elementById('raw2').getAttribute('loading')).toBe(
'lazy'
)
expect(await browser.elementById('raw2').getAttribute('style')).toBe('')
expect(await browser.elementById('raw2').getAttribute('height')).toBe('400')
expect(await browser.elementById('raw2').getAttribute('width')).toBe('400')
})
it('should handle the styles prop appropriately', async () => {
let browser
try {
@ -862,6 +947,15 @@ function runTests(mode) {
)
})
it('should show error when layout=raw and lazyBoundary assigned', async () => {
const browser = await webdriver(appPort, '/invalid-raw-lazy-boundary')
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxHeader(browser)).toContain(
`Image with src "/test.jpg" has "layout='raw'" and "lazyBoundary='500px'". For raw images, native lazy loading is used so "lazyBoundary" cannot be used.`
)
})
it('should warn when img with layout=responsive is inside flex container', async () => {
const browser = await webdriver(appPort, '/layout-responsive-inside-flex')
await browser.eval(`document.getElementById("img").scrollIntoView()`)