diff --git a/packages/common/src/directives/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image.ts index 63648967c18..fe3ce9ea490 100644 --- a/packages/common/src/directives/ng_optimized_image.ts +++ b/packages/common/src/directives/ng_optimized_image.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, Inject, InjectionToken, Input, NgModule, OnInit, ɵRuntimeError as RuntimeError} from '@angular/core'; +import {Directive, ElementRef, Inject, InjectionToken, Input, NgModule, OnInit, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core'; import {RuntimeErrorCode} from '../errors'; @@ -50,18 +50,16 @@ export const IMAGE_LOADER = new InjectionToken('ImageLoader', { * @usageNotes * TODO: add Image directive usage notes. */ -@Directive({ - selector: 'img[rawSrc]', - host: { - '[src]': 'getRewrittenSrc()', - }, -}) +@Directive({selector: 'img[rawSrc]'}) export class NgOptimizedImage implements OnInit { - constructor(@Inject(IMAGE_LOADER) private imageLoader: ImageLoader) {} + constructor( + @Inject(IMAGE_LOADER) private imageLoader: ImageLoader, private renderer: Renderer2, + private imgElement: ElementRef) {} // Private fields to keep normalized input values. private _width?: number; private _height?: number; + private _priority?: boolean; /** * Name of the source image. @@ -94,6 +92,14 @@ export class NgOptimizedImage implements OnInit { return this._height; } + @Input() + set priority(value: string|boolean|undefined) { + this._priority = inputToBoolean(value); + } + get priority(): boolean|undefined { + return this._priority; + } + /** * Get a value of the `src` if it's set on a host element. * This input is needed to verify that there are no `src` and `rawSrc` provided @@ -105,6 +111,19 @@ export class NgOptimizedImage implements OnInit { if (ngDevMode) { assertExistingSrc(this); } + this.setHostAttribute('loading', this.getLoadingBehavior()); + this.setHostAttribute('fetchpriority', this.getFetchPriority()); + // The `src` attribute should be set last since other attributes + // could affect the image's loading behavior. + this.setHostAttribute('src', this.getRewrittenSrc()); + } + + getLoadingBehavior(): string { + return this.priority ? 'eager' : 'lazy'; + } + + getFetchPriority(): string { + return this.priority ? 'high' : 'auto'; } getRewrittenSrc(): string { @@ -117,6 +136,10 @@ export class NgOptimizedImage implements OnInit { }; return this.imageLoader(imgConfig); } + + private setHostAttribute(name: string, value: string): void { + this.renderer.setAttribute(this.imgElement.nativeElement, name, value); + } } /** @@ -137,6 +160,10 @@ function inputToInteger(value: string|number|undefined): number|undefined { return typeof value === 'string' ? parseInt(value, 10) : value; } +function inputToBoolean(value: unknown): boolean { + return value != null && `${value}` !== 'false'; +} + function imgDirectiveDetails(dir: NgOptimizedImage) { return `The NgOptimizedImage directive (activated on an element ` + `with the \`rawSrc="${dir.rawSrc}"\`)`; diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts index e508dddf391..a5274a90158 100644 --- a/packages/common/test/directives/ng_optimized_image_spec.ts +++ b/packages/common/test/directives/ng_optimized_image_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {CommonModule} from '@angular/common'; +import {CommonModule, DOCUMENT} from '@angular/common'; import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgOptimizedImage} from '@angular/common/src/directives/ng_optimized_image'; import {Component} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; @@ -25,6 +25,57 @@ describe('Image directive', () => { expect(img.src.endsWith('/path/img.png')).toBeTrue(); }); + 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. + if (!isBrowser) return; + + setupTestingModule(); + + const template = ''; + TestBed.overrideComponent(TestComponent, {set: {template: template}}); + + const _document = TestBed.inject(DOCUMENT); + const _window = _document.defaultView!; + const setAttributeSpy = + spyOn(_window.HTMLImageElement.prototype, 'setAttribute').and.callThrough(); + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('loading')).toBe('eager'); + + let _imgInstance = null; + let _loadingAttrId = -1; + let _fetchpriorityAttrId = -1; + let _srcAttrId = -1; + const count = setAttributeSpy.calls.count(); + for (let i = 0; i < count; i++) { + if (!_imgInstance) { + _imgInstance = setAttributeSpy.calls.thisFor(i); + } else if (_imgInstance !== setAttributeSpy.calls.thisFor(i)) { + // Verify that the instance is the same during the test. + fail('Unexpected instance of a second instance present in a test.'); + } + + // Note: spy.calls.argsFor(i) returns args as an array: ['src', 'eager'] + const attrName = setAttributeSpy.calls.argsFor(i)[0]; + if (attrName == 'loading') _loadingAttrId = i; + if (attrName == 'fetchpriority') _fetchpriorityAttrId = i; + if (attrName == 'src') _srcAttrId = i; + } + // Verify that both `loading` and `fetchpriority` are set *before* `src`: + expect(_loadingAttrId).toBeGreaterThan(-1); // was actually set + expect(_loadingAttrId).toBeLessThan(_srcAttrId); // was set after `src` + + expect(_fetchpriorityAttrId).toBeGreaterThan(-1); // was actually set + expect(_fetchpriorityAttrId).toBeLessThan(_srcAttrId); // was set after `src` + }); + + it('should use an image loader provided via `IMAGE_LOADER` token', () => { const imageLoader = (config: ImageLoaderConfig) => `${config.src}?w=${config.width}`; setupTestingModule({imageLoader}); @@ -54,6 +105,56 @@ describe('Image directive', () => { 'the `rawSrc` to compute the final image URL and set the `src` itself.'); }); }); + + describe('lazy loading', () => { + it('should eagerly load priority images', () => { + setupTestingModule(); + + const template = ''; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('loading')).toBe('eager'); + }); + it('should lazily load non-priority images', () => { + setupTestingModule(); + + const template = ''; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('loading')).toBe('lazy'); + }); + }); + + describe('fetch priority', () => { + it('should be "high" for priority images', () => { + setupTestingModule(); + + const template = ''; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('fetchpriority')).toBe('high'); + }); + it('should be "auto" for non-priority images', () => { + setupTestingModule(); + + const template = ''; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('fetchpriority')).toBe('auto'); + }); + }); }); // Helpers