Skip to content

Commit

Permalink
Image component foundation (#17343)
Browse files Browse the repository at this point in the history
Co-authored-by: Tim Neutkens <timneutkens@me.com>
Co-authored-by: Tim Neutkens <tim@timneutkens.nl>
  • Loading branch information
3 people committed Oct 14, 2020
1 parent 9a5a152 commit 87175fe
Show file tree
Hide file tree
Showing 14 changed files with 564 additions and 1 deletion.
21 changes: 21 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -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 =
Expand Down Expand Up @@ -583,6 +602,7 @@ export default async function getBaseWebpackConfig(
'next/app',
'next/document',
'next/link',
'next/image',
'next/error',
'string-hash',
'next/constants',
Expand Down Expand Up @@ -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(
Expand Down
Expand Up @@ -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
/**
Expand Down
183 changes: 183 additions & 0 deletions packages/next/client/image.tsx
@@ -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 changes: 2 additions & 0 deletions packages/next/image.d.ts
@@ -0,0 +1,2 @@
export * from './dist/client/image'
export { default } from './dist/client/image'
1 change: 1 addition & 0 deletions packages/next/image.js
@@ -0,0 +1 @@
module.exports = require('./dist/client/image')
1 change: 1 addition & 0 deletions packages/next/next-server/server/config.ts
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/next/next-server/server/next-server.ts
Expand Up @@ -137,6 +137,7 @@ export default class Server {
ampOptimizerConfig?: { [key: string]: any }
basePath: string
optimizeFonts: boolean
images: string
fontManifest: FontManifest
optimizeImages: boolean
locale?: string
Expand Down Expand Up @@ -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
Expand Down
@@ -0,0 +1,7 @@
import React from 'react'

const page = () => {
return <div>Hello</div>
}

export default page
@@ -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'
)
})
})
15 changes: 15 additions & 0 deletions test/integration/image-component/basic/next.config.js
@@ -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],
},
}
31 changes: 31 additions & 0 deletions test/integration/image-component/basic/pages/client-side.js
@@ -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

0 comments on commit 87175fe

Please sign in to comment.