-
Notifications
You must be signed in to change notification settings - Fork 26.2k
/
Copy pathimage_performance_warning.ts
221 lines (200 loc) · 9.08 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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
/**
* @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.dev/license
*/
import {IMAGE_CONFIG, ImageConfig} from './application/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';
// 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 lcpImageUrl?: string;
public start() {
if (
(typeof ngServerMode !== 'undefined' && ngServerMode) ||
typeof PerformanceObserver === 'undefined' ||
(this.options?.disableImageSizeWarning && this.options?.disableImageLazyLoadWarning)
) {
return;
}
this.observer = this.initPerformanceObserver();
const doc = getDocument();
const win = doc.defaultView;
if (win) {
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);
};
const setup = () => {
// Consider the case when the application is created and destroyed multiple times.
// Typically, applications are created instantly once the page is loaded, and the
// `window.load` listener is always triggered. However, the `window.load` event will never
// be fired if the page is loaded, and the application is created later. Checking for
// `readyState` is the easiest way to determine whether the page has been loaded or not.
if (doc.readyState === 'complete') {
waitToScan();
} else {
this.window?.addEventListener('load', waitToScan, {once: true});
}
};
// Angular doesn't have to run change detection whenever any asynchronous tasks are invoked in
// the scope of this functionality.
if (typeof Zone !== 'undefined') {
Zone.root.run(() => setup());
} else {
setup();
}
}
}
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) {
// 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;
}
// The `isOversized` check may not be applicable or may require adjustments
// for several types of image formats or scenarios. Currently, we specify only
// `svg`, but this may also include `gif` since their quality isn’t tied to
// dimensions in the same way as raster images.
const nonOversizedImageExtentions = [
// SVG images are vector-based, which means they can scale
// to any size without losing quality.
'.svg',
];
// Convert it to lowercase because this may have uppercase
// extensions, such as `IMAGE.SVG`.
// We fallback to an empty string because `src` may be `undefined`
// if it is explicitly set to `null` by some third-party code
// (e.g., `image.src = null`).
const imageSource = (image.src || '').toLowerCase();
if (nonOversizedImageExtentions.some((extension) => imageSource.endsWith(extension))) {
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') {
// If the image `box-sizing` is set to `border-box`, we adjust the rendered
// dimensions by subtracting padding values.
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.dev/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.dev/errors/NG0913`,
),
);
}