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

Image component foundation #17343

Merged
merged 19 commits into from Oct 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -631,7 +631,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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can pass imgSrc instead and avoid recomputing the 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 : ''}`
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
}

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'
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
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' } } },
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
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