/
image_performance_warning.ts
173 lines (155 loc) Β· 7.38 KB
/
image_performance_warning.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {IMAGE_CONFIG, ImageConfig} from './application_tokens';
import {Injectable} from './di';
import {inject} from './di/injector_compatibility';
import {formatRuntimeError, RuntimeErrorCode} from './errors';
import {OnDestroy} from './interface/lifecycle_hooks';
import {getDocument} from './render3/interfaces/document';
import {NgZone} from './zone';
// A delay in milliseconds before the scan is run after onLoad, to avoid any
// potential race conditions with other LCP-related functions. This delay
// happens outside of the main JavaScript execution and will only effect the timing
// on when the warning becomes visible in the console.
const SCAN_DELAY = 200;
const OVERSIZED_IMAGE_TOLERANCE = 1200;
@Injectable({providedIn: 'root'})
export class ImagePerformanceWarning implements OnDestroy {
// Map of full image URLs -> original `ngSrc` values.
private window: Window|null = null;
private observer: PerformanceObserver|null = null;
private options: ImageConfig = inject(IMAGE_CONFIG);
private ngZone = inject(NgZone);
private lcpImageUrl?: string;
public start() {
if (typeof PerformanceObserver === 'undefined' ||
(this.options?.disableImageSizeWarning && this.options?.disableImageLazyLoadWarning)) {
return;
}
this.observer = this.initPerformanceObserver();
const win = getDocument().defaultView;
if (typeof win !== 'undefined') {
this.window = win;
// Wait to avoid race conditions where LCP image triggers
// load event before it's recorded by the performance observer
const waitToScan = () => {
setTimeout(this.scanImages.bind(this), SCAN_DELAY);
};
// Angular doesn't have to run change detection whenever any asynchronous tasks are invoked in
// the scope of this functionality.
this.ngZone.runOutsideAngular(() => {
this.window?.addEventListener('load', waitToScan);
});
}
}
ngOnDestroy() {
this.observer?.disconnect();
}
private initPerformanceObserver(): PerformanceObserver|null {
if (typeof PerformanceObserver === 'undefined') {
return null;
}
const observer = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
if (entries.length === 0) return;
// We use the latest entry produced by the `PerformanceObserver` as the best
// signal on which element is actually an LCP one. As an example, the first image to load on
// a page, by virtue of being the only thing on the page so far, is often a LCP candidate
// and gets reported by PerformanceObserver, but isn't necessarily the LCP element.
const lcpElement = entries[entries.length - 1];
// Cast to `any` due to missing `element` on the `LargestContentfulPaint` type of entry.
// See https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint
const imgSrc = (lcpElement as any).element?.src ?? '';
// Exclude `data:` and `blob:` URLs, since they are fetched resources.
if (imgSrc.startsWith('data:') || imgSrc.startsWith('blob:')) return;
this.lcpImageUrl = imgSrc;
});
observer.observe({type: 'largest-contentful-paint', buffered: true});
return observer;
}
private scanImages(): void {
const images = getDocument().querySelectorAll('img');
let lcpElementFound, lcpElementLoadedCorrectly = false;
images.forEach(image => {
if (!this.options?.disableImageSizeWarning) {
for (const image of images) {
// Image elements using the NgOptimizedImage directive are excluded,
// as that directive has its own version of this check.
if (!image.getAttribute('ng-img') && this.isOversized(image)) {
logOversizedImageWarning(image.src);
}
}
}
if (!this.options?.disableImageLazyLoadWarning && this.lcpImageUrl) {
if (image.src === this.lcpImageUrl) {
lcpElementFound = true;
if (image.loading !== 'lazy' || image.getAttribute('ng-img')) {
// This variable is set to true and never goes back to false to account
// for the case where multiple images have the same src url, and some
// have lazy loading while others don't.
// Also ignore NgOptimizedImage because there's a different warning for that.
lcpElementLoadedCorrectly = true;
}
}
}
});
if (lcpElementFound && !lcpElementLoadedCorrectly && this.lcpImageUrl &&
!this.options?.disableImageLazyLoadWarning) {
logLazyLCPWarning(this.lcpImageUrl);
}
}
private isOversized(image: HTMLImageElement): boolean {
if (!this.window) {
return false;
}
const computedStyle = this.window.getComputedStyle(image);
let renderedWidth = parseFloat(computedStyle.getPropertyValue('width'));
let renderedHeight = parseFloat(computedStyle.getPropertyValue('height'));
const boxSizing = computedStyle.getPropertyValue('box-sizing');
const objectFit = computedStyle.getPropertyValue('object-fit');
if (objectFit === `cover`) {
// Object fit cover may indicate a use case such as a sprite sheet where
// this warning does not apply.
return false;
}
if (boxSizing === 'border-box') {
const paddingTop = computedStyle.getPropertyValue('padding-top');
const paddingRight = computedStyle.getPropertyValue('padding-right');
const paddingBottom = computedStyle.getPropertyValue('padding-bottom');
const paddingLeft = computedStyle.getPropertyValue('padding-left');
renderedWidth -= parseFloat(paddingRight) + parseFloat(paddingLeft);
renderedHeight -= parseFloat(paddingTop) + parseFloat(paddingBottom);
}
const intrinsicWidth = image.naturalWidth;
const intrinsicHeight = image.naturalHeight;
const recommendedWidth = this.window.devicePixelRatio * renderedWidth;
const recommendedHeight = this.window.devicePixelRatio * renderedHeight;
const oversizedWidth = (intrinsicWidth - recommendedWidth) >= OVERSIZED_IMAGE_TOLERANCE;
const oversizedHeight = (intrinsicHeight - recommendedHeight) >= OVERSIZED_IMAGE_TOLERANCE;
return oversizedWidth || oversizedHeight;
}
}
function logLazyLCPWarning(src: string) {
console.warn(formatRuntimeError(
RuntimeErrorCode.IMAGE_PERFORMANCE_WARNING,
`An image with src ${src} is the Largest Contentful Paint (LCP) element ` +
`but was given a "loading" value of "lazy", which can negatively impact ` +
`application loading performance. This warning can be addressed by ` +
`changing the loading value of the LCP image to "eager", or by using the ` +
`NgOptimizedImage directive's prioritization utilities. For more ` +
`information about addressing or disabling this warning, see ` +
`https://angular.io/errors/NG0913`));
}
function logOversizedImageWarning(src: string) {
console.warn(formatRuntimeError(
RuntimeErrorCode.IMAGE_PERFORMANCE_WARNING,
`An image with src ${src} has intrinsic file dimensions much larger than its ` +
`rendered size. This can negatively impact application loading performance. ` +
`For more information about addressing or disabling this warning, see ` +
`https://angular.io/errors/NG0913`));
}