feat(common): Add image lazy loading and fetchpriority (#47082)

PR Close #47082
This commit is contained in:
Katie Hempenius
2022-05-10 12:11:05 +00:00
committed by Pawel Kozlowski
parent b58454d129
commit 0566205a02
2 changed files with 137 additions and 9 deletions
@@ -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>('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 <img> 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 <img> element ` +
`with the \`rawSrc="${dir.rawSrc}"\`)`;
@@ -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 = '<img rawSrc="path/img.png" width="150" height="50" priority>';
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 <img> instance is the same during the test.
fail('Unexpected instance of a second <img> 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 = '<img rawSrc="path/img.png" width="150" height="50" priority>';
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 = '<img rawSrc="path/img.png" width="150" height="50">';
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 = '<img rawSrc="path/img.png" width="150" height="50" priority>';
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 = '<img rawSrc="path/img.png" width="150" height="50">';
const fixture = createTestComponent(template);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
const img = nativeElement.querySelector('img')!;
expect(img.getAttribute('fetchpriority')).toBe('auto');
});
});
});
// Helpers