diff --git a/goldens/public-api/common/errors.api.md b/goldens/public-api/common/errors.api.md index 5ed18588252..ff719b033d1 100644 --- a/goldens/public-api/common/errors.api.md +++ b/goldens/public-api/common/errors.api.md @@ -37,6 +37,8 @@ export const enum RuntimeErrorCode { // (undocumented) TOO_MANY_PRELOADED_IMAGES = 2961, // (undocumented) + TOO_MANY_PRIORITY_ATTRIBUTES = 2966, + // (undocumented) UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958, // (undocumented) UNEXPECTED_INPUT_CHANGE = 2953, diff --git a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts index dc4d53c850b..33d14674b7e 100644 --- a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts +++ b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts @@ -137,6 +137,18 @@ export const BUILT_IN_LOADERS = [ netlifyLoaderInfo, ]; +/** + * Threshold for the PRIORITY_TRUE_COUNT + */ +const PRIORITY_COUNT_THRESHOLD = 10; + +/** + * This count is used to log a devMode warning + * when the count of directive instances with priority=true + * exceeds the threshold PRIORITY_COUNT_THRESHOLD + */ +let IMGS_WITH_PRIORITY_ATTR_COUNT = 0; + /** * Config options used in rendering placeholder images. * @@ -430,6 +442,14 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { if (this.priority) { const checker = this.injector.get(PreconnectLinkChecker); checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc); + + // This leaves the Angular zone to avoid triggering unnecessary change detection cycles when + // document.addEventListener is invoked + if (!this.isServer) { + ngZone.runOutsideAngular(() => { + assetPriorityCountBelowThreshold(); + }); + } } } if (this.placeholder) { @@ -1245,6 +1265,27 @@ function assertNoLoaderParamsWithoutLoader(dir: NgOptimizedImage, imageLoader: I } } +/** + * Warns if the priority attribute is used too often on page load + */ +function assetPriorityCountBelowThreshold() { + if (IMGS_WITH_PRIORITY_ATTR_COUNT === 0) { + document.addEventListener('DOMContentLoaded', (event) => { + if (IMGS_WITH_PRIORITY_ATTR_COUNT > PRIORITY_COUNT_THRESHOLD) { + console.warn( + formatRuntimeError( + RuntimeErrorCode.TOO_MANY_PRIORITY_ATTRIBUTES, + `NgOptimizedImage: The "priority" attribute is set to true more than ${PRIORITY_COUNT_THRESHOLD} times (${IMGS_WITH_PRIORITY_ATTR_COUNT} times). ` + + `Marking too many images as "high" priority can hurt your application's LCP (https://web.dev/lcp).` + + `"Priority" should only be set on the image expected to be the page's LCP element.`, + ), + ); + } + }); + } + IMGS_WITH_PRIORITY_ATTR_COUNT++; +} + function round(input: number): number | string { return Number.isInteger(input) ? input : input.toFixed(2); } diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts index 17af8225d9e..6970f1c2def 100644 --- a/packages/common/src/errors.ts +++ b/packages/common/src/errors.ts @@ -39,4 +39,5 @@ export const enum RuntimeErrorCode { MISSING_NECESSARY_LOADER = 2963, LCP_IMG_NGSRC_MODIFIED = 2964, OVERSIZED_PLACEHOLDER = 2965, + TOO_MANY_PRIORITY_ATTRIBUTES = 2966, } diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts index 04496b19e55..83304d0e57a 100644 --- a/packages/common/test/directives/ng_optimized_image_spec.ts +++ b/packages/common/test/directives/ng_optimized_image_spec.ts @@ -870,6 +870,51 @@ describe('Image directive', () => { const img = nativeElement.querySelector('img')!; expect(img.getAttribute('fetchpriority')).toBe('auto'); }); + + it('should log a warning if the priority attribute is used too often', async () => { + withHead('', () => { + const imageLoader = () => { + // We need something different from the `localhost` (as we don't want to produce + // a preconnect warning for local environments). + return 'https://angular.io/assets/images/logos/angular/angular.svg'; + }; + + setupTestingModule({imageLoader}); + + // 11 priority attributes, threshold is 10 + const template = ` + + + + + + + + + + + + `; + const consoleWarnSpy = spyOn(console, 'warn'); + + const fixture = createTestComponent(template); + fixture.detectChanges(); + + // We manually fire the event that is listened by the directive + // as it won't fire in the context of our unit test + document.dispatchEvent(new Event('DOMContentLoaded')); + + if (isBrowser) { + expect(consoleWarnSpy.calls.count()).toBe(1); + expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( + new RegExp(`NG0${RuntimeErrorCode.TOO_MANY_PRIORITY_ATTRIBUTES}`), + ); + } else { + // The warning is only logged on browsers + expect(consoleWarnSpy.calls.count()).toBe(0); + } + }); + }); }); describe('meta data', () => {