Skip to content

Commit

Permalink
feat(common): warn if using supported CDN but not built-in loader
Browse files Browse the repository at this point in the history
This commit adds a console 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.
  • Loading branch information
kara committed Sep 2, 2022
1 parent 7d3df47 commit 7c81ba2
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 2 deletions.
Expand Up @@ -47,7 +47,7 @@ export type ImageLoader = (config: ImageLoaderConfig) => string;
* @see `ImageLoader`
* @see `NgOptimizedImage`
*/
const noopImageLoader = (config: ImageLoaderConfig) => config.src;
export const noopImageLoader = (config: ImageLoaderConfig) => config.src;

/**
* Injection token that configures the image loader function.
Expand Down
Expand Up @@ -11,7 +11,7 @@ import {Directive, ElementRef, inject, Injector, Input, NgZone, OnChanges, OnDes
import {RuntimeErrorCode} from '../../errors';

import {imgDirectiveDetails} from './error_helper';
import {IMAGE_LOADER} from './image_loaders/image_loader';
import {IMAGE_LOADER, ImageLoader, noopImageLoader} from './image_loaders/image_loader';
import {LCPImageObserver} from './lcp_image_observer';
import {PreconnectLinkChecker} from './preconnect_link_checker';

Expand Down Expand Up @@ -61,6 +61,20 @@ const ASPECT_RATIO_TOLERANCE = .1;
*/
const OVERSIZED_IMAGE_TOLERANCE = 1000;

/**
* The following are used to determine if an image URL is hosted
* on the listed image CDN (for warnings).
*
* Doesn't match URLs where the CDN is not in the initial position,
* e.g. for Imgix:
*
* Match: https://mysite.imgix.net/myimage.png
* No match: https://othersite.com/assets/mysite.imgix.net/myimage.png
*/
const IMGIX_LOADER_REGEX = /https?\:\/\/[^\/]+\.imgix\.net\/.+/;
const IMAGE_KIT_LOADER_REGEX = /https?\:\/\/[^\/]+\.imagekit\.io\/.+/;
const CLOUDINARY_LOADER_REGEX = /https?\:\/\/[^\/]+\.cloudinary\.com\/.+/;

/**
* Directive that improves image loading performance by enforcing best practices.
*
Expand Down Expand Up @@ -273,6 +287,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
assertNonEmptyWidthAndHeight(this);
assertValidLoadingInput(this);
assertNoImageDistortion(this, this.imgElement, this.renderer);
assertNotMissingBuiltInLoader(this.rawSrc, this.imageLoader);
if (this.priority) {
const checker = this.injector.get(PreconnectLinkChecker);
checker.assertPreconnect(this.getRewrittenSrc(), this.rawSrc);
Expand Down Expand Up @@ -665,3 +680,36 @@ 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 rawSrc Value of the rawSrc attribute
* @param imageLoader ImageLoader provided
*/
function assertNotMissingBuiltInLoader(rawSrc: string, imageLoader: ImageLoader) {
if (imageLoader === noopImageLoader) {
let builtInLoaderName = '';
if (IMGIX_LOADER_REGEX.test(rawSrc)) {
builtInLoaderName = 'Imgix';
} else if (IMAGE_KIT_LOADER_REGEX.test(rawSrc)) {
builtInLoaderName = 'ImageKit';
} else if (CLOUDINARY_LOADER_REGEX.test(rawSrc)) {
builtInLoaderName = 'Cloudinary';
}
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.`));
}
}
}
1 change: 1 addition & 0 deletions packages/common/src/errors.ts
Expand Up @@ -32,4 +32,5 @@ export const enum RuntimeErrorCode {
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
INVALID_LOADER_ARGUMENTS = 2959,
OVERSIZED_IMAGE = 2960,
MISSING_BUILTIN_LOADER = 2961,
}
60 changes: 60 additions & 0 deletions packages/common/test/directives/ng_optimized_image_spec.ts
Expand Up @@ -818,6 +818,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 = `<img rawSrc="https://some.imgix.net/img.png" width="100" height="50">`;
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 = `<img rawSrc="https://some.imagekit.io/img.png" width="100" height="50">`;
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 = `<img rawSrc="https://some.cloudinary.com/img.png" width="100" height="50">`;
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 = `<img rawSrc="https://some.cloudinary.com/img.png" width="100" height="50">`;
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}`;
Expand Down Expand Up @@ -1058,6 +1108,16 @@ function setupTestingModule(config?: {imageLoader?: ImageLoader, extraProviders?
});
}

// 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<TestComponent> {
return TestBed.overrideComponent(TestComponent, {set: {template: template}})
.createComponent(TestComponent);
Expand Down

0 comments on commit 7c81ba2

Please sign in to comment.