Skip to content

Commit

Permalink
feat(common): Add image lazy loading and fetchpriority (#47082)
Browse files Browse the repository at this point in the history
PR Close #47082
  • Loading branch information
khempenius authored and Pawel Kozlowski committed Aug 16, 2022
1 parent b58454d commit 0566205
Show file tree
Hide file tree
Showing 2 changed files with 137 additions and 9 deletions.
43 changes: 35 additions & 8 deletions packages/common/src/directives/ng_optimized_image.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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);
}
}

/**
Expand All @@ -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}"\`)`;
Expand Down
103 changes: 102 additions & 1 deletion packages/common/test/directives/ng_optimized_image_spec.ts
Expand Up @@ -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';
Expand All @@ -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});
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 0566205

Please sign in to comment.