Skip to content

Commit c8bc17f

Browse files
authored
Support for custom image loaders via image component prop (vercel#20216)
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
1 parent b6c6770 commit c8bc17f

File tree

5 files changed

+205
-40
lines changed

5 files changed

+205
-40
lines changed

packages/next/client/image.tsx

+61-40
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,20 @@ if (typeof window === 'undefined') {
1616
const VALID_LOADING_VALUES = ['lazy', 'eager', undefined] as const
1717
type LoadingValue = typeof VALID_LOADING_VALUES[number]
1818

19-
const loaders = new Map<LoaderValue, (props: LoaderProps) => string>([
19+
export type ImageLoader = (resolverProps: ImageLoaderProps) => string
20+
21+
export type ImageLoaderProps = {
22+
src: string
23+
width: number
24+
quality?: number
25+
}
26+
27+
type DefaultImageLoaderProps = ImageLoaderProps & { root: string }
28+
29+
const loaders = new Map<
30+
LoaderValue,
31+
(props: DefaultImageLoaderProps) => string
32+
>([
2033
['imgix', imgixLoader],
2134
['cloudinary', cloudinaryLoader],
2235
['akamai', akamaiLoader],
@@ -39,6 +52,7 @@ export type ImageProps = Omit<
3952
'src' | 'srcSet' | 'ref' | 'width' | 'height' | 'loading' | 'style'
4053
> & {
4154
src: string
55+
loader?: ImageLoader
4256
quality?: number | string
4357
priority?: boolean
4458
loading?: LoadingValue
@@ -103,28 +117,11 @@ function getWidths(
103117
return { widths, kind: 'x' }
104118
}
105119

106-
type CallLoaderProps = {
107-
src: string
108-
width: number
109-
quality?: number
110-
}
111-
112-
function callLoader(loaderProps: CallLoaderProps): string {
113-
const load = loaders.get(configLoader)
114-
if (load) {
115-
return load({ root: configPath, ...loaderProps })
116-
}
117-
throw new Error(
118-
`Unknown "loader" found in "next.config.js". Expected: ${VALID_LOADERS.join(
119-
', '
120-
)}. Received: ${configLoader}`
121-
)
122-
}
123-
124120
type GenImgAttrsData = {
125121
src: string
126122
unoptimized: boolean
127123
layout: LayoutValue
124+
loader: ImageLoader
128125
width?: number
129126
quality?: number
130127
sizes?: string
@@ -143,6 +140,7 @@ function generateImgAttrs({
143140
width,
144141
quality,
145142
sizes,
143+
loader,
146144
}: GenImgAttrsData): GenImgAttrsResult {
147145
if (unoptimized) {
148146
return { src, srcSet: undefined, sizes: undefined }
@@ -151,22 +149,18 @@ function generateImgAttrs({
151149
const { widths, kind } = getWidths(width, layout)
152150
const last = widths.length - 1
153151

154-
const srcSet = widths
155-
.map(
156-
(w, i) =>
157-
`${callLoader({ src, quality, width: w })} ${
158-
kind === 'w' ? w : i + 1
159-
}${kind}`
160-
)
161-
.join(', ')
162-
163-
if (!sizes && kind === 'w') {
164-
sizes = '100vw'
152+
return {
153+
src: loader({ src, quality, width: widths[last] }),
154+
sizes: !sizes && kind === 'w' ? '100vw' : sizes,
155+
srcSet: widths
156+
.map(
157+
(w, i) =>
158+
`${loader({ src, quality, width: w })} ${
159+
kind === 'w' ? w : i + 1
160+
}${kind}`
161+
)
162+
.join(', '),
165163
}
166-
167-
src = callLoader({ src, quality, width: widths[last] })
168-
169-
return { src, sizes, srcSet }
170164
}
171165

172166
function getInt(x: unknown): number | undefined {
@@ -179,6 +173,18 @@ function getInt(x: unknown): number | undefined {
179173
return undefined
180174
}
181175

176+
function defaultImageLoader(loaderProps: ImageLoaderProps) {
177+
const load = loaders.get(configLoader)
178+
if (load) {
179+
return load({ root: configPath, ...loaderProps })
180+
}
181+
throw new Error(
182+
`Unknown "loader" found in "next.config.js". Expected: ${VALID_LOADERS.join(
183+
', '
184+
)}. Received: ${configLoader}`
185+
)
186+
}
187+
182188
export default function Image({
183189
src,
184190
sizes,
@@ -191,6 +197,7 @@ export default function Image({
191197
height,
192198
objectFit,
193199
objectPosition,
200+
loader = defaultImageLoader,
194201
...all
195202
}: ImageProps) {
196203
let rest: Partial<ImageProps> = all
@@ -377,6 +384,7 @@ export default function Image({
377384
width: widthInt,
378385
quality: qualityInt,
379386
sizes,
387+
loader,
380388
})
381389
}
382390

@@ -444,13 +452,16 @@ export default function Image({
444452

445453
//BUILT IN LOADERS
446454

447-
type LoaderProps = CallLoaderProps & { root: string }
448-
449455
function normalizeSrc(src: string): string {
450456
return src[0] === '/' ? src.slice(1) : src
451457
}
452458

453-
function imgixLoader({ root, src, width, quality }: LoaderProps): string {
459+
function imgixLoader({
460+
root,
461+
src,
462+
width,
463+
quality,
464+
}: DefaultImageLoaderProps): string {
454465
// Demo: https://static.imgix.net/daisy.png?format=auto&fit=max&w=300
455466
const params = ['auto=format', 'fit=max', 'w=' + width]
456467
let paramsString = ''
@@ -464,18 +475,28 @@ function imgixLoader({ root, src, width, quality }: LoaderProps): string {
464475
return `${root}${normalizeSrc(src)}${paramsString}`
465476
}
466477

467-
function akamaiLoader({ root, src, width }: LoaderProps): string {
478+
function akamaiLoader({ root, src, width }: DefaultImageLoaderProps): string {
468479
return `${root}${normalizeSrc(src)}?imwidth=${width}`
469480
}
470481

471-
function cloudinaryLoader({ root, src, width, quality }: LoaderProps): string {
482+
function cloudinaryLoader({
483+
root,
484+
src,
485+
width,
486+
quality,
487+
}: DefaultImageLoaderProps): string {
472488
// Demo: https://res.cloudinary.com/demo/image/upload/w_300,c_limit,q_auto/turtles.jpg
473489
const params = ['f_auto', 'c_limit', 'w_' + width, 'q_' + (quality || 'auto')]
474490
let paramsString = params.join(',') + '/'
475491
return `${root}${paramsString}${normalizeSrc(src)}`
476492
}
477493

478-
function defaultLoader({ root, src, width, quality }: LoaderProps): string {
494+
function defaultLoader({
495+
root,
496+
src,
497+
width,
498+
quality,
499+
}: DefaultImageLoaderProps): string {
479500
if (process.env.NODE_ENV !== 'production') {
480501
const missingValues = []
481502

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module.exports = {
2+
images: {
3+
deviceSizes: [480, 1024, 1600, 2000],
4+
imageSizes: [16, 32, 48, 64],
5+
path: 'https://globalresolver.com/myaccount/',
6+
loader: 'imgix',
7+
},
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from 'react'
2+
import Image from 'next/image'
3+
4+
const myLoader = ({ src, width, quality }) => {
5+
return `https://customresolver.com/${src}?w~~${width},q~~${quality}`
6+
}
7+
8+
const MyImage = (props) => {
9+
return <Image loader={myLoader} {...props}></Image>
10+
}
11+
12+
const Page = () => {
13+
return (
14+
<div>
15+
<p>Image Client Side Test</p>
16+
<MyImage
17+
id="basic-image"
18+
src="foo.jpg"
19+
loading="eager"
20+
width={300}
21+
height={400}
22+
quality={60}
23+
/>
24+
<Image
25+
id="unoptimized-image"
26+
unoptimized
27+
src="https://arbitraryurl.com/foo.jpg"
28+
loading="eager"
29+
width={300}
30+
height={400}
31+
/>
32+
</div>
33+
)
34+
}
35+
36+
export default Page
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from 'react'
2+
import Image from 'next/image'
3+
import Link from 'next/link'
4+
5+
const myLoader = ({ src, width, quality }) => {
6+
return `https://customresolver.com/${src}?w~~${width},q~~${quality}`
7+
}
8+
9+
const MyImage = (props) => {
10+
return <Image loader={myLoader} {...props}></Image>
11+
}
12+
13+
const Page = () => {
14+
return (
15+
<div>
16+
<p>Image SSR Test</p>
17+
<MyImage
18+
id="basic-image"
19+
src="foo.jpg"
20+
loading="eager"
21+
width={300}
22+
height={400}
23+
quality={60}
24+
/>
25+
<Image
26+
id="unoptimized-image"
27+
unoptimized
28+
src="https://arbitraryurl.com/foo.jpg"
29+
loading="eager"
30+
width={300}
31+
height={400}
32+
/>
33+
<Link href="/client-side">
34+
<a id="clientlink">Client Side</a>
35+
</Link>
36+
</div>
37+
)
38+
}
39+
40+
export default Page
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* eslint-env jest */
2+
3+
import { join } from 'path'
4+
import { killApp, findPort, nextStart, nextBuild } from 'next-test-utils'
5+
import webdriver from 'next-webdriver'
6+
7+
jest.setTimeout(1000 * 30)
8+
9+
const appDir = join(__dirname, '../')
10+
let appPort
11+
let app
12+
let browser
13+
14+
function runTests() {
15+
it('Should use a custom resolver for image URL', async () => {
16+
expect(await browser.elementById('basic-image').getAttribute('src')).toBe(
17+
'https://customresolver.com/foo.jpg?w~~1024,q~~60'
18+
)
19+
})
20+
it('should add a srcset based on the custom resolver', async () => {
21+
expect(
22+
await browser.elementById('basic-image').getAttribute('srcset')
23+
).toBe(
24+
'https://customresolver.com/foo.jpg?w~~480,q~~60 1x, https://customresolver.com/foo.jpg?w~~1024,q~~60 2x'
25+
)
26+
})
27+
it('should support the unoptimized attribute', async () => {
28+
expect(
29+
await browser.elementById('unoptimized-image').getAttribute('src')
30+
).toBe('https://arbitraryurl.com/foo.jpg')
31+
})
32+
}
33+
34+
describe('Custom Resolver Tests', () => {
35+
beforeAll(async () => {
36+
await nextBuild(appDir)
37+
appPort = await findPort()
38+
app = await nextStart(appDir, appPort)
39+
})
40+
afterAll(() => killApp(app))
41+
describe('SSR Custom Loader Tests', () => {
42+
beforeAll(async () => {
43+
browser = await webdriver(appPort, '/')
44+
})
45+
afterAll(async () => {
46+
browser = null
47+
})
48+
runTests()
49+
})
50+
describe('Client-side Custom Loader Tests', () => {
51+
beforeAll(async () => {
52+
browser = await webdriver(appPort, '/')
53+
await browser.waitForElementByCss('#clientlink').click()
54+
})
55+
afterAll(async () => {
56+
browser = null
57+
})
58+
runTests()
59+
})
60+
})

0 commit comments

Comments
 (0)