diff --git a/goldens/public-api/common/errors.md b/goldens/public-api/common/errors.md
index e24e5d7adf2267..92b257c65a96e3 100644
--- a/goldens/public-api/common/errors.md
+++ b/goldens/public-api/common/errors.md
@@ -15,6 +15,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
LCP_IMG_MISSING_PRIORITY = 2955,
// (undocumented)
+ MISSING_BUILTIN_LOADER = 2962,
+ // (undocumented)
NG_FOR_MISSING_DIFFER = -2200,
// (undocumented)
OVERSIZED_IMAGE = 2960,
diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts
index 6f233c30dbe354..7bb41b54a3a569 100644
--- a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts
+++ b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts
@@ -6,7 +6,23 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {createImageLoader, ImageLoaderConfig} from './image_loader';
+import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';
+
+/**
+ * Name and URL tester for Cloudinary.
+ */
+export const cloudinaryLoaderInfo: ImageLoaderInfo = {
+ name: 'Cloudinary',
+ testUrl: isCloudinaryUrl
+};
+
+const CLOUDINARY_LOADER_REGEX = /https?\:\/\/[^\/]+\.cloudinary\.com\/.+/;
+/**
+ * Tests whether a URL is from Cloudinary CDN.
+ */
+function isCloudinaryUrl(url: string): boolean {
+ return CLOUDINARY_LOADER_REGEX.test(url);
+}
/**
* Function that generates an ImageLoader for Cloudinary and turns it into an Angular provider.
diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts
index 382d2f020f43b5..4fe7d9383c84ba 100644
--- a/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts
+++ b/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts
@@ -46,7 +46,15 @@ export type ImageLoader = (config: ImageLoaderConfig) => string;
* @see `ImageLoader`
* @see `NgOptimizedImage`
*/
-const noopImageLoader = (config: ImageLoaderConfig) => config.src;
+export const noopImageLoader = (config: ImageLoaderConfig) => config.src;
+
+/**
+ * Metadata about the image loader.
+ */
+export type ImageLoaderInfo = {
+ name: string,
+ testUrl: (url: string) => boolean
+};
/**
* Injection token that configures the image loader function.
diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts
index 27c5795390dcd6..7f5dbb758e714d 100644
--- a/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts
+++ b/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts
@@ -6,7 +6,23 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {createImageLoader, ImageLoaderConfig} from './image_loader';
+import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';
+
+/**
+ * Name and URL tester for ImageKit.
+ */
+export const imageKitLoaderInfo: ImageLoaderInfo = {
+ name: 'ImageKit',
+ testUrl: isImageKitUrl
+};
+
+const IMAGE_KIT_LOADER_REGEX = /https?\:\/\/[^\/]+\.imagekit\.io\/.+/;
+/**
+ * Tests whether a URL is from ImageKit CDN.
+ */
+function isImageKitUrl(url: string): boolean {
+ return IMAGE_KIT_LOADER_REGEX.test(url);
+}
/**
* Function that generates an ImageLoader for ImageKit and turns it into an Angular provider.
diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts
index 4e7f89a4eda450..f3ce35a6fbd535 100644
--- a/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts
+++ b/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts
@@ -6,7 +6,23 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {createImageLoader, ImageLoaderConfig} from './image_loader';
+import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';
+
+/**
+ * Name and URL tester for Imgix.
+ */
+export const imgixLoaderInfo: ImageLoaderInfo = {
+ name: 'Imgix',
+ testUrl: isImgixUrl
+};
+
+const IMGIX_LOADER_REGEX = /https?\:\/\/[^\/]+\.imgix\.net\/.+/;
+/**
+ * Tests whether a URL is from Imgix CDN.
+ */
+function isImgixUrl(url: string): boolean {
+ return IMGIX_LOADER_REGEX.test(url);
+}
/**
* Function that generates an ImageLoader for Imgix and turns it into an Angular provider.
diff --git a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts
index d645a97e9fd7e1..1f8d9d00619172 100644
--- a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts
+++ b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts
@@ -12,7 +12,10 @@ import {RuntimeErrorCode} from '../../errors';
import {isPlatformServer} from '../../platform_id';
import {imgDirectiveDetails} from './error_helper';
-import {IMAGE_LOADER} from './image_loaders/image_loader';
+import {cloudinaryLoaderInfo} from './image_loaders/cloudinary_loader';
+import {IMAGE_LOADER, ImageLoader, noopImageLoader} from './image_loaders/image_loader';
+import {imageKitLoaderInfo} from './image_loaders/imagekit_loader';
+import {imgixLoaderInfo} from './image_loaders/imgix_loader';
import {LCPImageObserver} from './lcp_image_observer';
import {PreconnectLinkChecker} from './preconnect_link_checker';
import {PreloadLinkCreator} from './preload-link-creator';
@@ -72,6 +75,9 @@ const ASPECT_RATIO_TOLERANCE = .1;
*/
const OVERSIZED_IMAGE_TOLERANCE = 1000;
+/** Info about built-in loaders we can test for. */
+export const BUILT_IN_LOADERS = [imgixLoaderInfo, imageKitLoaderInfo, cloudinaryLoaderInfo];
+
/**
* A configuration object for the NgOptimizedImage directive. Contains:
* - breakpoints: An array of integer breakpoints used to generate
@@ -385,6 +391,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
if (!this.ngSrcset) {
assertNoComplexSizes(this);
}
+ assertNotMissingBuiltInLoader(this.ngSrc, this.imageLoader);
if (this.priority) {
const checker = this.injector.get(PreconnectLinkChecker);
checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc);
@@ -873,3 +880,35 @@ function assertValidLoadingInput(dir: NgOptimizedImage) {
`To fix this, provide a valid value ("lazy", "eager", or "auto").`);
}
}
+
+/**
+ * Warns if NOT using a loader (falling back to the generic loader) and
+ * the image appears to be hosted on one of the image CDNs for which
+ * we do have a built-in image loader. Suggests switching to the
+ * built-in loader.
+ *
+ * @param ngSrc Value of the ngSrc attribute
+ * @param imageLoader ImageLoader provided
+ */
+function assertNotMissingBuiltInLoader(ngSrc: string, imageLoader: ImageLoader) {
+ if (imageLoader === noopImageLoader) {
+ let builtInLoaderName = '';
+ for (const loader of BUILT_IN_LOADERS) {
+ if (loader.testUrl(ngSrc)) {
+ builtInLoaderName = loader.name;
+ break;
+ }
+ }
+ if (builtInLoaderName) {
+ console.warn(formatRuntimeError(
+ RuntimeErrorCode.MISSING_BUILTIN_LOADER,
+ `NgOptimizedImage: It looks like your images may be hosted on the ` +
+ `${builtInLoaderName} CDN, but your app is not using Angular's ` +
+ `built-in loader for that CDN. We recommend switching to use ` +
+ `the built-in by calling \`provide${builtInLoaderName}Loader()\` ` +
+ `in your \`providers\` and passing it your instance's base URL. ` +
+ `If you don't want to use the built-in loader, define a custom ` +
+ `loader function using IMAGE_LOADER to silence this warning.`));
+ }
+ }
+}
diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts
index d8a3f06ca97f8d..d088c0bc160c12 100644
--- a/packages/common/src/errors.ts
+++ b/packages/common/src/errors.ts
@@ -32,4 +32,5 @@ export const enum RuntimeErrorCode {
INVALID_LOADER_ARGUMENTS = 2959,
OVERSIZED_IMAGE = 2960,
TOO_MANY_PRELOADED_IMAGES = 2961,
+ MISSING_BUILTIN_LOADER = 2962,
}
diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts
index 8870960cee7150..9301487e7e4ff6 100644
--- a/packages/common/test/directives/ng_optimized_image_spec.ts
+++ b/packages/common/test/directives/ng_optimized_image_spec.ts
@@ -1097,6 +1097,56 @@ describe('Image directive', () => {
expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
});
+ it('should warn if there is no image loader but using Imgix URL', () => {
+ setUpModuleNoLoader();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ const consoleWarnSpy = spyOn(console, 'warn');
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toMatch(/your images may be hosted on the Imgix CDN/);
+ });
+
+ it('should warn if there is no image loader but using ImageKit URL', () => {
+ setUpModuleNoLoader();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ const consoleWarnSpy = spyOn(console, 'warn');
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toMatch(/your images may be hosted on the ImageKit CDN/);
+ });
+
+ it('should warn if there is no image loader but using Cloudinary URL', () => {
+ setUpModuleNoLoader();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ const consoleWarnSpy = spyOn(console, 'warn');
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toMatch(/your images may be hosted on the Cloudinary CDN/);
+ });
+
+ it('should NOT warn if there is a custom loader but using CDN URL', () => {
+ setupTestingModule();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ const consoleWarnSpy = spyOn(console, 'warn');
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ });
+
it('should set `src` using the image loader provided via the `IMAGE_LOADER` token to compose src URL',
() => {
const imageLoader = (config: ImageLoaderConfig) => `${IMG_BASE_URL}/${config.src}`;
@@ -1526,6 +1576,16 @@ function setupTestingModule(config?: {
});
}
+// Same as above but explicitly doesn't provide a custom loader,
+// so the noopImageLoader should be used.
+function setUpModuleNoLoader() {
+ TestBed.configureTestingModule({
+ declarations: [TestComponent],
+ imports: [CommonModule, NgOptimizedImage],
+ providers: [{provide: DOCUMENT, useValue: window.document}]
+ });
+}
+
function createTestComponent(template: string): ComponentFixture {
return TestBed.overrideComponent(TestComponent, {set: {template: template}})
.createComponent(TestComponent);