Skip to content

Commit d3c3426

Browse files
AndrewKushnirPawel Kozlowski
authored andcommitted
feat(common): detect LCP images in NgOptimizedImage and assert if priority is set (#47082)
This commit adds extra logic into the `NgOptimizedImage` experimental directive to detect an LCP image and assert whether the `priority` attribute is applied. PR Close #47082
1 parent 1c06540 commit d3c3426

3 files changed

Lines changed: 114 additions & 9 deletions

File tree

packages/common/src/directives/ng_optimized_image.ts

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

9-
import {Directive, ElementRef, Inject, InjectionToken, Input, NgModule, OnChanges, OnInit, Renderer2, SimpleChanges, ɵRuntimeError as RuntimeError} from '@angular/core';
9+
import {Directive, ElementRef, Inject, Injectable, InjectionToken, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
1010

11+
import {DOCUMENT} from '../dom_tokens';
1112
import {RuntimeErrorCode} from '../errors';
1213

1314
/**
@@ -48,30 +49,100 @@ export const IMAGE_LOADER = new InjectionToken<ImageLoader>('ImageLoader', {
4849
factory: () => noopImageLoader,
4950
});
5051

52+
/**
53+
* Contains the logic to detect whether an image with the `NgOptimizedImage` directive
54+
* is treated as an LCP element. If so, verifies that the image is marked as a priority,
55+
* using the `priority` attribute.
56+
*
57+
* Note: this is a dev-mode only class, which should not appear in prod bundles,
58+
* thus there is no `ngDevMode` use in the code.
59+
*
60+
* Based on https://web.dev/lcp/#measure-lcp-in-javascript.
61+
*/
62+
@Injectable({
63+
providedIn: 'root',
64+
})
65+
class LCPImageObserver implements OnDestroy {
66+
// Map of full image URLs -> image metadata (`raw-src` and `priority`).
67+
private images = new Map<string, {rawSrc: string, priority: boolean}>();
68+
69+
private window: Window|null = null;
70+
private observer: PerformanceObserver|null = null;
71+
72+
constructor(@Inject(DOCUMENT) doc: Document) {
73+
const win = doc.defaultView;
74+
if (typeof win !== 'undefined' && typeof PerformanceObserver !== 'undefined') {
75+
this.window = win;
76+
this.observer = this.initPerformanceObserver();
77+
}
78+
}
79+
80+
// Converts relative image URL to a full URL.
81+
private getFullUrl(src: string) {
82+
return new URL(src, this.window!.location.href).href;
83+
}
84+
85+
// Inits PerformanceObserver and subscribes to LCP events.
86+
// Based on https://web.dev/lcp/#measure-lcp-in-javascript
87+
private initPerformanceObserver(): PerformanceObserver {
88+
const observer = new PerformanceObserver((entryList) => {
89+
for (const entry of entryList.getEntries()) {
90+
// Cast to `any` due to missing `element` on observed type of entry.
91+
const imgSrc = (entry as any).element?.src ?? '';
92+
const img = this.images.get(imgSrc);
93+
// Exclude `data:` and `blob:` URLs, since they are not supported by the directive.
94+
if (img && !img.priority && !imgSrc.startsWith('data:') && !imgSrc.startsWith('blob:')) {
95+
const directiveDetails = imgDirectiveDetails({rawSrc: img.rawSrc} as any);
96+
console.warn(formatRuntimeError(
97+
RuntimeErrorCode.LCP_IMG_MISSING_PRIORITY,
98+
`${directiveDetails}: the image was detected as the Largest Contentful Paint (LCP) ` +
99+
`element, so its loading should be prioritized for optimal performance. Please ` +
100+
`add the "priority" attribute if this image is above the fold.`));
101+
}
102+
}
103+
});
104+
observer.observe({type: 'largest-contentful-paint', buffered: true});
105+
return observer;
106+
}
107+
108+
registerImage(rewrittenSrc: string, rawSrc: string, priority: boolean) {
109+
if (!this.observer) return;
110+
this.images.set(this.getFullUrl(rewrittenSrc), {rawSrc, priority});
111+
}
112+
113+
unregisterImage(rewrittenSrc: string) {
114+
if (!this.observer) return;
115+
this.images.delete(this.getFullUrl(rewrittenSrc));
116+
}
117+
118+
ngOnDestroy() {
119+
if (!this.observer) return;
120+
this.observer.disconnect();
121+
this.images.clear();
122+
}
123+
}
124+
51125
/**
52126
* ** EXPERIMENTAL **
53127
*
54128
* TODO: add Image directive description.
55129
*
56-
* IMPORTANT: this directive should become standalone (i.e. not attached to any NgModule) once
57-
* the `standalone` flag is implemented and available as a public API.
58-
*
59130
* @usageNotes
60131
* TODO: add Image directive usage notes.
61132
*/
62133
@Directive({
63134
standalone: true,
64135
selector: 'img[rawSrc]',
65136
})
66-
export class NgOptimizedImage implements OnInit, OnChanges {
137+
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
67138
constructor(
68139
@Inject(IMAGE_LOADER) private imageLoader: ImageLoader, private renderer: Renderer2,
69-
private imgElement: ElementRef) {}
140+
private imgElement: ElementRef, private injector: Injector) {}
70141

71142
// Private fields to keep normalized input values.
72143
private _width?: number;
73144
private _height?: number;
74-
private _priority?: boolean;
145+
private _priority = false;
75146

76147
/**
77148
* Name of the source image.
@@ -111,7 +182,7 @@ export class NgOptimizedImage implements OnInit, OnChanges {
111182
set priority(value: string|boolean|undefined) {
112183
this._priority = inputToBoolean(value);
113184
}
114-
get priority(): boolean|undefined {
185+
get priority(): boolean {
115186
return this._priority;
116187
}
117188

@@ -131,6 +202,10 @@ export class NgOptimizedImage implements OnInit, OnChanges {
131202
assertNotBlobURL(this);
132203
assertRequiredNumberInput(this, this.width, 'width');
133204
assertRequiredNumberInput(this, this.height, 'height');
205+
withLCPImageObserver(
206+
this.injector,
207+
(observer: LCPImageObserver) =>
208+
observer.registerImage(this.getRewrittenSrc(), this.rawSrc, this.priority));
134209
}
135210
this.setHostAttribute('loading', this.getLoadingBehavior());
136211
this.setHostAttribute('fetchpriority', this.getFetchPriority());
@@ -164,6 +239,15 @@ export class NgOptimizedImage implements OnInit, OnChanges {
164239
return this.imageLoader(imgConfig);
165240
}
166241

242+
ngOnDestroy() {
243+
if (ngDevMode) {
244+
// An image is only registered in dev mode, try to unregister only in dev mode as well.
245+
withLCPImageObserver(
246+
this.injector,
247+
(observer: LCPImageObserver) => observer.unregisterImage(this.getRewrittenSrc()));
248+
}
249+
}
250+
167251
private setHostAttribute(name: string, value: string): void {
168252
this.renderer.setAttribute(this.imgElement.nativeElement, name, value);
169253
}
@@ -176,10 +260,30 @@ function inputToInteger(value: string|number|undefined): number|undefined {
176260
return typeof value === 'string' ? parseInt(value, 10) : value;
177261
}
178262

263+
// Convert input value to boolean.
179264
function inputToBoolean(value: unknown): boolean {
180265
return value != null && `${value}` !== 'false';
181266
}
182267

268+
/**
269+
* Invokes a function, passing an instance of the `LCPImageObserver` as an argument.
270+
*
271+
* Notes:
272+
* - the `LCPImageObserver` is a tree-shakable provider, provided in 'root',
273+
* thus it's a singleton within this application
274+
* - the process of `LCPImageObserver` creation and an actual operation are invoked outside of the
275+
* NgZone to make sure none of the calls inside the `LCPImageObserver` class trigger unnecessary
276+
* change detection
277+
*/
278+
function withLCPImageObserver(
279+
injector: Injector, operation: (observer: LCPImageObserver) => void): void {
280+
const ngZone = injector.get(NgZone);
281+
return ngZone.runOutsideAngular(() => {
282+
const observer = injector.get(LCPImageObserver);
283+
operation(observer);
284+
});
285+
}
286+
183287
function imgDirectiveDetails(dir: NgOptimizedImage) {
184288
return `The NgOptimizedImage directive (activated on an <img> element ` +
185289
`with the \`rawSrc="${dir.rawSrc}"\`)`;

packages/common/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ export const enum RuntimeErrorCode {
2525
INVALID_INPUT = 2951,
2626
UNEXPECTED_INPUT_CHANGE = 2952,
2727
REQUIRED_INPUT_MISSING = 2953,
28+
LCP_IMG_MISSING_PRIORITY = 2954,
2829
}

packages/core/src/core_private_export.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export {getDebugNodeR2 as ɵgetDebugNodeR2} from './debug/debug_node';
1515
export {setCurrentInjector as ɵsetCurrentInjector} from './di/injector_compatibility';
1616
export {getInjectableDef as ɵgetInjectableDef, ɵɵInjectableDeclaration, ɵɵInjectorDef} from './di/interface/defs';
1717
export {INJECTOR_SCOPE as ɵINJECTOR_SCOPE} from './di/scope';
18-
export {RuntimeError as ɵRuntimeError} from './errors';
18+
export {formatRuntimeError as ɵformatRuntimeError, RuntimeError as ɵRuntimeError} from './errors';
1919
export {CurrencyIndex as ɵCurrencyIndex, ExtraLocaleDataIndex as ɵExtraLocaleDataIndex, findLocaleData as ɵfindLocaleData, getLocaleCurrencyCode as ɵgetLocaleCurrencyCode, getLocalePluralCase as ɵgetLocalePluralCase, LocaleDataIndex as ɵLocaleDataIndex, registerLocaleData as ɵregisterLocaleData, unregisterAllLocaleData as ɵunregisterLocaleData} from './i18n/locale_data_api';
2020
export {DEFAULT_LOCALE_ID as ɵDEFAULT_LOCALE_ID} from './i18n/localization';
2121
export {ComponentFactory as ɵComponentFactory} from './linker/component_factory';

0 commit comments

Comments
 (0)