Image component foundation (#17343)

Co-authored-by: Tim Neutkens <timneutkens@me.com>
Co-authored-by: Tim Neutkens <tim@timneutkens.nl>
This commit is contained in:
Alex Castle 2020-10-14 02:57:10 -07:00 committed by GitHub
parent 9a5a1525bc
commit 87175fe9df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 564 additions and 1 deletions

View file

@ -229,6 +229,25 @@ export default async function getBaseWebpackConfig(
}
}
if (config.images?.hosts) {
if (!config.images.hosts.default) {
// If the image component is being used, a default host must be provided
throw new Error(
'If the image configuration property is present in next.config.js, it must have a host named "default"'
)
}
Object.values(config.images.hosts).forEach((host: any) => {
if (!host.path) {
throw new Error(
'All hosts defined in the image configuration property of next.config.js must define a path'
)
}
// Normalize hosts so all paths have trailing slash
if (host.path[host.path.length - 1] !== '/') {
host.path += '/'
}
})
}
const reactVersion = await getPackageVersion({ cwd: dir, name: 'react' })
const hasReactRefresh: boolean = dev && !isServer
const hasJsxRuntime: boolean =
@ -583,6 +602,7 @@ export default async function getBaseWebpackConfig(
'next/app',
'next/document',
'next/link',
'next/image',
'next/error',
'string-hash',
'next/constants',
@ -984,6 +1004,7 @@ export default async function getBaseWebpackConfig(
'process.env.__NEXT_SCROLL_RESTORATION': JSON.stringify(
config.experimental.scrollRestoration
),
'process.env.__NEXT_IMAGE_OPTS': JSON.stringify(config.images),
'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath),
'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites),
'process.env.__NEXT_i18n_SUPPORT': JSON.stringify(

View file

@ -684,7 +684,6 @@ const nextServerlessLoader: loader.Loader = function () {
const previewData = tryGetPreviewData(req, res, options.previewProps)
const isPreviewMode = previewData !== false
if (process.env.__NEXT_OPTIMIZE_FONTS) {
renderOpts.optimizeFonts = true
/**

View file

@ -0,0 +1,183 @@
import React, { ReactElement } from 'react'
import Head from '../next-server/lib/head'
const loaders: { [key: string]: (props: LoaderProps) => string } = {
imgix: imgixLoader,
cloudinary: cloudinaryLoader,
default: defaultLoader,
}
type ImageData = {
hosts: {
[key: string]: {
path: string
loader: string
}
}
breakpoints?: number[]
}
type ImageProps = {
src: string
host: string
sizes: string
breakpoints: number[]
priority: boolean
unoptimized: boolean
rest: any[]
}
let imageData: any = process.env.__NEXT_IMAGE_OPTS
const breakpoints = imageData.breakpoints || [640, 1024, 1600]
function computeSrc(src: string, host: string, unoptimized: boolean): string {
if (unoptimized) {
return src
}
if (!host) {
// No host provided, use default
return callLoader(src, 'default')
} else {
let selectedHost = imageData.hosts[host]
if (!selectedHost) {
if (process.env.NODE_ENV !== 'production') {
console.error(
`Image tag is used specifying host ${host}, but that host is not defined in next.config`
)
}
return src
}
return callLoader(src, host)
}
}
function callLoader(src: string, host: string, width?: number): string {
let loader = loaders[imageData.hosts[host].loader || 'default']
return loader({ root: imageData.hosts[host].path, filename: src, width })
}
type SrcSetData = {
src: string
host: string
widths: number[]
}
function generateSrcSet({ src, host, widths }: SrcSetData): string {
// At each breakpoint, generate an image url using the loader, such as:
// ' www.example.com/foo.jpg?w=480 480w, '
return widths
.map((width: number) => `${callLoader(src, host, width)} ${width}w`)
.join(', ')
}
type PreloadData = {
src: string
host: string
widths: number[]
sizes: string
unoptimized: boolean
}
function generatePreload({
src,
host,
widths,
unoptimized,
sizes,
}: PreloadData): ReactElement {
// This function generates an image preload that makes use of the "imagesrcset" and "imagesizes"
// attributes for preloading responsive images. They're still experimental, but fully backward
// compatible, as the link tag includes all necessary attributes, even if the final two are ignored.
// See: https://web.dev/preload-responsive-images/
return (
<Head>
<link
rel="preload"
as="image"
href={computeSrc(src, host, unoptimized)}
// @ts-ignore: imagesrcset and imagesizes not yet in the link element type
imagesrcset={generateSrcSet({ src, host, widths })}
imagesizes={sizes}
/>
</Head>
)
}
export default function Image({
src,
host,
sizes,
unoptimized,
priority,
...rest
}: ImageProps) {
// Sanity Checks:
if (process.env.NODE_ENV !== 'production') {
if (unoptimized && host) {
console.error(`Image tag used specifying both a host and the unoptimized attribute--these are mutually exclusive.
With the unoptimized attribute, no host will be used, so specify an absolute URL.`)
}
}
if (host && !imageData.hosts[host]) {
// If unregistered host is selected, log an error and use the default instead
if (process.env.NODE_ENV !== 'production') {
console.error(`Image host identifier ${host} could not be resolved.`)
}
host = 'default'
}
host = host || 'default'
// Normalize provided src
if (src[0] === '/') {
src = src.slice(1)
}
// Generate attribute values
const imgSrc = computeSrc(src, host, unoptimized)
const imgAttributes: { src: string; srcSet?: string } = { src: imgSrc }
if (!unoptimized) {
imgAttributes.srcSet = generateSrcSet({
src,
host: host,
widths: breakpoints,
})
}
// No need to add preloads on the client side--by the time the application is hydrated,
// it's too late for preloads
const shouldPreload = priority && typeof window === 'undefined'
return (
<div>
{shouldPreload
? generatePreload({
src,
host,
widths: breakpoints,
unoptimized,
sizes,
})
: ''}
<img {...rest} {...imgAttributes} sizes={sizes} />
</div>
)
}
//BUILT IN LOADERS
type LoaderProps = {
root: string
filename: string
width?: number
}
function imgixLoader({ root, filename, width }: LoaderProps): string {
return `${root}${filename}${width ? '?w=' + width : ''}`
}
function cloudinaryLoader({ root, filename, width }: LoaderProps): string {
return `${root}${width ? 'w_' + width + '/' : ''}${filename}`
}
function defaultLoader({ root, filename }: LoaderProps): string {
return `${root}${filename}`
}

2
packages/next/image.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
export * from './dist/client/image'
export { default } from './dist/client/image'

1
packages/next/image.js Normal file
View file

@ -0,0 +1 @@
module.exports = require('./dist/client/image')

View file

@ -23,6 +23,7 @@ const defaultConfig: { [key: string]: any } = {
target: 'server',
poweredByHeader: true,
compress: true,
images: { hosts: { default: { path: 'defaultconfig' } } },
devIndicators: {
buildActivity: true,
autoPrerender: true,

View file

@ -137,6 +137,7 @@ export default class Server {
ampOptimizerConfig?: { [key: string]: any }
basePath: string
optimizeFonts: boolean
images: string
fontManifest: FontManifest
optimizeImages: boolean
locale?: string
@ -188,6 +189,7 @@ export default class Server {
customServer: customServer === true ? true : undefined,
ampOptimizerConfig: this.nextConfig.experimental.amp?.optimizer,
basePath: this.nextConfig.basePath,
images: JSON.stringify(this.nextConfig.images),
optimizeFonts: this.nextConfig.experimental.optimizeFonts && !dev,
fontManifest:
this.nextConfig.experimental.optimizeFonts && !dev

View file

@ -0,0 +1,7 @@
import React from 'react'
const page = () => {
return <div>Hello</div>
}
export default page

View file

@ -0,0 +1,76 @@
/* eslint-env jest */
import { join } from 'path'
import { nextBuild } from 'next-test-utils'
import fs from 'fs-extra'
jest.setTimeout(1000 * 30)
const appDir = join(__dirname, '../')
const nextConfig = join(appDir, 'next.config.js')
describe('Next.config.js images prop without default host', () => {
let build
beforeAll(async () => {
await fs.writeFile(
nextConfig,
`module.exports = {
images: {
hosts: {
secondary: {
path: 'https://examplesecondary.com/images/',
loader: 'cloudinary',
},
},
breakpoints: [480, 1024, 1600],
},
}`,
'utf8'
)
build = await nextBuild(appDir, [], {
stdout: true,
stderr: true,
})
})
it('Should error during build if images prop in next.config is malformed', () => {
expect(build.stderr).toContain(
'If the image configuration property is present in next.config.js, it must have a host named "default"'
)
})
})
describe('Next.config.js images prop without path', () => {
let build
beforeAll(async () => {
await fs.writeFile(
nextConfig,
`module.exports = {
images: {
hosts: {
default: {
path: 'https://examplesecondary.com/images/',
loader: 'cloudinary',
},
secondary: {
loader: 'cloudinary',
},
},
breakpoints: [480, 1024, 1600],
},
}`,
'utf8'
)
build = await nextBuild(appDir, [], {
stdout: true,
stderr: true,
})
})
afterAll(async () => {
await fs.remove(nextConfig)
})
it('Should error during build if images prop in next.config is malformed', () => {
expect(build.stderr).toContain(
'All hosts defined in the image configuration property of next.config.js must define a path'
)
})
})

View file

@ -0,0 +1,15 @@
module.exports = {
images: {
hosts: {
default: {
path: 'https://example.com/myaccount/',
loader: 'imgix',
},
secondary: {
path: 'https://examplesecondary.com/images/',
loader: 'cloudinary',
},
},
breakpoints: [480, 1024, 1600],
},
}

View file

@ -0,0 +1,31 @@
import React from 'react'
import Image from 'next/image'
import Link from 'next/link'
const ClientSide = () => {
return (
<div>
<p id="stubtext">This is a client side page</p>
<Image id="basic-image" src="foo.jpg"></Image>
<Image id="attribute-test" data-demo="demo-value" src="bar.jpg" />
<Image
id="secondary-image"
data-demo="demo-value"
host="secondary"
src="foo2.jpg"
/>
<Image
id="unoptimized-image"
unoptimized
src="https://arbitraryurl.com/foo.jpg"
/>
<Image id="priority-image-client" priority src="withpriorityclient.png" />
<Image id="preceding-slash-image" src="/fooslash.jpg" priority />
<Link href="/errors">
<a id="errorslink">Errors</a>
</Link>
</div>
)
}
export default ClientSide

View file

@ -0,0 +1,13 @@
import React from 'react'
import Image from 'next/image'
const Errors = () => {
return (
<div>
<p id="stubtext">This is a page with errors</p>
<Image id="nonexistant-host" host="nope" src="wronghost.jpg"></Image>
</div>
)
}
export default Errors

View file

@ -0,0 +1,44 @@
import React from 'react'
import Image from 'next/image'
import Link from 'next/link'
const Page = () => {
return (
<div>
<p>Hello World</p>
<Image id="basic-image" src="foo.jpg"></Image>
<Image id="attribute-test" data-demo="demo-value" src="bar.jpg" />
<Image
id="secondary-image"
data-demo="demo-value"
host="secondary"
src="foo2.jpg"
/>
<Image
id="unoptimized-image"
unoptimized
src="https://arbitraryurl.com/foo.jpg"
/>
<Image id="priority-image" priority src="withpriority.png" />
<Image
id="priority-image"
priority
host="secondary"
src="withpriority2.png"
/>
<Image
id="priority-image"
priority
unoptimized
src="https://arbitraryurl.com/withpriority3.png"
/>
<Image id="preceding-slash-image" src="/fooslash.jpg" priority />
<Link href="/client-side">
<a id="clientlink">Client Side</a>
</Link>
<p id="stubtext">This is the index page</p>
</div>
)
}
export default Page

View file

@ -0,0 +1,168 @@
/* eslint-env jest */
import { join } from 'path'
import {
killApp,
findPort,
nextStart,
nextBuild,
waitFor,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
jest.setTimeout(1000 * 30)
const appDir = join(__dirname, '../')
let appPort
let app
let browser
function runTests() {
it('should render an image tag', async () => {
await waitFor(1000)
expect(await browser.hasElementByCssSelector('img')).toBeTruthy()
})
it('should support passing through arbitrary attributes', async () => {
expect(
await browser.hasElementByCssSelector('img#attribute-test')
).toBeTruthy()
expect(
await browser.elementByCss('img#attribute-test').getAttribute('data-demo')
).toBe('demo-value')
})
it('should modify src with the loader', async () => {
expect(await browser.elementById('basic-image').getAttribute('src')).toBe(
'https://example.com/myaccount/foo.jpg'
)
})
it('should correctly generate src even if preceding slash is included in prop', async () => {
expect(
await browser.elementById('preceding-slash-image').getAttribute('src')
).toBe('https://example.com/myaccount/fooslash.jpg')
})
it('should support manually selecting a different host', async () => {
expect(
await browser.elementById('secondary-image').getAttribute('src')
).toBe('https://examplesecondary.com/images/foo2.jpg')
})
it('should add a srcset based on the loader', async () => {
expect(
await browser.elementById('basic-image').getAttribute('srcset')
).toBe(
'https://example.com/myaccount/foo.jpg?w=480 480w, https://example.com/myaccount/foo.jpg?w=1024 1024w, https://example.com/myaccount/foo.jpg?w=1600 1600w'
)
})
it('should add a srcset even with preceding slash in prop', async () => {
expect(
await browser.elementById('preceding-slash-image').getAttribute('srcset')
).toBe(
'https://example.com/myaccount/fooslash.jpg?w=480 480w, https://example.com/myaccount/fooslash.jpg?w=1024 1024w, https://example.com/myaccount/fooslash.jpg?w=1600 1600w'
)
})
it('should support the unoptimized attribute', async () => {
expect(
await browser.elementById('unoptimized-image').getAttribute('src')
).toBe('https://arbitraryurl.com/foo.jpg')
})
it('should not add a srcset if unoptimized attribute present', async () => {
expect(
await browser.elementById('unoptimized-image').getAttribute('srcset')
).toBeFalsy()
})
}
async function hasPreloadLinkMatchingUrl(url) {
const links = await browser.elementsByCss('link')
let foundMatch = false
for (const link of links) {
const rel = await link.getAttribute('rel')
const href = await link.getAttribute('href')
if (rel === 'preload' && href === url) {
foundMatch = true
break
}
}
return foundMatch
}
describe('Image Component Tests', () => {
beforeAll(async () => {
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
})
afterAll(() => killApp(app))
describe('SSR Image Component Tests', () => {
beforeAll(async () => {
browser = await webdriver(appPort, '/')
})
afterAll(async () => {
browser = null
})
runTests()
it('should add a preload tag for a priority image', async () => {
expect(
await hasPreloadLinkMatchingUrl(
'https://example.com/myaccount/withpriority.png'
)
).toBe(true)
})
it('should add a preload tag for a priority image with preceding slash', async () => {
expect(
await hasPreloadLinkMatchingUrl(
'https://example.com/myaccount/fooslash.jpg'
)
).toBe(true)
})
it('should add a preload tag for a priority image, with secondary host', async () => {
expect(
await hasPreloadLinkMatchingUrl(
'https://examplesecondary.com/images/withpriority2.png'
)
).toBe(true)
})
it('should add a preload tag for a priority image, with arbitrary host', async () => {
expect(
await hasPreloadLinkMatchingUrl(
'https://arbitraryurl.com/withpriority3.png'
)
).toBe(true)
})
})
describe('Client-side Image Component Tests', () => {
beforeAll(async () => {
browser = await webdriver(appPort, '/')
await browser.waitForElementByCss('#clientlink').click()
})
afterAll(async () => {
browser = null
})
runTests()
it('should NOT add a preload tag for a priority image', async () => {
expect(
await hasPreloadLinkMatchingUrl(
'https://example.com/myaccount/withpriorityclient.png'
)
).toBe(false)
})
describe('Client-side Errors', () => {
beforeAll(async () => {
await browser.eval(`(function() {
window.gotHostError = false
const origError = console.error
window.console.error = function () {
if (arguments[0].match(/Image host identifier/)) {
window.gotHostError = true
}
origError.apply(this, arguments)
}
})()`)
await browser.waitForElementByCss('#errorslink').click()
})
it('Should not log an error when an unregistered host is used in production', async () => {
const foundError = await browser.eval('window.gotHostError')
expect(foundError).toBe(false)
})
})
})
})