diff --git a/goldens/public-api/common/errors.md b/goldens/public-api/common/errors.md
index f3858153c562a..e24e5d7adf226 100644
--- a/goldens/public-api/common/errors.md
+++ b/goldens/public-api/common/errors.md
@@ -25,6 +25,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
REQUIRED_INPUT_MISSING = 2954,
// (undocumented)
+ TOO_MANY_PRELOADED_IMAGES = 2961,
+ // (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 fc1ac8469a9c0..9d24eea2ffb3c 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
@@ -6,14 +6,16 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {Directive, ElementRef, inject, InjectionToken, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
+import {Directive, ElementRef, inject, InjectionToken, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, PLATFORM_ID, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
import {RuntimeErrorCode} from '../../errors';
+import {isPlatformServer} from '../../platform_id';
import {imgDirectiveDetails} from './error_helper';
import {IMAGE_LOADER} from './image_loaders/image_loader';
import {LCPImageObserver} from './lcp_image_observer';
import {PreconnectLinkChecker} from './preconnect_link_checker';
+import {PreloadLinkCreator} from './preload-link-creator';
/**
* When a Base64-encoded image is passed as an input to the `NgOptimizedImage` directive,
@@ -207,6 +209,8 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
private renderer = inject(Renderer2);
private imgElement: HTMLImageElement = inject(ElementRef).nativeElement;
private injector = inject(Injector);
+ private readonly isServer = isPlatformServer(inject(PLATFORM_ID));
+ private readonly preloadLinkChecker = inject(PreloadLinkCreator);
// a LCP image observer - should be injected only in the dev mode
private lcpObserver = ngDevMode ? this.injector.get(LCPImageObserver) : null;
@@ -386,14 +390,28 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
this.setHostAttribute('fetchpriority', this.getFetchPriority());
// The `src` and `srcset` attributes should be set last since other attributes
// could affect the image's loading behavior.
- this.setHostAttribute('src', this.getRewrittenSrc());
+ const rewrittenSrc = this.getRewrittenSrc();
+ this.setHostAttribute('src', rewrittenSrc);
+
+ let rewrittenSrcset: string|undefined = undefined;
+
if (this.sizes) {
this.setHostAttribute('sizes', this.sizes);
}
+
if (this.ngSrcset) {
- this.setHostAttribute('srcset', this.getRewrittenSrcset());
+ rewrittenSrcset = this.getRewrittenSrcset();
} else if (!this._disableOptimizedSrcset && !this.srcset) {
- this.setHostAttribute('srcset', this.getAutomaticSrcset());
+ rewrittenSrcset = this.getAutomaticSrcset();
+ }
+
+ if (rewrittenSrcset) {
+ this.setHostAttribute('srcset', rewrittenSrcset);
+ }
+
+ if (this.isServer && this.priority) {
+ this.preloadLinkChecker.createPreloadLinkTag(
+ this.renderer, rewrittenSrc, rewrittenSrcset, this.sizes);
}
}
diff --git a/packages/common/src/directives/ng_optimized_image/preload-link-creator.ts b/packages/common/src/directives/ng_optimized_image/preload-link-creator.ts
new file mode 100644
index 0000000000000..f2f65f1e1ec82
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/preload-link-creator.ts
@@ -0,0 +1,79 @@
+/**
+ * @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 {inject, Injectable, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core';
+
+import {DOCUMENT} from '../../dom_tokens';
+import {RuntimeErrorCode} from '../../errors';
+
+import {DEFAULT_PRELOADED_IMAGES_LIMIT, PRELOADED_IMAGES} from './tokens';
+
+/**
+ * @description Contains the logic needed to track and add preload link tags to the `
` tag. It
+ * will also track what images have already had preload link tags added so as to not duplicate link
+ * tags.
+ *
+ * In dev mode this service will validate that the number of preloaded images does not exceed the
+ * configured default preloaded images limit: {@link DEFAULT_PRELOADED_IMAGES_LIMIT}.
+ */
+@Injectable({providedIn: 'root'})
+export class PreloadLinkCreator {
+ private readonly preloadedImages = inject(PRELOADED_IMAGES);
+ private readonly document = inject(DOCUMENT);
+
+ /**
+ * @description Add a preload `` to the `` of the `index.html` that is served from the
+ * server while using Angular Universal and SSR to kick off image loads for high priority images.
+ *
+ * The `sizes` (passed in from the user) and `srcset` (parsed and formatted from `ngSrcset`)
+ * properties used to set the corresponding attributes, `imagesizes` and `imagesrcset`
+ * respectively, on the preload `` tag so that the correctly sized image is preloaded from
+ * the CDN.
+ *
+ * {@link https://web.dev/preload-responsive-images/#imagesrcset-and-imagesizes}
+ *
+ * @param renderer The `Renderer2` passed in from the directive
+ * @param src The original src of the image that is set on the `ngSrc` input.
+ * @param srcset The parsed and formatted srcset created from the `ngSrcset` input
+ * @param sizes The value of the `sizes` attribute passed in to the `` tag
+ */
+ createPreloadLinkTag(renderer: Renderer2, src: string, srcset?: string, sizes?: string): void {
+ if (ngDevMode) {
+ if (this.preloadedImages.size >= DEFAULT_PRELOADED_IMAGES_LIMIT) {
+ throw new RuntimeError(
+ RuntimeErrorCode.TOO_MANY_PRELOADED_IMAGES,
+ ngDevMode &&
+ `The \`NgOptimizedImage\` directive has detected that more than ` +
+ `${DEFAULT_PRELOADED_IMAGES_LIMIT} images were marked as priority. ` +
+ `This might negatively affect an overall performance of the page. ` +
+ `To fix this, remove the "priority" attribute from images with less priority.`);
+ }
+ }
+
+ if (this.preloadedImages.has(src)) {
+ return;
+ }
+
+ this.preloadedImages.add(src);
+
+ const preload = renderer.createElement('link');
+ renderer.setAttribute(preload, 'as', 'image');
+ renderer.setAttribute(preload, 'href', src);
+ renderer.setAttribute(preload, 'rel', 'preload');
+
+ if (sizes) {
+ renderer.setAttribute(preload, 'imageSizes', sizes);
+ }
+
+ if (srcset) {
+ renderer.setAttribute(preload, 'imageSrcset', srcset);
+ }
+
+ renderer.appendChild(this.document.head, preload);
+ }
+}
diff --git a/packages/common/src/directives/ng_optimized_image/tokens.ts b/packages/common/src/directives/ng_optimized_image/tokens.ts
new file mode 100644
index 0000000000000..ab09b9b6475e6
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/tokens.ts
@@ -0,0 +1,27 @@
+/**
+ * @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 {InjectionToken} from '@angular/core';
+
+/**
+ * In SSR scenarios, a preload `` element is generated for priority images.
+ * Having a large number of preload tags may negatively affect the performance,
+ * so we warn developers (by throwing an error) if the number of preloaded images
+ * is above a certain threshold. This const specifies this threshold.
+ */
+export const DEFAULT_PRELOADED_IMAGES_LIMIT = 5;
+
+/**
+ * Helps to keep track of priority images that already have a corresponding
+ * preload tag (to avoid generating multiple preload tags with the same URL).
+ *
+ * This Set tracks the original src passed into the `ngSrc` input not the src after it has been
+ * run through the specified `IMAGE_LOADER`.
+ */
+export const PRELOADED_IMAGES = new InjectionToken>(
+ 'NG_OPTIMIZED_PRELOADED_IMAGES', {providedIn: 'root', factory: () => new Set()});
diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts
index 5ec4986f2b123..d8a3f06ca97f8 100644
--- a/packages/common/src/errors.ts
+++ b/packages/common/src/errors.ts
@@ -31,4 +31,5 @@ export const enum RuntimeErrorCode {
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
INVALID_LOADER_ARGUMENTS = 2959,
OVERSIZED_IMAGE = 2960,
+ TOO_MANY_PRELOADED_IMAGES = 2961,
}
diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts
index 49284f4e3ec7c..8483bf6d5a3d8 100644
--- a/packages/common/test/directives/ng_optimized_image_spec.ts
+++ b/packages/common/test/directives/ng_optimized_image_spec.ts
@@ -8,16 +8,186 @@
import {CommonModule, DOCUMENT} from '@angular/common';
import {RuntimeErrorCode} from '@angular/common/src/errors';
-import {Component, Provider, Type} from '@angular/core';
+import {PLATFORM_SERVER_ID} from '@angular/common/src/platform_id';
+import {Component, PLATFORM_ID, Provider, Type} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {expect} from '@angular/platform-browser/testing/src/matchers';
import {withHead} from '@angular/private/testing';
+import {PRELOADED_IMAGES} from '../..//src/directives/ng_optimized_image/tokens';
import {createImageLoader, IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from '../../src/directives/ng_optimized_image/image_loaders/image_loader';
import {ABSOLUTE_SRCSET_DENSITY_CAP, assertValidNgSrcset, IMAGE_CONFIG, ImageConfig, NgOptimizedImage, RECOMMENDED_SRCSET_DENSITY_CAP} from '../../src/directives/ng_optimized_image/ng_optimized_image';
import {PRECONNECT_CHECK_BLOCKLIST} from '../../src/directives/ng_optimized_image/preconnect_link_checker';
describe('Image directive', () => {
+ describe('preload element on a server', () => {
+ it('should create `` element when the image priority attr is true', () => {
+ // Only run this test in a browser since the Node-based DOM mocks don't
+ // allow to override `HTMLImageElement.prototype.setAttribute` easily.
+ if (!isBrowser) return;
+
+ const src = 'preload1/img.png';
+
+ setupTestingModule({
+ extraProviders: [
+ {provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}, {
+ provide: IMAGE_LOADER,
+ useValue: (config: ImageLoaderConfig) => config.width ?
+ `https://angular.io/${config.src}?width=${config.width}` :
+ `https://angular.io/${config.src}`
+ }
+ ]
+ });
+
+ const template =
+ ``;
+ TestBed.overrideComponent(TestComponent, {set: {template: template}});
+
+ const _document = TestBed.inject(DOCUMENT);
+ const _window = _document.defaultView!;
+ const setAttributeSpy =
+ spyOn(_window.HTMLLinkElement.prototype, 'setAttribute').and.callThrough();
+
+ const fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+
+ const head = _document.head;
+
+ const rewrittenSrc = `https://angular.io/${src}`;
+
+ const preloadLink = head.querySelector(`link[href="${rewrittenSrc}"]`);
+
+ expect(preloadLink).toBeTruthy();
+
+ const [name, value] = setAttributeSpy.calls.argsFor(0);
+
+ expect(name).toEqual('as');
+ expect(value).toEqual('image');
+
+ expect(preloadLink!.getAttribute('rel')).toEqual('preload');
+ expect(preloadLink!.getAttribute('as')).toEqual('image');
+ expect(preloadLink!.getAttribute('imagesizes')).toEqual('10vw');
+ expect(preloadLink!.getAttribute('imagesrcset')).toEqual(`${rewrittenSrc}?width=100 100w`);
+
+ preloadLink!.remove();
+ });
+
+ it('should not create a preload `` element when src is already preloaded.', () => {
+ // Only run this test in a browser since the Node-based DOM mocks don't
+ // allow to override `HTMLImageElement.prototype.setAttribute` easily.
+ if (!isBrowser) return;
+
+ const src = `preload2/img.png`;
+
+ const rewrittenSrc = `https://angular.io/${src}`;
+
+ setupTestingModule({
+ extraProviders: [
+ {provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}, {
+ provide: IMAGE_LOADER,
+ useValue: (config: ImageLoaderConfig) => `https://angular.io/${config.src}`
+ }
+ ]
+ });
+
+ const template = ``;
+ TestBed.overrideComponent(TestComponent, {set: {template: template}});
+
+ const _document = TestBed.inject(DOCUMENT);
+
+ const fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+
+ const head = _document.head;
+
+ const preloadImages = TestBed.inject(PRELOADED_IMAGES);
+
+ expect(preloadImages.has(rewrittenSrc)).toBeTruthy();
+
+ const preloadLinks = head.querySelectorAll(`link[href="${rewrittenSrc}"]`);
+
+ expect(preloadLinks.length).toEqual(1);
+
+ preloadLinks[0]!.remove();
+ });
+
+ it('should error when the number of preloaded images is larger than the limit', () => {
+ // Only run this test in a browser since the Node-based DOM mocks don't
+ // allow to override `HTMLImageElement.prototype.setAttribute` easily.
+ if (!isBrowser) return;
+
+ setupTestingModule({
+ extraProviders: [
+ {provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}, {
+ provide: IMAGE_LOADER,
+ useValue: (config: ImageLoaderConfig) => `https://angular.io/${config.src}`
+ }
+ ]
+ });
+
+ const template = `
+
+
+
+
+
+
+
+
+
+ `;
+
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02961: The `NgOptimizedImage` directive has detected that more than 5 images were marked as priority. This might negatively affect an overall performance of the page. To fix this, remove the "priority" attribute from images with less priority.');
+ });
+
+ it('should not hit max preload limit when not on the server', () => {
+ // Only run this test in a browser since the Node-based DOM mocks don't
+ // allow to override `HTMLImageElement.prototype.setAttribute` easily.
+ if (!isBrowser) return;
+
+ setupTestingModule({
+ extraProviders: [{
+ provide: IMAGE_LOADER,
+ useValue: (config: ImageLoaderConfig) => `https://angular.io/${config.src}`
+ }]
+ });
+
+ const template = `
+
+
+
+
+
+
+
+
+
+ `;
+
+ TestBed.overrideComponent(TestComponent, {set: {template: template}});
+
+ const _document = TestBed.inject(DOCUMENT);
+
+ const fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+
+ const head = _document.head;
+
+ const preloadImages = TestBed.inject(PRELOADED_IMAGES);
+
+ const preloadLinks = head.querySelectorAll(`link[preload]`);
+
+ expect(preloadImages.size).toEqual(0);
+ expect(preloadLinks.length).toEqual(0);
+ });
+ });
+
it('should set `loading` and `fetchpriority` attributes before `src`', () => {
// Only run this test in a browser since the Node-based DOM mocks don't
// allow to override `HTMLImageElement.prototype.setAttribute` easily.
@@ -741,9 +911,7 @@ describe('Image directive', () => {
describe('PRECONNECT_CHECK_BLOCKLIST token', () => {
it(`should allow passing host names`, withHead('', () => {
- const providers = [
- {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'angular.io'},
- ];
+ const providers = [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'angular.io'}];
setupTestingModule({imageLoader, extraProviders: providers});
const consoleWarnSpy = spyOn(console, 'warn');
@@ -756,9 +924,8 @@ describe('Image directive', () => {
}));
it(`should allow passing origins`, withHead('', () => {
- const providers = [
- {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'https://angular.io'},
- ];
+ const providers =
+ [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'https://angular.io'}];
setupTestingModule({imageLoader, extraProviders: providers});
const consoleWarnSpy = spyOn(console, 'warn');
@@ -771,9 +938,8 @@ describe('Image directive', () => {
}));
it(`should allow passing arrays of host names`, withHead('', () => {
- const providers = [
- {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: ['https://angular.io']},
- ];
+ const providers =
+ [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: ['https://angular.io']}];
setupTestingModule({imageLoader, extraProviders: providers});
const consoleWarnSpy = spyOn(console, 'warn');
@@ -786,9 +952,8 @@ describe('Image directive', () => {
}));
it(`should allow passing nested arrays of host names`, withHead('', () => {
- const providers = [
- {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: [['https://angular.io']]},
- ];
+ const providers =
+ [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: [['https://angular.io']]}];
setupTestingModule({imageLoader, extraProviders: providers});
const consoleWarnSpy = spyOn(console, 'warn');
@@ -1207,10 +1372,7 @@ const IMG_BASE_URL = {
const ANGULAR_LOGO_BASE64 =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg==';
-@Component({
- selector: 'test-cmp',
- template: '',
-})
+@Component({selector: 'test-cmp', template: ''})
class TestComponent {
width = 100;
height = 50;
@@ -1231,9 +1393,8 @@ function setupTestingModule(config?: {
const loader = config?.imageLoader || defaultLoader;
const extraProviders = config?.extraProviders || [];
const providers: Provider[] = [
- {provide: DOCUMENT, useValue: window.document},
- {provide: IMAGE_LOADER, useValue: loader},
- ...extraProviders,
+ {provide: DOCUMENT, useValue: window.document}, {provide: IMAGE_LOADER, useValue: loader},
+ ...extraProviders
];
if (config?.imageConfig) {
providers.push({provide: IMAGE_CONFIG, useValue: config.imageConfig});
@@ -1244,7 +1405,7 @@ function setupTestingModule(config?: {
// Note: the `NgOptimizedImage` directive is experimental and is not a part of the
// `CommonModule` yet, so it's imported separately.
imports: [CommonModule, NgOptimizedImage],
- providers,
+ providers
});
}