diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index d557ea7e6400f..06c81e7b03203 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -121,7 +121,7 @@ The following Image Optimization cloud providers are included: - [Imgix](https://www.imgix.com): `loader: 'imgix'` - [Cloudinary](https://cloudinary.com): `loader: 'cloudinary'` - [Akamai](https://www.akamai.com): `loader: 'akamai'` -- dangerously-unoptimized: uses image directly from `src` prop without optimization (only needed with `next export` or when the built-in `_next/image` optimizer can not be leveraged) `loader: 'dangerously-unoptimized'` +- Custom: `loader: 'custom'` use a custom cloud provider by implementing the [`loader`](/docs/api-reference/next/image.md#loader) prop on the `next/image` component - Default: Works automatically with `next dev`, `next start`, or a custom server If you need a different provider, you can use the [`loader`](/docs/api-reference/next/image.md#loader) prop with `next/image`. diff --git a/errors/manifest.json b/errors/manifest.json index 17059bb427d90..7232ca54cb4fa 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -249,6 +249,14 @@ "title": "next-head-count-missing", "path": "/errors/next-head-count-missing.md" }, + { + "title": "next-image-missing-loader", + "path": "/errors/next-image-missing-loader.md" + }, + { + "title": "next-image-missing-loader-width", + "path": "/errors/next-image-missing-loader-width.md" + }, { "title": "next-image-unconfigured-host", "path": "/errors/next-image-unconfigured-host.md" diff --git a/errors/next-image-missing-loader-width.md b/errors/next-image-missing-loader-width.md new file mode 100644 index 0000000000000..0df5c6efa7124 --- /dev/null +++ b/errors/next-image-missing-loader-width.md @@ -0,0 +1,17 @@ +# Missing `width` in the URL Returned by the Loader Prop on `next/image` + +#### Why This Error Occurred + +The [`loader`](https://nextjs.org/docs/api-reference/next/image#loader) prop on the `next/image` component allows you to override the built-in URL resolution with a custom implementation in order to support any 3rd party cloud provider that can perform Image Optimization. + +This error occurred because the provided `loader()` function did not use `width` in the returned URL string. This means that the image will likely not be resized and therefore degrade performance. + +#### Possible Ways to Fix It + +- Ensure your Image Optimization provider can resize images. Then use the `width` parameter from the [`loader()`](https://nextjs.org/docs/api-reference/next/image#loader) function to construct the correct URL string. +- Add the [`unoptimized`](https://nextjs.org/docs/api-reference/next/image#unoptimized) prop. + +### Useful Links + +- [Image Optimization Documentation](https://nextjs.org/docs/basic-features/image-optimization) +- [`next/image` Documentation](https://nextjs.org/docs/api-reference/next/image) diff --git a/errors/next-image-missing-loader.md b/errors/next-image-missing-loader.md new file mode 100644 index 0000000000000..9a03dcb26a3d9 --- /dev/null +++ b/errors/next-image-missing-loader.md @@ -0,0 +1,15 @@ +# Missing `loader` Prop on `next/image` + +#### Why This Error Occurred + +When using the `next/image` component with [`loader="custom"`](https://nextjs.org/docs/basic-features/image-optimization#loader) in `next.config.js`, you must provide the [`loader`](https://nextjs.org/docs/api-reference/next/image#loader) prop to the component with your custom implementation. + +#### Possible Ways to Fix It + +- Add the [`loader`](https://nextjs.org/docs/api-reference/next/image#loader) prop to all usages of the `next/image` component. +- Change the [`loader`](https://nextjs.org/docs/basic-features/image-optimization#loader) configuration in `next.config.js`. + +### Useful Links + +- [Image Optimization Documentation](https://nextjs.org/docs/basic-features/image-optimization) +- [`next/image` Documentation](https://nextjs.org/docs/api-reference/next/image) diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 68606c94ecfa8..e5af929ea357c 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -32,11 +32,11 @@ const loaders = new Map< LoaderValue, (props: DefaultImageLoaderProps) => string >([ + ['default', defaultLoader], ['imgix', imgixLoader], ['cloudinary', cloudinaryLoader], ['akamai', akamaiLoader], - ['default', defaultLoader], - ['dangerously-unoptimized', unoptimizedLoader], + ['custom', customLoader], ]) const VALID_LAYOUT_VALUES = [ @@ -338,6 +338,17 @@ export default function Image({ const heightInt = getInt(height) const qualityInt = getInt(quality) + let isLazy = + !priority && (loading === 'lazy' || typeof loading === 'undefined') + if (src.startsWith('data:')) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs + unoptimized = true + isLazy = false + } + if (typeof window !== 'undefined' && loadedImageURLs.has(src)) { + isLazy = false + } + if (process.env.NODE_ENV !== 'production') { if (!src) { throw new Error( @@ -404,16 +415,16 @@ export default function Image({ `Image with src "${src}" is using unsupported "ref" property. Consider using the "onLoadingComplete" property instead.` ) } - } - let isLazy = - !priority && (loading === 'lazy' || typeof loading === 'undefined') - if (src && src.startsWith('data:')) { - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs - unoptimized = true - isLazy = false - } - if (src && typeof window !== 'undefined' && loadedImageURLs.has(src)) { - isLazy = false + const rand = Math.floor(Math.random() * 1000) + 100 + if ( + !unoptimized && + !loader({ src, width: rand, quality: 75 }).includes(rand.toString()) + ) { + console.warn( + `Image with src "${src}" has a "loader" property that does not implement width. Please implement it or use the "unoptimized" property instead.` + + `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader-width` + ) + } } const [setRef, isIntersected] = useIntersection({ @@ -626,8 +637,6 @@ export default function Image({ ) } -//BUILT IN LOADERS - function normalizeSrc(src: string): string { return src[0] === '/' ? src.slice(1) : src } @@ -669,8 +678,11 @@ function cloudinaryLoader({ return `${root}${paramsString}${normalizeSrc(src)}` } -function unoptimizedLoader({ src }: DefaultImageLoaderProps): string { - return src +function customLoader({ src }: DefaultImageLoaderProps): string { + throw new Error( + `Image with src "${src}" is missing "loader" prop.` + + `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader` + ) } function defaultLoader({ diff --git a/packages/next/server/image-config.ts b/packages/next/server/image-config.ts index c257d558e011b..c1656abd4d147 100644 --- a/packages/next/server/image-config.ts +++ b/packages/next/server/image-config.ts @@ -3,7 +3,7 @@ export const VALID_LOADERS = [ 'imgix', 'cloudinary', 'akamai', - 'dangerously-unoptimized', + 'custom', ] as const export type LoaderValue = typeof VALID_LOADERS[number] diff --git a/test/integration/export-image-loader/pages/index.js b/test/integration/export-image-loader/pages/index.js index e888a5c1eb6e5..0968b8aee540e 100644 --- a/test/integration/export-image-loader/pages/index.js +++ b/test/integration/export-image-loader/pages/index.js @@ -1,8 +1,10 @@ import Image from 'next/image' +const loader = undefined + export default () => (

Should succeed during export

- icon + icon
) diff --git a/test/integration/export-image-loader/test/index.test.js b/test/integration/export-image-loader/test/index.test.js index b4f144756d996..ebc63aa6b93ae 100644 --- a/test/integration/export-image-loader/test/index.test.js +++ b/test/integration/export-image-loader/test/index.test.js @@ -9,6 +9,7 @@ jest.setTimeout(1000 * 60 * 5) const appDir = join(__dirname, '../') const outdir = join(appDir, 'out') const nextConfig = new File(join(appDir, 'next.config.js')) +const pagesIndexJs = new File(join(appDir, 'pages', 'index.js')) describe('Export with cloudinary loader next/image component', () => { beforeAll(async () => { @@ -44,16 +45,20 @@ describe('Export with cloudinary loader next/image component', () => { }) }) -describe('Export with dangerously-unoptimized loader next/image component', () => { +describe('Export with custom loader next/image component', () => { beforeAll(async () => { await nextConfig.replace( '{ /* replaceme */ }', JSON.stringify({ images: { - loader: 'dangerously-unoptimized', + loader: 'custom', }, }) ) + await pagesIndexJs.replace( + 'loader = undefined', + 'loader = ({src}) => "/custom" + src' + ) }) it('should build successfully', async () => { await fs.remove(join(appDir, '.next')) @@ -69,10 +74,11 @@ describe('Export with dangerously-unoptimized loader next/image component', () = it('should contain img element with same src in html output', async () => { const html = await fs.readFile(join(outdir, 'index.html')) const $ = cheerio.load(html) - expect($('img[alt="icon"]').attr('src')).toBe('/i.png') + expect($('img[alt="icon"]').attr('src')).toBe('/custom/i.png') }) afterAll(async () => { await nextConfig.restore() + await pagesIndexJs.restore() }) }) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 9e9594737a1c1..611677354289f 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -9,6 +9,7 @@ import { launchApp, nextBuild, nextStart, + renderViaHTTP, waitFor, } from 'next-test-utils' import isAnimated from 'next/dist/compiled/is-animated' @@ -730,7 +731,34 @@ describe('Image Optimizer', () => { await nextConfig.restore() expect(stderr).toContain( - 'Specified images.loader should be one of (default, imgix, cloudinary, akamai, dangerously-unoptimized), received invalid value (notreal)' + 'Specified images.loader should be one of (default, imgix, cloudinary, akamai, custom), received invalid value (notreal)' + ) + }) + + it('should error when loader=custom but loader prop is undefined', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + loader: 'custom', + }, + }) + ) + let output = '' + const appPort = await findPort() + app = await launchApp(appDir, appPort, { + onStderr(msg) { + output += msg || '' + }, + onStdout(msg) { + output += msg || '' + }, + }) + await renderViaHTTP(appPort, '/', {}) + await killApp(app).catch(() => {}) + await nextConfig.restore() + expect(output).toMatch( + /Error: Image with src "(.+)" is missing "loader" prop/ ) }) })