Skip to content

Commit

Permalink
Support for custom image loaders via image component prop (vercel#20216)
Browse files Browse the repository at this point in the history
This is a vercel#19325 reconfigured to support a loader passed in via a `loader` prop on the Image component, rather than using a config-based approach.

The idea is that applications wanting to use a custom loader will create a wrapper element for the  image component that incorporates that loader. See a simple example of this pattern in the integration tests. 

This solution is similar to the one prototyped by @ricokahler in vercel#20213 and described at vercel#18606 (comment)

---

Closes vercel#19325
Fixes vercel#18606
  • Loading branch information
atcastle committed Jan 5, 2021
1 parent b6c6770 commit c8bc17f
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 40 deletions.
101 changes: 61 additions & 40 deletions packages/next/client/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,20 @@ if (typeof window === 'undefined') {
const VALID_LOADING_VALUES = ['lazy', 'eager', undefined] as const
type LoadingValue = typeof VALID_LOADING_VALUES[number]

const loaders = new Map<LoaderValue, (props: LoaderProps) => string>([
export type ImageLoader = (resolverProps: ImageLoaderProps) => string

export type ImageLoaderProps = {
src: string
width: number
quality?: number
}

type DefaultImageLoaderProps = ImageLoaderProps & { root: string }

const loaders = new Map<
LoaderValue,
(props: DefaultImageLoaderProps) => string
>([
['imgix', imgixLoader],
['cloudinary', cloudinaryLoader],
['akamai', akamaiLoader],
Expand All @@ -39,6 +52,7 @@ export type ImageProps = Omit<
'src' | 'srcSet' | 'ref' | 'width' | 'height' | 'loading' | 'style'
> & {
src: string
loader?: ImageLoader
quality?: number | string
priority?: boolean
loading?: LoadingValue
Expand Down Expand Up @@ -103,28 +117,11 @@ function getWidths(
return { widths, kind: 'x' }
}

type CallLoaderProps = {
src: string
width: number
quality?: number
}

function callLoader(loaderProps: CallLoaderProps): string {
const load = loaders.get(configLoader)
if (load) {
return load({ root: configPath, ...loaderProps })
}
throw new Error(
`Unknown "loader" found in "next.config.js". Expected: ${VALID_LOADERS.join(
', '
)}. Received: ${configLoader}`
)
}

type GenImgAttrsData = {
src: string
unoptimized: boolean
layout: LayoutValue
loader: ImageLoader
width?: number
quality?: number
sizes?: string
Expand All @@ -143,6 +140,7 @@ function generateImgAttrs({
width,
quality,
sizes,
loader,
}: GenImgAttrsData): GenImgAttrsResult {
if (unoptimized) {
return { src, srcSet: undefined, sizes: undefined }
Expand All @@ -151,22 +149,18 @@ function generateImgAttrs({
const { widths, kind } = getWidths(width, layout)
const last = widths.length - 1

const srcSet = widths
.map(
(w, i) =>
`${callLoader({ src, quality, width: w })} ${
kind === 'w' ? w : i + 1
}${kind}`
)
.join(', ')

if (!sizes && kind === 'w') {
sizes = '100vw'
return {
src: loader({ src, quality, width: widths[last] }),
sizes: !sizes && kind === 'w' ? '100vw' : sizes,
srcSet: widths
.map(
(w, i) =>
`${loader({ src, quality, width: w })} ${
kind === 'w' ? w : i + 1
}${kind}`
)
.join(', '),
}

src = callLoader({ src, quality, width: widths[last] })

return { src, sizes, srcSet }
}

function getInt(x: unknown): number | undefined {
Expand All @@ -179,6 +173,18 @@ function getInt(x: unknown): number | undefined {
return undefined
}

function defaultImageLoader(loaderProps: ImageLoaderProps) {
const load = loaders.get(configLoader)
if (load) {
return load({ root: configPath, ...loaderProps })
}
throw new Error(
`Unknown "loader" found in "next.config.js". Expected: ${VALID_LOADERS.join(
', '
)}. Received: ${configLoader}`
)
}

export default function Image({
src,
sizes,
Expand All @@ -191,6 +197,7 @@ export default function Image({
height,
objectFit,
objectPosition,
loader = defaultImageLoader,
...all
}: ImageProps) {
let rest: Partial<ImageProps> = all
Expand Down Expand Up @@ -377,6 +384,7 @@ export default function Image({
width: widthInt,
quality: qualityInt,
sizes,
loader,
})
}

Expand Down Expand Up @@ -444,13 +452,16 @@ export default function Image({

//BUILT IN LOADERS

type LoaderProps = CallLoaderProps & { root: string }

function normalizeSrc(src: string): string {
return src[0] === '/' ? src.slice(1) : src
}

function imgixLoader({ root, src, width, quality }: LoaderProps): string {
function imgixLoader({
root,
src,
width,
quality,
}: DefaultImageLoaderProps): string {
// Demo: https://static.imgix.net/daisy.png?format=auto&fit=max&w=300
const params = ['auto=format', 'fit=max', 'w=' + width]
let paramsString = ''
Expand All @@ -464,18 +475,28 @@ function imgixLoader({ root, src, width, quality }: LoaderProps): string {
return `${root}${normalizeSrc(src)}${paramsString}`
}

function akamaiLoader({ root, src, width }: LoaderProps): string {
function akamaiLoader({ root, src, width }: DefaultImageLoaderProps): string {
return `${root}${normalizeSrc(src)}?imwidth=${width}`
}

function cloudinaryLoader({ root, src, width, quality }: LoaderProps): string {
function cloudinaryLoader({
root,
src,
width,
quality,
}: DefaultImageLoaderProps): string {
// Demo: https://res.cloudinary.com/demo/image/upload/w_300,c_limit,q_auto/turtles.jpg
const params = ['f_auto', 'c_limit', 'w_' + width, 'q_' + (quality || 'auto')]
let paramsString = params.join(',') + '/'
return `${root}${paramsString}${normalizeSrc(src)}`
}

function defaultLoader({ root, src, width, quality }: LoaderProps): string {
function defaultLoader({
root,
src,
width,
quality,
}: DefaultImageLoaderProps): string {
if (process.env.NODE_ENV !== 'production') {
const missingValues = []

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
images: {
deviceSizes: [480, 1024, 1600, 2000],
imageSizes: [16, 32, 48, 64],
path: 'https://globalresolver.com/myaccount/',
loader: 'imgix',
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react'
import Image from 'next/image'

const myLoader = ({ src, width, quality }) => {
return `https://customresolver.com/${src}?w~~${width},q~~${quality}`
}

const MyImage = (props) => {
return <Image loader={myLoader} {...props}></Image>
}

const Page = () => {
return (
<div>
<p>Image Client Side Test</p>
<MyImage
id="basic-image"
src="foo.jpg"
loading="eager"
width={300}
height={400}
quality={60}
/>
<Image
id="unoptimized-image"
unoptimized
src="https://arbitraryurl.com/foo.jpg"
loading="eager"
width={300}
height={400}
/>
</div>
)
}

export default Page
40 changes: 40 additions & 0 deletions test/integration/image-component/custom-resolver/pages/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react'
import Image from 'next/image'
import Link from 'next/link'

const myLoader = ({ src, width, quality }) => {
return `https://customresolver.com/${src}?w~~${width},q~~${quality}`
}

const MyImage = (props) => {
return <Image loader={myLoader} {...props}></Image>
}

const Page = () => {
return (
<div>
<p>Image SSR Test</p>
<MyImage
id="basic-image"
src="foo.jpg"
loading="eager"
width={300}
height={400}
quality={60}
/>
<Image
id="unoptimized-image"
unoptimized
src="https://arbitraryurl.com/foo.jpg"
loading="eager"
width={300}
height={400}
/>
<Link href="/client-side">
<a id="clientlink">Client Side</a>
</Link>
</div>
)
}

export default Page
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/* eslint-env jest */

import { join } from 'path'
import { killApp, findPort, nextStart, nextBuild } 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 use a custom resolver for image URL', async () => {
expect(await browser.elementById('basic-image').getAttribute('src')).toBe(
'https://customresolver.com/foo.jpg?w~~1024,q~~60'
)
})
it('should add a srcset based on the custom resolver', async () => {
expect(
await browser.elementById('basic-image').getAttribute('srcset')
).toBe(
'https://customresolver.com/foo.jpg?w~~480,q~~60 1x, https://customresolver.com/foo.jpg?w~~1024,q~~60 2x'
)
})
it('should support the unoptimized attribute', async () => {
expect(
await browser.elementById('unoptimized-image').getAttribute('src')
).toBe('https://arbitraryurl.com/foo.jpg')
})
}

describe('Custom Resolver Tests', () => {
beforeAll(async () => {
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
})
afterAll(() => killApp(app))
describe('SSR Custom Loader Tests', () => {
beforeAll(async () => {
browser = await webdriver(appPort, '/')
})
afterAll(async () => {
browser = null
})
runTests()
})
describe('Client-side Custom Loader Tests', () => {
beforeAll(async () => {
browser = await webdriver(appPort, '/')
await browser.waitForElementByCss('#clientlink').click()
})
afterAll(async () => {
browser = null
})
runTests()
})
})

0 comments on commit c8bc17f

Please sign in to comment.