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 14 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
17 changes: 17 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -229,6 +229,21 @@ 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 provide
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
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'
)
}
})
}
const reactVersion = await getPackageVersion({ cwd: dir, name: 'react' })
const hasReactRefresh: boolean = dev && !isServer
const hasJsxRuntime: boolean =
Expand Down Expand Up @@ -583,6 +598,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 +1000,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),
...(isServer
Expand Down
Expand Up @@ -540,7 +540,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
171 changes: 171 additions & 0 deletions packages/next/client/image.tsx
@@ -0,0 +1,171 @@
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) {
console.error(
atcastle marked this conversation as resolved.
Show resolved Hide resolved
`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 (unoptimized && host) {
console.error(`Image tag used specifying both a host and the unoptimized attribute--these are mutually exclusive.
atcastle marked this conversation as resolved.
Show resolved Hide resolved
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
console.error(`Image host identifier ${host} could not be resolved.`)
host = 'default'
}

host = host || 'default'
// 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}`
}
1 change: 1 addition & 0 deletions packages/next/export/worker.ts
Expand Up @@ -67,6 +67,7 @@ interface RenderOpts {
optimizeFonts?: boolean
optimizeImages?: boolean
fontManifest?: FontManifest
images?: any
}

type ComponentModule = ComponentType<{}> & {
Expand Down
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 @@ -122,6 +122,7 @@ export default class Server {
ampOptimizerConfig?: { [key: string]: any }
basePath: string
optimizeFonts: boolean
images: string
fontManifest: FontManifest
optimizeImages: boolean
}
Expand Down Expand Up @@ -170,6 +171,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],
},
}
30 changes: 30 additions & 0 deletions test/integration/image-component/basic/pages/client-side.js
@@ -0,0 +1,30 @@
import React from '../integration/image-component/basic/pages/react'
import Image from '../integration/image-component/basic/pages/next/image'
import Link from '../integration/image-component/basic/pages/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" />
<Link href="/errors">
<a id="errorslink">Errors</a>
</Link>
</div>
)
}

export default ClientSide