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 8 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
3 changes: 3 additions & 0 deletions packages/next/build/index.ts
Expand Up @@ -485,6 +485,9 @@ export default async function build(
let namedExports: Array<string> | undefined

process.env.NEXT_PHASE = PHASE_PRODUCTION_BUILD
if (config.images) {
process.env.__NEXT_IMAGE_OPTS = JSON.stringify(config.images)
}

const staticCheckWorkers = new Worker(staticCheckWorker, {
numWorkers: config.experimental.cpus,
Expand Down
Expand Up @@ -540,7 +540,9 @@ const nextServerlessLoader: loader.Loader = function () {

const previewData = tryGetPreviewData(req, res, options.previewProps)
const isPreviewMode = previewData !== false

if (process.env.__NEXT_IMAGE_OPTS) {
renderOpts.images = process.env.__NEXT_IMAGE_OPTS
}
if (process.env.__NEXT_OPTIMIZE_FONTS) {
renderOpts.optimizeFonts = true
/**
Expand Down
117 changes: 117 additions & 0 deletions packages/next/client/image.tsx
@@ -0,0 +1,117 @@
import React from 'react'

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[]
unoptimized: boolean
rest: any[]
}

let imageData: ImageData
if (typeof window === 'undefined') {
// Rendering on a server, get image data from env
imageData = JSON.parse(process.env.__NEXT_IMAGE_OPTS || '')
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
} else {
// Rendering on a client, get image data from window
imageData = JSON.parse((window as any).__NEXT_DATA__.images)
}
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(', ')
}

function Image({ src, host, sizes, unoptimized, ...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.`)
}
// Generate attribute values
const imgSrc = computeSrc(src, host || 'default', unoptimized)
const imgAttributes: { src: string; srcSet?: string } = { src: imgSrc }
if (!unoptimized) {
imgAttributes.srcSet = generateSrcSet({
src,
host: host || 'default',
widths: breakpoints,
})
}
return (
<div>
<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}`
}

export default Image
2 changes: 2 additions & 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 Expand Up @@ -283,6 +284,7 @@ export default async function exportPage({
params,
optimizeFonts,
optimizeImages,
images: process.env.__NEXT_IMAGE_OPTS,
fontManifest: optimizeFonts
? requireFontManifest(distDir, serverless)
: null,
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/lib/utils.ts
Expand Up @@ -94,6 +94,7 @@ export type NEXT_DATA = {
autoExport?: boolean
isFallback?: boolean
dynamicIds?: string[]
images: string
err?: Error & { statusCode?: number }
gsp?: boolean
gssp?: boolean
Expand Down
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
5 changes: 5 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 Expand Up @@ -243,6 +245,9 @@ export default class Server {
if (this.renderOpts.optimizeImages) {
process.env.__NEXT_OPTIMIZE_IMAGES = JSON.stringify(true)
}
if (this.nextConfig.images) {
process.env.__NEXT_IMAGE_OPTS = JSON.stringify(this.nextConfig.images)
}
}

protected currentPhase(): string {
Expand Down
4 changes: 4 additions & 0 deletions packages/next/next-server/server/render.tsx
Expand Up @@ -140,6 +140,7 @@ export type RenderOptsPartial = {
ampMode?: any
ampPath?: string
inAmpMode?: boolean
images: string
hybridAmp?: boolean
ErrorDebug?: React.ComponentType<{ error: Error }>
ampValidator?: (html: string, pathname: string) => Promise<void>
Expand Down Expand Up @@ -184,6 +185,7 @@ function renderDocument(
ampState,
inAmpMode,
hybridAmp,
images,
dynamicImports,
headTags,
gsp,
Expand All @@ -205,6 +207,7 @@ function renderDocument(
inAmpMode: boolean
hybridAmp: boolean
dynamicImportsIds: string[]
images: string
dynamicImports: ManifestItem[]
headTags: any
isFallback?: boolean
Expand All @@ -231,6 +234,7 @@ function renderDocument(
nextExport, // If this is a page exported by `next export`
autoExport, // If this is an auto exported page
isFallback,
images,
dynamicIds:
dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds,
err: err ? serializeError(dev, err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML
Expand Down
15 changes: 15 additions & 0 deletions test/integration/image-component/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],
},
}
25 changes: 25 additions & 0 deletions test/integration/image-component/pages/client-side.js
@@ -0,0 +1,25 @@
import React from 'react'
import Image from 'next/image'

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"
/>
</div>
)
}

export default ClientSide
30 changes: 30 additions & 0 deletions test/integration/image-component/pages/index.js
@@ -0,0 +1,30 @@
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"
/>
<Link href="/client-side">
<a id="clientlink">Client Side</a>
</Link>
<p id="stubtext">This is the index page</p>
</div>
)
}

export default Page
88 changes: 88 additions & 0 deletions test/integration/image-component/test/index.test.js
@@ -0,0 +1,88 @@
/* 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 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 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()
})
}

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()
})
describe('Client-side Image Component Tests', () => {
beforeAll(async () => {
browser = await webdriver(appPort, '/')
await browser.waitForElementByCss('#clientlink').click()
})
afterAll(async () => {
browser = null
})
runTests()
})
})