Skip to content

Commit 23f210c

Browse files
karadylhunn
authored andcommitted
fix(common): warn if using supported CDN but not built-in loader (#47330)
This commit adds a missing warning if the image directive detects that you're hosting your image on one of our supported image CDNs but you're not using the built-in loader for it. This excludes applications that are using a custom loader. PR Close #47330
1 parent a2f4170 commit 23f210c

File tree

8 files changed

+163
-5
lines changed

8 files changed

+163
-5
lines changed

goldens/public-api/common/errors.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export const enum RuntimeErrorCode {
1515
// (undocumented)
1616
LCP_IMG_MISSING_PRIORITY = 2955,
1717
// (undocumented)
18+
MISSING_BUILTIN_LOADER = 2962,
19+
// (undocumented)
1820
NG_FOR_MISSING_DIFFER = -2200,
1921
// (undocumented)
2022
OVERSIZED_IMAGE = 2960,

packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,23 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {createImageLoader, ImageLoaderConfig} from './image_loader';
9+
import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';
10+
11+
/**
12+
* Name and URL tester for Cloudinary.
13+
*/
14+
export const cloudinaryLoaderInfo: ImageLoaderInfo = {
15+
name: 'Cloudinary',
16+
testUrl: isCloudinaryUrl
17+
};
18+
19+
const CLOUDINARY_LOADER_REGEX = /https?\:\/\/[^\/]+\.cloudinary\.com\/.+/;
20+
/**
21+
* Tests whether a URL is from Cloudinary CDN.
22+
*/
23+
function isCloudinaryUrl(url: string): boolean {
24+
return CLOUDINARY_LOADER_REGEX.test(url);
25+
}
1026

1127
/**
1228
* Function that generates an ImageLoader for Cloudinary and turns it into an Angular provider.

packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,15 @@ export type ImageLoader = (config: ImageLoaderConfig) => string;
4646
* @see `ImageLoader`
4747
* @see `NgOptimizedImage`
4848
*/
49-
const noopImageLoader = (config: ImageLoaderConfig) => config.src;
49+
export const noopImageLoader = (config: ImageLoaderConfig) => config.src;
50+
51+
/**
52+
* Metadata about the image loader.
53+
*/
54+
export type ImageLoaderInfo = {
55+
name: string,
56+
testUrl: (url: string) => boolean
57+
};
5058

5159
/**
5260
* Injection token that configures the image loader function.

packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,23 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {createImageLoader, ImageLoaderConfig} from './image_loader';
9+
import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';
10+
11+
/**
12+
* Name and URL tester for ImageKit.
13+
*/
14+
export const imageKitLoaderInfo: ImageLoaderInfo = {
15+
name: 'ImageKit',
16+
testUrl: isImageKitUrl
17+
};
18+
19+
const IMAGE_KIT_LOADER_REGEX = /https?\:\/\/[^\/]+\.imagekit\.io\/.+/;
20+
/**
21+
* Tests whether a URL is from ImageKit CDN.
22+
*/
23+
function isImageKitUrl(url: string): boolean {
24+
return IMAGE_KIT_LOADER_REGEX.test(url);
25+
}
1026

1127
/**
1228
* Function that generates an ImageLoader for ImageKit and turns it into an Angular provider.

packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,23 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {createImageLoader, ImageLoaderConfig} from './image_loader';
9+
import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';
10+
11+
/**
12+
* Name and URL tester for Imgix.
13+
*/
14+
export const imgixLoaderInfo: ImageLoaderInfo = {
15+
name: 'Imgix',
16+
testUrl: isImgixUrl
17+
};
18+
19+
const IMGIX_LOADER_REGEX = /https?\:\/\/[^\/]+\.imgix\.net\/.+/;
20+
/**
21+
* Tests whether a URL is from Imgix CDN.
22+
*/
23+
function isImgixUrl(url: string): boolean {
24+
return IMGIX_LOADER_REGEX.test(url);
25+
}
1026

1127
/**
1228
* Function that generates an ImageLoader for Imgix and turns it into an Angular provider.

packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {RuntimeErrorCode} from '../../errors';
1212
import {isPlatformServer} from '../../platform_id';
1313

1414
import {imgDirectiveDetails} from './error_helper';
15-
import {IMAGE_LOADER} from './image_loaders/image_loader';
15+
import {cloudinaryLoaderInfo} from './image_loaders/cloudinary_loader';
16+
import {IMAGE_LOADER, ImageLoader, noopImageLoader} from './image_loaders/image_loader';
17+
import {imageKitLoaderInfo} from './image_loaders/imagekit_loader';
18+
import {imgixLoaderInfo} from './image_loaders/imgix_loader';
1619
import {LCPImageObserver} from './lcp_image_observer';
1720
import {PreconnectLinkChecker} from './preconnect_link_checker';
1821
import {PreloadLinkCreator} from './preload-link-creator';
@@ -72,6 +75,9 @@ const ASPECT_RATIO_TOLERANCE = .1;
7275
*/
7376
const OVERSIZED_IMAGE_TOLERANCE = 1000;
7477

78+
/** Info about built-in loaders we can test for. */
79+
export const BUILT_IN_LOADERS = [imgixLoaderInfo, imageKitLoaderInfo, cloudinaryLoaderInfo];
80+
7581
/**
7682
* A configuration object for the NgOptimizedImage directive. Contains:
7783
* - breakpoints: An array of integer breakpoints used to generate
@@ -385,6 +391,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
385391
if (!this.ngSrcset) {
386392
assertNoComplexSizes(this);
387393
}
394+
assertNotMissingBuiltInLoader(this.ngSrc, this.imageLoader);
388395
if (this.priority) {
389396
const checker = this.injector.get(PreconnectLinkChecker);
390397
checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc);
@@ -873,3 +880,35 @@ function assertValidLoadingInput(dir: NgOptimizedImage) {
873880
`To fix this, provide a valid value ("lazy", "eager", or "auto").`);
874881
}
875882
}
883+
884+
/**
885+
* Warns if NOT using a loader (falling back to the generic loader) and
886+
* the image appears to be hosted on one of the image CDNs for which
887+
* we do have a built-in image loader. Suggests switching to the
888+
* built-in loader.
889+
*
890+
* @param ngSrc Value of the ngSrc attribute
891+
* @param imageLoader ImageLoader provided
892+
*/
893+
function assertNotMissingBuiltInLoader(ngSrc: string, imageLoader: ImageLoader) {
894+
if (imageLoader === noopImageLoader) {
895+
let builtInLoaderName = '';
896+
for (const loader of BUILT_IN_LOADERS) {
897+
if (loader.testUrl(ngSrc)) {
898+
builtInLoaderName = loader.name;
899+
break;
900+
}
901+
}
902+
if (builtInLoaderName) {
903+
console.warn(formatRuntimeError(
904+
RuntimeErrorCode.MISSING_BUILTIN_LOADER,
905+
`NgOptimizedImage: It looks like your images may be hosted on the ` +
906+
`${builtInLoaderName} CDN, but your app is not using Angular's ` +
907+
`built-in loader for that CDN. We recommend switching to use ` +
908+
`the built-in by calling \`provide${builtInLoaderName}Loader()\` ` +
909+
`in your \`providers\` and passing it your instance's base URL. ` +
910+
`If you don't want to use the built-in loader, define a custom ` +
911+
`loader function using IMAGE_LOADER to silence this warning.`));
912+
}
913+
}
914+
}

packages/common/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ export const enum RuntimeErrorCode {
3232
INVALID_LOADER_ARGUMENTS = 2959,
3333
OVERSIZED_IMAGE = 2960,
3434
TOO_MANY_PRELOADED_IMAGES = 2961,
35+
MISSING_BUILTIN_LOADER = 2962,
3536
}

packages/common/test/directives/ng_optimized_image_spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,6 +1097,56 @@ describe('Image directive', () => {
10971097
expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
10981098
});
10991099

1100+
it('should warn if there is no image loader but using Imgix URL', () => {
1101+
setUpModuleNoLoader();
1102+
1103+
const template = `<img ngSrc="https://some.imgix.net/img.png" width="100" height="50">`;
1104+
const fixture = createTestComponent(template);
1105+
const consoleWarnSpy = spyOn(console, 'warn');
1106+
fixture.detectChanges();
1107+
1108+
expect(consoleWarnSpy.calls.count()).toBe(1);
1109+
expect(consoleWarnSpy.calls.argsFor(0)[0])
1110+
.toMatch(/your images may be hosted on the Imgix CDN/);
1111+
});
1112+
1113+
it('should warn if there is no image loader but using ImageKit URL', () => {
1114+
setUpModuleNoLoader();
1115+
1116+
const template = `<img ngSrc="https://some.imagekit.io/img.png" width="100" height="50">`;
1117+
const fixture = createTestComponent(template);
1118+
const consoleWarnSpy = spyOn(console, 'warn');
1119+
fixture.detectChanges();
1120+
1121+
expect(consoleWarnSpy.calls.count()).toBe(1);
1122+
expect(consoleWarnSpy.calls.argsFor(0)[0])
1123+
.toMatch(/your images may be hosted on the ImageKit CDN/);
1124+
});
1125+
1126+
it('should warn if there is no image loader but using Cloudinary URL', () => {
1127+
setUpModuleNoLoader();
1128+
1129+
const template = `<img ngSrc="https://some.cloudinary.com/img.png" width="100" height="50">`;
1130+
const fixture = createTestComponent(template);
1131+
const consoleWarnSpy = spyOn(console, 'warn');
1132+
fixture.detectChanges();
1133+
1134+
expect(consoleWarnSpy.calls.count()).toBe(1);
1135+
expect(consoleWarnSpy.calls.argsFor(0)[0])
1136+
.toMatch(/your images may be hosted on the Cloudinary CDN/);
1137+
});
1138+
1139+
it('should NOT warn if there is a custom loader but using CDN URL', () => {
1140+
setupTestingModule();
1141+
1142+
const template = `<img ngSrc="https://some.cloudinary.com/img.png" width="100" height="50">`;
1143+
const fixture = createTestComponent(template);
1144+
const consoleWarnSpy = spyOn(console, 'warn');
1145+
fixture.detectChanges();
1146+
1147+
expect(consoleWarnSpy.calls.count()).toBe(0);
1148+
});
1149+
11001150
it('should set `src` using the image loader provided via the `IMAGE_LOADER` token to compose src URL',
11011151
() => {
11021152
const imageLoader = (config: ImageLoaderConfig) => `${IMG_BASE_URL}/${config.src}`;
@@ -1526,6 +1576,16 @@ function setupTestingModule(config?: {
15261576
});
15271577
}
15281578

1579+
// Same as above but explicitly doesn't provide a custom loader,
1580+
// so the noopImageLoader should be used.
1581+
function setUpModuleNoLoader() {
1582+
TestBed.configureTestingModule({
1583+
declarations: [TestComponent],
1584+
imports: [CommonModule, NgOptimizedImage],
1585+
providers: [{provide: DOCUMENT, useValue: window.document}]
1586+
});
1587+
}
1588+
15291589
function createTestComponent(template: string): ComponentFixture<TestComponent> {
15301590
return TestBed.overrideComponent(TestComponent, {set: {template: template}})
15311591
.createComponent(TestComponent);

0 commit comments

Comments
 (0)