From 75e6297f0901cc98aea1626a138a820e68d026ec Mon Sep 17 00:00:00 2001 From: jaybell Date: Sun, 4 Sep 2022 16:52:41 -0700 Subject: [PATCH] feat(common): add preload tag on server for priority img (#47343) This commit adds a logic that generates preload tags for priority images, when rendering happens on the server (e.g. Angular Universal). PR Close #47343 --- goldens/public-api/common/errors.md | 2 + .../ng_optimized_image/ng_optimized_image.ts | 26 ++- .../preload-link-creator.ts | 79 +++++++ .../directives/ng_optimized_image/tokens.ts | 27 +++ packages/common/src/errors.ts | 1 + .../directives/ng_optimized_image_spec.ts | 203 ++++++++++++++++-- 6 files changed, 313 insertions(+), 25 deletions(-) create mode 100644 packages/common/src/directives/ng_optimized_image/preload-link-creator.ts 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 f3858153c562a..e24e5d7adf226 100644 --- a/goldens/public-api/common/errors.md +++ b/goldens/public-api/common/errors.md @@ -25,6 +25,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/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts index fc1ac8469a9c0..9d24eea2ffb3c 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,14 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, ElementRef, inject, InjectionToken, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, 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 {RuntimeErrorCode} from '../../errors'; +import {isPlatformServer} from '../../platform_id'; 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 {PreloadLinkCreator} from './preload-link-creator'; /** * When a Base64-encoded image is passed as an input to the `NgOptimizedImage` directive, @@ -207,6 +209,8 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { private renderer = inject(Renderer2); private imgElement: HTMLImageElement = inject(ElementRef).nativeElement; private injector = inject(Injector); + private readonly isServer = isPlatformServer(inject(PLATFORM_ID)); + private readonly preloadLinkChecker = inject(PreloadLinkCreator); // a LCP image observer - should be injected only in the dev mode private lcpObserver = ngDevMode ? this.injector.get(LCPImageObserver) : null; @@ -386,14 +390,28 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { this.setHostAttribute('fetchpriority', this.getFetchPriority()); // The `src` and `srcset` attributes should be set last since other attributes // could affect the image's loading behavior. - this.setHostAttribute('src', this.getRewrittenSrc()); + const rewrittenSrc = this.getRewrittenSrc(); + this.setHostAttribute('src', rewrittenSrc); + + let rewrittenSrcset: string|undefined = undefined; + if (this.sizes) { this.setHostAttribute('sizes', this.sizes); } + if (this.ngSrcset) { - this.setHostAttribute('srcset', this.getRewrittenSrcset()); + rewrittenSrcset = this.getRewrittenSrcset(); } else if (!this._disableOptimizedSrcset && !this.srcset) { - this.setHostAttribute('srcset', this.getAutomaticSrcset()); + rewrittenSrcset = this.getAutomaticSrcset(); + } + + if (rewrittenSrcset) { + this.setHostAttribute('srcset', rewrittenSrcset); + } + + if (this.isServer && this.priority) { + this.preloadLinkChecker.createPreloadLinkTag( + this.renderer, rewrittenSrc, rewrittenSrcset, this.sizes); } } diff --git a/packages/common/src/directives/ng_optimized_image/preload-link-creator.ts b/packages/common/src/directives/ng_optimized_image/preload-link-creator.ts new file mode 100644 index 0000000000000..f2f65f1e1ec82 --- /dev/null +++ b/packages/common/src/directives/ng_optimized_image/preload-link-creator.ts @@ -0,0 +1,79 @@ +/** + * @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 {inject, Injectable, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core'; + +import {DOCUMENT} from '../../dom_tokens'; +import {RuntimeErrorCode} from '../../errors'; + +import {DEFAULT_PRELOADED_IMAGES_LIMIT, PRELOADED_IMAGES} from './tokens'; + +/** + * @description Contains the logic needed to track and add preload link tags to the `` tag. It + * will also track what images have already had preload link tags added so as to not duplicate link + * tags. + * + * In dev mode this service will validate that the number of preloaded images does not exceed the + * configured default preloaded images limit: {@link DEFAULT_PRELOADED_IMAGES_LIMIT}. + */ +@Injectable({providedIn: 'root'}) +export class PreloadLinkCreator { + private readonly preloadedImages = inject(PRELOADED_IMAGES); + private readonly document = inject(DOCUMENT); + + /** + * @description Add a preload `` to the `` of the `index.html` that is served from the + * server while using Angular Universal and SSR to kick off image loads for high priority images. + * + * The `sizes` (passed in from the user) and `srcset` (parsed and formatted from `ngSrcset`) + * properties used to set the corresponding attributes, `imagesizes` and `imagesrcset` + * respectively, on the preload `` tag so that the correctly sized image is preloaded from + * the CDN. + * + * {@link https://web.dev/preload-responsive-images/#imagesrcset-and-imagesizes} + * + * @param renderer The `Renderer2` passed in from the directive + * @param src The original src of the image that is set on the `ngSrc` input. + * @param srcset The parsed and formatted srcset created from the `ngSrcset` input + * @param sizes The value of the `sizes` attribute passed in to the `` tag + */ + createPreloadLinkTag(renderer: Renderer2, src: string, srcset?: string, sizes?: string): void { + if (ngDevMode) { + if (this.preloadedImages.size >= DEFAULT_PRELOADED_IMAGES_LIMIT) { + throw new RuntimeError( + RuntimeErrorCode.TOO_MANY_PRELOADED_IMAGES, + ngDevMode && + `The \`NgOptimizedImage\` directive has detected that more than ` + + `${DEFAULT_PRELOADED_IMAGES_LIMIT} images were marked as priority. ` + + `This might negatively affect an overall performance of the page. ` + + `To fix this, remove the "priority" attribute from images with less priority.`); + } + } + + if (this.preloadedImages.has(src)) { + return; + } + + this.preloadedImages.add(src); + + const preload = renderer.createElement('link'); + renderer.setAttribute(preload, 'as', 'image'); + renderer.setAttribute(preload, 'href', src); + renderer.setAttribute(preload, 'rel', 'preload'); + + if (sizes) { + renderer.setAttribute(preload, 'imageSizes', sizes); + } + + if (srcset) { + renderer.setAttribute(preload, 'imageSrcset', srcset); + } + + renderer.appendChild(this.document.head, preload); + } +} 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 0000000000000..ab09b9b6475e6 --- /dev/null +++ b/packages/common/src/directives/ng_optimized_image/tokens.ts @@ -0,0 +1,27 @@ +/** + * @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'; + +/** + * In SSR scenarios, a preload `` element is generated for priority images. + * Having a large number of preload tags may negatively affect the performance, + * so we warn developers (by throwing an error) if the number of preloaded images + * is above a certain threshold. This const specifies this threshold. + */ +export const DEFAULT_PRELOADED_IMAGES_LIMIT = 5; + +/** + * Helps to keep track of priority images that already have a corresponding + * preload tag (to avoid generating multiple preload tags with the same URL). + * + * This Set tracks the original src passed into the `ngSrc` input not the src after it has been + * run through the specified `IMAGE_LOADER`. + */ +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 5ec4986f2b123..d8a3f06ca97f8 100644 --- a/packages/common/src/errors.ts +++ b/packages/common/src/errors.ts @@ -31,4 +31,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 49284f4e3ec7c..8483bf6d5a3d8 100644 --- a/packages/common/test/directives/ng_optimized_image_spec.ts +++ b/packages/common/test/directives/ng_optimized_image_spec.ts @@ -8,16 +8,186 @@ import {CommonModule, DOCUMENT} from '@angular/common'; import {RuntimeErrorCode} from '@angular/common/src/errors'; -import {Component, Provider, Type} from '@angular/core'; +import {PLATFORM_SERVER_ID} from '@angular/common/src/platform_id'; +import {Component, PLATFORM_ID, Provider, Type} from '@angular/core'; 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 {createImageLoader, IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from '../../src/directives/ng_optimized_image/image_loaders/image_loader'; import {ABSOLUTE_SRCSET_DENSITY_CAP, assertValidNgSrcset, IMAGE_CONFIG, ImageConfig, 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'; describe('Image directive', () => { + describe('preload element on a server', () => { + it('should create `` element when the image priority attr is true', () => { + // 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 src = 'preload1/img.png'; + + setupTestingModule({ + extraProviders: [ + {provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}, { + provide: IMAGE_LOADER, + useValue: (config: ImageLoaderConfig) => config.width ? + `https://angular.io/${config.src}?width=${config.width}` : + `https://angular.io/${config.src}` + } + ] + }); + + const template = + ``; + TestBed.overrideComponent(TestComponent, {set: {template: template}}); + + const _document = TestBed.inject(DOCUMENT); + const _window = _document.defaultView!; + const setAttributeSpy = + spyOn(_window.HTMLLinkElement.prototype, 'setAttribute').and.callThrough(); + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + const head = _document.head; + + const rewrittenSrc = `https://angular.io/${src}`; + + const preloadLink = head.querySelector(`link[href="${rewrittenSrc}"]`); + + expect(preloadLink).toBeTruthy(); + + const [name, value] = setAttributeSpy.calls.argsFor(0); + + expect(name).toEqual('as'); + expect(value).toEqual('image'); + + expect(preloadLink!.getAttribute('rel')).toEqual('preload'); + expect(preloadLink!.getAttribute('as')).toEqual('image'); + expect(preloadLink!.getAttribute('imagesizes')).toEqual('10vw'); + expect(preloadLink!.getAttribute('imagesrcset')).toEqual(`${rewrittenSrc}?width=100 100w`); + + preloadLink!.remove(); + }); + + it('should not create a preload `` element when src 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 src = `preload2/img.png`; + + const rewrittenSrc = `https://angular.io/${src}`; + + 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 preloadImages = TestBed.inject(PRELOADED_IMAGES); + + expect(preloadImages.has(rewrittenSrc)).toBeTruthy(); + + const preloadLinks = head.querySelectorAll(`link[href="${rewrittenSrc}"]`); + + expect(preloadLinks.length).toEqual(1); + + preloadLinks[0]!.remove(); + }); + + it('should error when the number of preloaded images is larger than the limit', () => { + // 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; + + setupTestingModule({ + extraProviders: [ + {provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}, { + provide: IMAGE_LOADER, + useValue: (config: ImageLoaderConfig) => `https://angular.io/${config.src}` + } + ] + }); + + const template = ` + + + + + + + + + + `; + + expect(() => { + const fixture = createTestComponent(template); + fixture.detectChanges(); + }) + .toThrowError( + 'NG02961: The `NgOptimizedImage` directive has detected that more than 5 images were marked as priority. This might negatively affect an overall performance of the page. To fix this, remove the "priority" attribute from images with less priority.'); + }); + + it('should not hit max preload limit when not on the server', () => { + // 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; + + setupTestingModule({ + extraProviders: [{ + 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 preloadImages = TestBed.inject(PRELOADED_IMAGES); + + const preloadLinks = head.querySelectorAll(`link[preload]`); + + expect(preloadImages.size).toEqual(0); + expect(preloadLinks.length).toEqual(0); + }); + }); + 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. @@ -741,9 +911,7 @@ describe('Image directive', () => { describe('PRECONNECT_CHECK_BLOCKLIST token', () => { it(`should allow passing host names`, withHead('', () => { - const providers = [ - {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'angular.io'}, - ]; + const providers = [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'angular.io'}]; setupTestingModule({imageLoader, extraProviders: providers}); const consoleWarnSpy = spyOn(console, 'warn'); @@ -756,9 +924,8 @@ describe('Image directive', () => { })); it(`should allow passing origins`, withHead('', () => { - const providers = [ - {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'https://angular.io'}, - ]; + const providers = + [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'https://angular.io'}]; setupTestingModule({imageLoader, extraProviders: providers}); const consoleWarnSpy = spyOn(console, 'warn'); @@ -771,9 +938,8 @@ describe('Image directive', () => { })); it(`should allow passing arrays of host names`, withHead('', () => { - const providers = [ - {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: ['https://angular.io']}, - ]; + const providers = + [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: ['https://angular.io']}]; setupTestingModule({imageLoader, extraProviders: providers}); const consoleWarnSpy = spyOn(console, 'warn'); @@ -786,9 +952,8 @@ describe('Image directive', () => { })); it(`should allow passing nested arrays of host names`, withHead('', () => { - const providers = [ - {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: [['https://angular.io']]}, - ]; + const providers = + [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: [['https://angular.io']]}]; setupTestingModule({imageLoader, extraProviders: providers}); const consoleWarnSpy = spyOn(console, 'warn'); @@ -1207,10 +1372,7 @@ const IMG_BASE_URL = { const ANGULAR_LOGO_BASE64 = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg=='; -@Component({ - selector: 'test-cmp', - template: '', -}) +@Component({selector: 'test-cmp', template: ''}) class TestComponent { width = 100; height = 50; @@ -1231,9 +1393,8 @@ function setupTestingModule(config?: { const loader = config?.imageLoader || defaultLoader; const extraProviders = config?.extraProviders || []; const providers: Provider[] = [ - {provide: DOCUMENT, useValue: window.document}, - {provide: IMAGE_LOADER, useValue: loader}, - ...extraProviders, + {provide: DOCUMENT, useValue: window.document}, {provide: IMAGE_LOADER, useValue: loader}, + ...extraProviders ]; if (config?.imageConfig) { providers.push({provide: IMAGE_CONFIG, useValue: config.imageConfig}); @@ -1244,7 +1405,7 @@ function setupTestingModule(config?: { // Note: the `NgOptimizedImage` directive is experimental and is not a part of the // `CommonModule` yet, so it's imported separately. imports: [CommonModule, NgOptimizedImage], - providers, + providers }); }