Skip to content

Commit

Permalink
feat(common): do not duplicate ng optimized preload tags
Browse files Browse the repository at this point in the history
Do not duplicate the preload `<link>` tags for images that show up more than once and throw a `RuntimeError` if there are too many preloaded images.
  • Loading branch information
yharaskrik committed Sep 20, 2022
1 parent e543005 commit fce5fb8
Show file tree
Hide file tree
Showing 8 changed files with 100 additions and 4 deletions.
2 changes: 2 additions & 0 deletions goldens/public-api/common/errors.md
Expand Up @@ -27,6 +27,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
REQUIRED_INPUT_MISSING = 2954,
// (undocumented)
TOO_MANY_PRELOADED_IMAGES = 2961,
// (undocumented)
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
// (undocumented)
UNEXPECTED_INPUT_CHANGE = 2953,
Expand Down
3 changes: 3 additions & 0 deletions goldens/public-api/common/index.md
Expand Up @@ -409,6 +409,9 @@ export class LowerCasePipe implements PipeTransform {
static ɵpipe: i0.ɵɵPipeDeclaration<LowerCasePipe, "lowercase", true>;
}

// @public (undocumented)
export const NG_OPTIMIZED_IMAGE_CONFIG: InjectionToken<NgOptimizedImageConfig>;

// @public
export class NgClass implements DoCheck {
constructor(_iterableDiffers: IterableDiffers, _keyValueDiffers: KeyValueDiffers, _ngEl: ElementRef, _renderer: Renderer2);
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/common.ts
Expand Up @@ -27,4 +27,4 @@ export {PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID, PLATFORM_SERVER_ID as ɵPL
export {VERSION} from './version';
export {ViewportScroller, NullViewportScroller as ɵNullViewportScroller} from './viewport_scroller';
export {XhrFactory} from './xhr';
export {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgOptimizedImage, PRECONNECT_CHECK_BLOCKLIST, provideCloudflareLoader, provideCloudinaryLoader, provideImageKitLoader, provideImgixLoader} from './directives/ng_optimized_image';
export {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgOptimizedImage, PRECONNECT_CHECK_BLOCKLIST, provideCloudflareLoader, provideCloudinaryLoader, provideImageKitLoader, provideImgixLoader, NG_OPTIMIZED_IMAGE_CONFIG} from './directives/ng_optimized_image';
1 change: 1 addition & 0 deletions packages/common/src/directives/ng_optimized_image/index.ts
Expand Up @@ -14,3 +14,4 @@ export {provideImageKitLoader} from './image_loaders/imagekit_loader';
export {provideImgixLoader} from './image_loaders/imgix_loader';
export {NgOptimizedImage} from './ng_optimized_image';
export {PRECONNECT_CHECK_BLOCKLIST} from './preconnect_link_checker';
export {NG_OPTIMIZED_IMAGE_CONFIG} from './tokens';
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive, ElementRef, inject, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, PLATFORM_ID, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
import {Directive, ElementRef, inject, InjectionToken, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, PLATFORM_ID, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';

import {DOCUMENT} from '../../dom_tokens';
import {RuntimeErrorCode} from '../../errors';
Expand All @@ -16,6 +16,7 @@ import {imgDirectiveDetails} from './error_helper';
import {IMAGE_LOADER} from './image_loaders/image_loader';
import {LCPImageObserver} from './lcp_image_observer';
import {PreconnectLinkChecker} from './preconnect_link_checker';
import {NG_OPTIMIZED_IMAGE_CONFIG, PRELOADED_IMAGES} from './tokens';

/**
* When a Base64-encoded image is passed as an input to the `NgOptimizedImage` directive,
Expand Down Expand Up @@ -170,6 +171,8 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
private renderer = inject(Renderer2);
private imgElement: HTMLImageElement = inject(ElementRef).nativeElement;
private injector = inject(Injector);
private readonly preloadedImages = inject(PRELOADED_IMAGES);
private readonly config = inject(NG_OPTIMIZED_IMAGE_CONFIG);
private readonly isServer = isPlatformServer(inject(PLATFORM_ID));
private readonly document = inject(DOCUMENT);

Expand Down Expand Up @@ -393,6 +396,23 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
}

private createPreloadLinkTag(url: string): void {
if (ngDevMode) {
if (this.preloadedImages.size >= this.config.maxPreloadedImages) {
throw new RuntimeError(
RuntimeErrorCode.TOO_MANY_PRELOADED_IMAGES,
ngDevMode &&
`You have marked too many images with the \`NgOptimizedImage\` directive on them as priority. Maximum number of images that can be marked as priority is ${
this.config
.maxPreloadedImages}. You can configure this by providing a different value for \`maxPreloadedImages\` in the \`NG_OPTIMIZED_IMAGE_CONFIG\` injection token.`);
}
}

if (this.preloadedImages.has(url)) {
return;
}

this.preloadedImages.add(url);

const preload = this.document.createElement('link');
preload.setAttribute('as', 'image');
preload.href = url;
Expand Down
25 changes: 25 additions & 0 deletions packages/common/src/directives/ng_optimized_image/tokens.ts
@@ -0,0 +1,25 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {InjectionToken} from '@angular/core';

const DEFAULT_PRELOADED_IMAGES_LIMIT = 5;

export interface NgOptimizedImageConfig {
maxPreloadedImages: number;
}

export const NG_OPTIMIZED_IMAGE_CONFIG =
new InjectionToken<NgOptimizedImageConfig>('NG_OPTIMIZED_IMAGE_CONFIG', {
providedIn: 'root',
factory: () =>
({maxPreloadedImages: DEFAULT_PRELOADED_IMAGES_LIMIT, preconnectCheckBlocklist: []})
});

export const PRELOADED_IMAGES = new InjectionToken<Set<string>>(
'NG_OPTIMIZED_PRELOADED_IMAGES', {providedIn: 'root', factory: () => new Set<string>()});
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,
TOO_MANY_PRELOADED_IMAGES = 2961,
}
48 changes: 46 additions & 2 deletions packages/common/test/directives/ng_optimized_image_spec.ts
Expand Up @@ -14,6 +14,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing';
import {expect} from '@angular/platform-browser/testing/src/matchers';
import {withHead} from '@angular/private/testing';

import {PRELOADED_IMAGES} from '../..//src/directives/ng_optimized_image/tokens';
import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from '../../src/directives/ng_optimized_image/image_loaders/image_loader';
import {ABSOLUTE_SRCSET_DENSITY_CAP, assertValidNgSrcset, NgOptimizedImage, RECOMMENDED_SRCSET_DENSITY_CAP} from '../../src/directives/ng_optimized_image/ng_optimized_image';
import {PRECONNECT_CHECK_BLOCKLIST} from '../../src/directives/ng_optimized_image/preconnect_link_checker';
Expand All @@ -34,7 +35,7 @@ describe('Image directive', () => {
]
});

const template = '<img ngSrc="path/img.png" width="150" height="50" priority>';
const template = '<img ngSrc="preload1/img.png" width="150" height="50" priority>';
TestBed.overrideComponent(TestComponent, {set: {template: template}});

const _document = TestBed.inject(DOCUMENT);
Expand All @@ -47,7 +48,8 @@ describe('Image directive', () => {

const head = _document.head;

const preconnectLink = head.querySelector(`link[href="https://angular.io/path/img.png"]`);
const preconnectLink =
head.querySelector(`link[href="https://angular.io/preload1/img.png"]`);

expect(preconnectLink).toBeTruthy();

Expand All @@ -58,8 +60,50 @@ describe('Image directive', () => {

expect(preconnectLink!.getAttribute('rel')).toEqual('preload');
expect(preconnectLink!.getAttribute('as')).toEqual('image');

preconnectLink!.remove();
});

it('should not create a preconnect `<link>` element when url is already preloaded.', () => {
// Only run this test in a browser since the Node-based DOM mocks don't
// allow to override `HTMLImageElement.prototype.setAttribute` easily.
if (!isBrowser) return;

const path = `preload2/img.png`;

setupTestingModule({
extraProviders: [
{provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID},
{
provide: IMAGE_LOADER,
useValue: (config: ImageLoaderConfig) => `https://angular.io/${config.src}`
},
]
});

const template = `<img ngSrc="${path}" width="150" height="50" priority><img ngSrc="${
path}" width="150" height="50" priority>`;
TestBed.overrideComponent(TestComponent, {set: {template: template}});

const _document = TestBed.inject(DOCUMENT);

const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();

const head = _document.head;

const preloadedImages = TestBed.inject(PRELOADED_IMAGES);

expect(preloadedImages.has(`https://angular.io/${path}`)).toBeTruthy();

const preconnectLinks =
head.querySelectorAll(`link[href="https://angular.io/preload2/img.png"]`);

expect(preconnectLinks.length).toEqual(1);

preconnectLinks[0]!.remove();
});

it('should set `loading` and `fetchpriority` attributes before `src`', () => {
// Only run this test in a browser since the Node-based DOM mocks don't
// allow to override `HTMLImageElement.prototype.setAttribute` easily.
Expand Down

0 comments on commit fce5fb8

Please sign in to comment.