From fce5fb8513a88d4b10cff2ed8171e7d43e7199e8 Mon Sep 17 00:00:00 2001 From: jaybell Date: Mon, 19 Sep 2022 21:40:40 -0700 Subject: [PATCH] feat(common): do not duplicate ng optimized preload tags Do not duplicate the preload `` tags for images that show up more than once and throw a `RuntimeError` if there are too many preloaded images. --- goldens/public-api/common/errors.md | 2 + goldens/public-api/common/index.md | 3 ++ packages/common/src/common.ts | 2 +- .../directives/ng_optimized_image/index.ts | 1 + .../ng_optimized_image/ng_optimized_image.ts | 22 ++++++++- .../directives/ng_optimized_image/tokens.ts | 25 ++++++++++ packages/common/src/errors.ts | 1 + .../directives/ng_optimized_image_spec.ts | 48 ++++++++++++++++++- 8 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 packages/common/src/directives/ng_optimized_image/tokens.ts diff --git a/goldens/public-api/common/errors.md b/goldens/public-api/common/errors.md index ab2f7ca05f747b..57a479942c963e 100644 --- a/goldens/public-api/common/errors.md +++ b/goldens/public-api/common/errors.md @@ -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, diff --git a/goldens/public-api/common/index.md b/goldens/public-api/common/index.md index fe00556bf0e777..b494d71302ff60 100644 --- a/goldens/public-api/common/index.md +++ b/goldens/public-api/common/index.md @@ -409,6 +409,9 @@ export class LowerCasePipe implements PipeTransform { static ɵpipe: i0.ɵɵPipeDeclaration; } +// @public (undocumented) +export const NG_OPTIMIZED_IMAGE_CONFIG: InjectionToken; + // @public export class NgClass implements DoCheck { constructor(_iterableDiffers: IterableDiffers, _keyValueDiffers: KeyValueDiffers, _ngEl: ElementRef, _renderer: Renderer2); diff --git a/packages/common/src/common.ts b/packages/common/src/common.ts index 2bd0c424f8d865..db50928e116471 100644 --- a/packages/common/src/common.ts +++ b/packages/common/src/common.ts @@ -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'; diff --git a/packages/common/src/directives/ng_optimized_image/index.ts b/packages/common/src/directives/ng_optimized_image/index.ts index 123cf5ccb6130a..afd37a88cbebcb 100644 --- a/packages/common/src/directives/ng_optimized_image/index.ts +++ b/packages/common/src/directives/ng_optimized_image/index.ts @@ -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'; 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 3a471cd76e45ee..3c23bb24a2e68e 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 @@ -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'; @@ -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, @@ -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); @@ -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; diff --git a/packages/common/src/directives/ng_optimized_image/tokens.ts b/packages/common/src/directives/ng_optimized_image/tokens.ts new file mode 100644 index 00000000000000..d27d940a441ec4 --- /dev/null +++ b/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('NG_OPTIMIZED_IMAGE_CONFIG', { + providedIn: 'root', + factory: () => + ({maxPreloadedImages: DEFAULT_PRELOADED_IMAGES_LIMIT, preconnectCheckBlocklist: []}) + }); + +export const PRELOADED_IMAGES = new InjectionToken>( + 'NG_OPTIMIZED_PRELOADED_IMAGES', {providedIn: 'root', factory: () => new Set()}); diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts index 6169ce8ba8f2d6..b2b31339f67685 100644 --- a/packages/common/src/errors.ts +++ b/packages/common/src/errors.ts @@ -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, } diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts index 240d61ed605d4d..5831a19bbd6ad1 100644 --- a/packages/common/test/directives/ng_optimized_image_spec.ts +++ b/packages/common/test/directives/ng_optimized_image_spec.ts @@ -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'; @@ -34,7 +35,7 @@ describe('Image directive', () => { ] }); - const template = ''; + const template = ''; TestBed.overrideComponent(TestComponent, {set: {template: template}}); const _document = TestBed.inject(DOCUMENT); @@ -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(); @@ -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 `` 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 = ``; + 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.