Skip to content

Commit 0566205

Browse files
khempeniusPawel Kozlowski
authored andcommitted
feat(common): Add image lazy loading and fetchpriority (#47082)
PR Close #47082
1 parent b58454d commit 0566205

File tree

2 files changed

+137
-9
lines changed

2 files changed

+137
-9
lines changed

packages/common/src/directives/ng_optimized_image.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive, Inject, InjectionToken, Input, NgModule, OnInit, ɵRuntimeError as RuntimeError} from '@angular/core';
9+
import {Directive, ElementRef, Inject, InjectionToken, Input, NgModule, OnInit, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core';
1010

1111
import {RuntimeErrorCode} from '../errors';
1212

@@ -50,18 +50,16 @@ export const IMAGE_LOADER = new InjectionToken<ImageLoader>('ImageLoader', {
5050
* @usageNotes
5151
* TODO: add Image directive usage notes.
5252
*/
53-
@Directive({
54-
selector: 'img[rawSrc]',
55-
host: {
56-
'[src]': 'getRewrittenSrc()',
57-
},
58-
})
53+
@Directive({selector: 'img[rawSrc]'})
5954
export class NgOptimizedImage implements OnInit {
60-
constructor(@Inject(IMAGE_LOADER) private imageLoader: ImageLoader) {}
55+
constructor(
56+
@Inject(IMAGE_LOADER) private imageLoader: ImageLoader, private renderer: Renderer2,
57+
private imgElement: ElementRef) {}
6158

6259
// Private fields to keep normalized input values.
6360
private _width?: number;
6461
private _height?: number;
62+
private _priority?: boolean;
6563

6664
/**
6765
* Name of the source image.
@@ -94,6 +92,14 @@ export class NgOptimizedImage implements OnInit {
9492
return this._height;
9593
}
9694

95+
@Input()
96+
set priority(value: string|boolean|undefined) {
97+
this._priority = inputToBoolean(value);
98+
}
99+
get priority(): boolean|undefined {
100+
return this._priority;
101+
}
102+
97103
/**
98104
* Get a value of the `src` if it's set on a host <img> element.
99105
* This input is needed to verify that there are no `src` and `rawSrc` provided
@@ -105,6 +111,19 @@ export class NgOptimizedImage implements OnInit {
105111
if (ngDevMode) {
106112
assertExistingSrc(this);
107113
}
114+
this.setHostAttribute('loading', this.getLoadingBehavior());
115+
this.setHostAttribute('fetchpriority', this.getFetchPriority());
116+
// The `src` attribute should be set last since other attributes
117+
// could affect the image's loading behavior.
118+
this.setHostAttribute('src', this.getRewrittenSrc());
119+
}
120+
121+
getLoadingBehavior(): string {
122+
return this.priority ? 'eager' : 'lazy';
123+
}
124+
125+
getFetchPriority(): string {
126+
return this.priority ? 'high' : 'auto';
108127
}
109128

110129
getRewrittenSrc(): string {
@@ -117,6 +136,10 @@ export class NgOptimizedImage implements OnInit {
117136
};
118137
return this.imageLoader(imgConfig);
119138
}
139+
140+
private setHostAttribute(name: string, value: string): void {
141+
this.renderer.setAttribute(this.imgElement.nativeElement, name, value);
142+
}
120143
}
121144

122145
/**
@@ -137,6 +160,10 @@ function inputToInteger(value: string|number|undefined): number|undefined {
137160
return typeof value === 'string' ? parseInt(value, 10) : value;
138161
}
139162

163+
function inputToBoolean(value: unknown): boolean {
164+
return value != null && `${value}` !== 'false';
165+
}
166+
140167
function imgDirectiveDetails(dir: NgOptimizedImage) {
141168
return `The NgOptimizedImage directive (activated on an <img> element ` +
142169
`with the \`rawSrc="${dir.rawSrc}"\`)`;

packages/common/test/directives/ng_optimized_image_spec.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {CommonModule} from '@angular/common';
9+
import {CommonModule, DOCUMENT} from '@angular/common';
1010
import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgOptimizedImage} from '@angular/common/src/directives/ng_optimized_image';
1111
import {Component} from '@angular/core';
1212
import {ComponentFixture, TestBed} from '@angular/core/testing';
@@ -25,6 +25,57 @@ describe('Image directive', () => {
2525
expect(img.src.endsWith('/path/img.png')).toBeTrue();
2626
});
2727

28+
it('should set `loading` and `fetchpriority` attributes before `src`', () => {
29+
// Only run this test in a browser since the Node-based DOM mocks don't
30+
// allow to override `HTMLImageElement.prototype.setAttribute` easily.
31+
if (!isBrowser) return;
32+
33+
setupTestingModule();
34+
35+
const template = '<img rawSrc="path/img.png" width="150" height="50" priority>';
36+
TestBed.overrideComponent(TestComponent, {set: {template: template}});
37+
38+
const _document = TestBed.inject(DOCUMENT);
39+
const _window = _document.defaultView!;
40+
const setAttributeSpy =
41+
spyOn(_window.HTMLImageElement.prototype, 'setAttribute').and.callThrough();
42+
43+
const fixture = TestBed.createComponent(TestComponent);
44+
fixture.detectChanges();
45+
46+
const nativeElement = fixture.nativeElement as HTMLElement;
47+
48+
const img = nativeElement.querySelector('img')!;
49+
expect(img.getAttribute('loading')).toBe('eager');
50+
51+
let _imgInstance = null;
52+
let _loadingAttrId = -1;
53+
let _fetchpriorityAttrId = -1;
54+
let _srcAttrId = -1;
55+
const count = setAttributeSpy.calls.count();
56+
for (let i = 0; i < count; i++) {
57+
if (!_imgInstance) {
58+
_imgInstance = setAttributeSpy.calls.thisFor(i);
59+
} else if (_imgInstance !== setAttributeSpy.calls.thisFor(i)) {
60+
// Verify that the <img> instance is the same during the test.
61+
fail('Unexpected instance of a second <img> instance present in a test.');
62+
}
63+
64+
// Note: spy.calls.argsFor(i) returns args as an array: ['src', 'eager']
65+
const attrName = setAttributeSpy.calls.argsFor(i)[0];
66+
if (attrName == 'loading') _loadingAttrId = i;
67+
if (attrName == 'fetchpriority') _fetchpriorityAttrId = i;
68+
if (attrName == 'src') _srcAttrId = i;
69+
}
70+
// Verify that both `loading` and `fetchpriority` are set *before* `src`:
71+
expect(_loadingAttrId).toBeGreaterThan(-1); // was actually set
72+
expect(_loadingAttrId).toBeLessThan(_srcAttrId); // was set after `src`
73+
74+
expect(_fetchpriorityAttrId).toBeGreaterThan(-1); // was actually set
75+
expect(_fetchpriorityAttrId).toBeLessThan(_srcAttrId); // was set after `src`
76+
});
77+
78+
2879
it('should use an image loader provided via `IMAGE_LOADER` token', () => {
2980
const imageLoader = (config: ImageLoaderConfig) => `${config.src}?w=${config.width}`;
3081
setupTestingModule({imageLoader});
@@ -54,6 +105,56 @@ describe('Image directive', () => {
54105
'the `rawSrc` to compute the final image URL and set the `src` itself.');
55106
});
56107
});
108+
109+
describe('lazy loading', () => {
110+
it('should eagerly load priority images', () => {
111+
setupTestingModule();
112+
113+
const template = '<img rawSrc="path/img.png" width="150" height="50" priority>';
114+
const fixture = createTestComponent(template);
115+
fixture.detectChanges();
116+
117+
const nativeElement = fixture.nativeElement as HTMLElement;
118+
const img = nativeElement.querySelector('img')!;
119+
expect(img.getAttribute('loading')).toBe('eager');
120+
});
121+
it('should lazily load non-priority images', () => {
122+
setupTestingModule();
123+
124+
const template = '<img rawSrc="path/img.png" width="150" height="50">';
125+
const fixture = createTestComponent(template);
126+
fixture.detectChanges();
127+
128+
const nativeElement = fixture.nativeElement as HTMLElement;
129+
const img = nativeElement.querySelector('img')!;
130+
expect(img.getAttribute('loading')).toBe('lazy');
131+
});
132+
});
133+
134+
describe('fetch priority', () => {
135+
it('should be "high" for priority images', () => {
136+
setupTestingModule();
137+
138+
const template = '<img rawSrc="path/img.png" width="150" height="50" priority>';
139+
const fixture = createTestComponent(template);
140+
fixture.detectChanges();
141+
142+
const nativeElement = fixture.nativeElement as HTMLElement;
143+
const img = nativeElement.querySelector('img')!;
144+
expect(img.getAttribute('fetchpriority')).toBe('high');
145+
});
146+
it('should be "auto" for non-priority images', () => {
147+
setupTestingModule();
148+
149+
const template = '<img rawSrc="path/img.png" width="150" height="50">';
150+
const fixture = createTestComponent(template);
151+
fixture.detectChanges();
152+
153+
const nativeElement = fixture.nativeElement as HTMLElement;
154+
const img = nativeElement.querySelector('img')!;
155+
expect(img.getAttribute('fetchpriority')).toBe('auto');
156+
});
157+
});
57158
});
58159

59160
// Helpers

0 commit comments

Comments
 (0)