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' ;
1112import { 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.
179264function 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+
183287function imgDirectiveDetails ( dir : NgOptimizedImage ) {
184288 return `The NgOptimizedImage directive (activated on an <img> element ` +
185289 `with the \`rawSrc="${ dir . rawSrc } "\`)` ;
0 commit comments