+Notice: Angular 13.4.0 includes a backported version of the NgOptimizedImage directive present in later versions of Angular. This was implemented by the Chrome Aurora team, as a special project to make performance enhancements available to more applications built on Angular. This does not reflect a shift in Angular release procedure and does not indicate that any additional features will be backported to earlier Angular versions.
+
+
+# Getting started with NgOptimizedImage
+
+The `NgOptimizedImage` directive makes it easy to adopt performance best practices for loading images.
+
+The directive ensures that the loading of the [Largest Contentful Paint (LCP)](http://web.dev/lcp) image is prioritized by:
+
+* Automatically setting the `fetchpriority` attribute on the `` tag
+* Lazy loading other images by default
+* Asserting that there is a corresponding preconnect link tag in the document head
+* Automatically generating a `srcset` attribute
+* Generating a [preload hint](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload) if app is using SSR
+
+In addition to optimizing the loading of the LCP image, `NgOptimizedImage` enforces a number of image best practices, such as:
+
+* Using [image CDN URLs to apply image optimizations](https://web.dev/image-cdns/#how-image-cdns-use-urls-to-indicate-optimization-options)
+* Preventing layout shift by requiring `width` and `height`
+* Warning if `width` or `height` have been set incorrectly
+* Warning if the image will be visually distorted when rendered
+
+## Getting Started
+
+#### Step 1: Import NgOptimizedImageModule
+
+
+
+import { NgOptimizedImageModule } from '@angular/common'
+
+
+
+#### Step 2: (Optional) Set up a Loader
+
+An image loader is not **required** in order to use NgOptimizedImage, but using one with an image CDN enables powerful performance features, including automatic `srcset`s for your images.
+
+A brief guide for setting up a loader can be found in the [Configuring an Image Loader](#configuring-an-image-loader-for-ngoptimizedimage) section at the end of this page.
+
+#### Step 3: Enable the directive
+
+To activate the `NgOptimizedImage` directive, replace your image's `src` attribute with `ngSrc`.
+
+
+
+<img ngSrc="cat.jpg">
+
+
+
+If you're using a [built-in third-party loader](#built-in-loaders), make sure to omit the base URL path from `src`, as that will be prepended automatically by the loader.
+
+#### Step 4: Mark images as `priority`
+
+Always mark the [LCP image](https://web.dev/lcp/#what-elements-are-considered) on your page as `priority` to prioritize its loading.
+
+
+
+<img ngSrc="cat.jpg" width="400" height="200" priority>
+
+
+
+Marking an image as `priority` applies the following optimizations:
+
+* Sets `fetchpriority=high` (read more about priority hints [here](https://web.dev/priority-hints))
+* Sets `loading=eager` (read more about native lazy loading [here](https://web.dev/browser-level-image-lazy-loading))
+* Automatically generates a [preload link element](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload) if [rendering on the server](/guide/universal).
+
+Angular displays a warning during development if the LCP element is an image that does not have the `priority` attribute. A page’s LCP element can vary based on a number of factors - such as the dimensions of a user's screen, so a page may have multiple images that should be marked `priority`. See [CSS for Web Vitals](https://web.dev/css-web-vitals/#images-and-largest-contentful-paint-lcp) for more details.
+
+#### Step 5: Include Height and Width
+
+In order to prevent [image-related layout shifts](https://web.dev/css-web-vitals/#images-and-layout-shifts), NgOptimizedImage requires that you specify a height and width for your image, as follows:
+
+
+
+<img ngSrc="cat.jpg" width="400" height="200">
+
+
+
+For **responsive images** (images which you've styled to grow and shrink relative to the viewport), the `width` and `height` attributes should be the instrinsic size of the image file.
+
+For **fixed size images**, the `width` and `height` attributes should reflect the desired rendered size of the image. The aspect ratio of these attributes should always match the intrinsic aspect ratio of the image.
+
+Note: If you don't know the size of your images, consider using "fill mode" to inherit the size of the parent container, as described below:
+
+### Using `fill` mode
+
+In cases where you want to have an image fill a containing element, you can use the `fill` attribute. This is often useful when you want to achieve a "background image" behavior. It can also be helpful when you don't know the exact width and height of your image, but you do have a parent container with a known size that you'd like to fit your image into (see "object-fit" below).
+
+When you add the `fill` attribute to your image, you do not need and should not include a `width` and `height`, as in this example:
+
+
+
+<img ngSrc="cat.jpg" fill>
+
+
+
+You can use the [object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) CSS property to change how the image will fill its container. If you style your image with `object-fit: "contain"`, the image will maintain its aspect ratio and be "letterboxed" to fit the element. If you set `object-fit: "cover"`, the element will retain its aspect ratio, fully fill the element, and some content may be "cropped" off.
+
+See visual examples of the above at the [MDN object-fit documentation.](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit)
+
+You can also style your image with the [object-position property](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) to adjust its position within its containing element.
+
+**Important note:** For the "fill" image to render properly, its parent element **must** be styled with `position: "relative"`, `position: "fixed"`, or `position: "absolute"`.
+
+### Adjusting image styling
+
+Depending on the image's styling, adding `width` and `height` attributes may cause the image to render differently. `NgOptimizedImage` warns you if your image styling renders the image at a distorted aspect ratio.
+
+You can typically fix this by adding `height: auto` or `width: auto` to your image styles. For more information, see the [web.dev article on the `` tag](https://web.dev/patterns/web-vitals-patterns/images/img-tag).
+
+If the `height` and `width` attribute on the image are preventing you from sizing the image the way you want with CSS, consider using "fill" mode instead, and styling the image's parent element.
+
+## Performance Features
+
+NgOptimizedImage includes a number of features designed to improve loading performance in your app. These features are described in this section.
+
+### Add resource hints
+
+You can add a [`preconnect` resource hint](https://web.dev/preconnect-and-dns-prefetch) for your image origin to ensure that the LCP image loads as quickly as possible. Always put resource hints in the `` of the document.
+
+
+
+<link rel="preconnect" href="https://my.cdn.origin" />
+
+
+
+By default, if you use a loader for a third-party image service, the `NgOptimizedImage` directive will warn during development if it detects that there is no `preconnect` resource hint for the origin that serves the LCP image.
+
+To disable these warnings, inject the `PRECONNECT_CHECK_BLOCKLIST` token:
+
+
+
+providers: [
+ {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'https://your-domain.com'}
+],
+
+
+
+### Request images at the correct size with automatic `srcset`
+
+Defining a [`srcset` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset) ensures that the browser requests an image at the right size for your user's viewport, so it doesn't waste time downloading an image that's too large. `NgOptimizedImage` generates an appropriate `srcset` for the image, based on the presence and value of the [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes) on the image tag.
+
+#### Fixed-size images
+
+If your image should be "fixed" in size (i.e. the same size across devices, except for [pixel density](https://web.dev/codelab-density-descriptors/)), there is no need to set a `sizes` attribute. A `srcset` can be generated automatically from the image's width and height attributes with no further input required.
+
+Example srcset generated: ``
+
+#### Responsive images
+
+If your image should be responsive (i.e. grow and shrink according to viewport size), then you will need to define a [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes) to generate the `srcset`.
+
+If you haven't used `sizes` before, a good place to start is to set it based on viewport width. For example, if your CSS causes the image to fill 100% of viewport width, set `sizes` to `100vw` and the browser will select the image in the `srcset` that is closest to the viewport width (after accounting for pixel density). If your image is only likely to take up half the screen (ex: in a sidebar), set `sizes` to `50vw` to ensure the browser selects a smaller image. And so on.
+
+If you find that the above does not cover your desired image behavior, see the documentation on [advanced sizes values](#advanced-sizes-values).
+
+By default, the responsive breakpoints are:
+
+`[16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840]`
+
+If you would like to customize these breakpoints, you can do so using the `IMAGE_CONFIG` provider:
+
+
+providers: [
+ {
+ provide: IMAGE_CONFIG,
+ useValue: {
+ breakpoints: [16, 48, 96, 128, 384, 640, 750, 828, 1080, 1200, 1920]
+ }
+ },
+],
+
+
+If you would like to manually define a `srcset` attribute, you can provide your own using the `ngSrcset` attribute:
+
+
+
+<img ngSrc="hero.jpg" ngSrcset="100w, 200w, 300w">
+
+
+
+If the `ngSrcset` attribute is present, `NgOptimizedImage` generates and sets the `srcset` based on the sizes included. Do not include image file names in `ngSrcset` - the directive infers this information from `ngSrc`. The directive supports both width descriptors (e.g. `100w`) and density descriptors (e.g. `1x`).
+
+
+
+<img ngSrc="hero.jpg" ngSrcset="100w, 200w, 300w" sizes="50vw">
+
+
+
+### Disabling automatic srcset generation
+
+To disable srcset generation for a single image, you can add the `disableOptimizedSrcset` attribute on the image:
+
+
+
+<img ngSrc="about.jpg" disableOptimizedSrcset>
+
+
+
+### Disabling image lazy loading
+
+By default, `NgOptimizedImage` sets `loading=lazy` for all images that are not marked `priority`. You can disable this behavior for non-priority images by setting the `loading` attribute. This attribute accepts values: `eager`, `auto`, and `lazy`. [See the documentation for the standard image `loading` attribute for details](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/loading#value).
+
+
+
+<img ngSrc="cat.jpg" width="400" height="200" loading="eager">
+
+
+
+### Advanced 'sizes' values
+
+You may want to have images displayed at varying widths on differently-sized screens. A common example of this pattern is a grid- or column-based layout that renders a single column on mobile devices, and two columns on larger devices. You can capture this behavior in the `sizes` attribute, using a "media query" syntax, such as the following:
+
+
+
+<img ngSrc="cat.jpg" width="400" height="200" sizes="(max-width: 768px) 100vw, 50vw">
+
+
+
+The `sizes` attribute in the above example says "I expect this image to be 100 percent of the screen width on devices under 768px wide. Otherwise, I expect it to be 50 percent of the screen width.
+
+For additional information about the `sizes` attribute, see [web.dev](https://web.dev/learn/design/responsive-images/#sizes) or [mdn](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes).
+
+## Configuring an image loader for `NgOptimizedImage`
+
+A "loader" is a function that generates an [image transformation URL](https://web.dev/image-cdns/#how-image-cdns-use-urls-to-indicate-optimization-options) for a given image file. When appropriate, `NgOptimizedImage` sets the size, format, and image quality transformations for an image.
+
+`NgOptimizedImage` provides both a generic loader that applies no transformations, as well as loaders for various third-party image services. It also supports writing your own custom loader.
+
+| Loader type| Behavior |
+|:--- |:--- |
+| Generic loader | The URL returned by the generic loader will always match the value of `src`. In other words, this loader applies no transformations. Sites that use Angular to serve images are the primary intended use case for this loader.|
+| Loaders for third-party image services | The URL returned by the loaders for third-party image services will follow API conventions used by that particular image service. |
+| Custom loaders | A custom loader's behavior is defined by its developer. You should use a custom loader if your image service isn't supported by the loaders that come preconfigured with `NgOptimizedImage`.|
+
+Based on the image services commonly used with Angular applications, `NgOptimizedImage` provides loaders preconfigured to work with the following image services:
+
+| Image Service | Angular API | Documentation |
+|:--- |:--- |:--- |
+| Cloudflare Image Resizing | `provideCloudflareLoader` | [Documentation](https://developers.cloudflare.com/images/image-resizing/) |
+| Cloudinary | `provideCloudinaryLoader` | [Documentation](https://cloudinary.com/documentation/resizing_and_cropping) |
+| ImageKit | `provideImageKitLoader` | [Documentation](https://docs.imagekit.io/) |
+| Imgix | `provideImgixLoader` | [Documentation](https://docs.imgix.com/) |
+
+To use the **generic loader** no additional code changes are necessary. This is the default behavior.
+
+### Built-in Loaders
+
+To use an existing loader for a **third-party image service**, add the provider factory for your chosen service to the `providers` array. In the example below, the Imgix loader is used:
+
+
+providers: [
+ provideImgixLoader('https://my.base.url/'),
+],
+
+
+The base URL for your image assets should be passed to the provider factory as an argument. For most sites, this base URL should match one of the following patterns:
+
+* https://yoursite.yourcdn.com
+* https://subdomain.yoursite.com
+* https://subdomain.yourcdn.com/yoursite
+
+You can learn more about the base URL structure in the docs of a corresponding CDN provider.
+
+### Custom Loaders
+
+To use a **custom loader**, provide your loader function as a value for the `IMAGE_LOADER` DI token. In the example below, the custom loader function returns a URL starting with `https://example.com` that includes `src` and `width` as URL parameters.
+
+
+providers: [
+ {
+ provide: IMAGE_LOADER,
+ useValue: (config: ImageLoaderConfig) => {
+ return `https://example.com/images?src=${config.src}&width=${config.width}`;
+ },
+ },
+],
+
+
+A loader function for the `NgOptimizedImage` directive takes an object with the `ImageLoaderConfig` type (from `@angular/common`) as its argument and returns the absolute URL of the image asset. The `ImageLoaderConfig` object contains the `src` property, and optional `width` and `loaderParams` properties.
+
+Note: even though the `width` property may not always be present, a custom loader must use it to support requesting images at various widths in order for `ngSrcset` to work properly.
+
+### The `loaderParams` Property
+
+There is an additional attribute supported by the `NgOptimizedImage` directive, called `loaderParams`, which is specifically designed to support the use of custom loaders. The `loaderParams` attribute take an object with any properties as a value, and does not do anything on its own. The data in `loaderParams` is added to the `ImageLoaderConfig` object passed to your custom loader, and can be used to control the behavior of the loader.
+
+A common use for `loaderParams` is controlling advanced image CDN features.
+
+### Example custom loader
+
+The following shows an example of a custom loader function. This example function concatenates `src` and `width`, and uses `loaderParams` to control a custom CDN feature for rounded corners:
+
+
+const myCustomLoader = (config: ImageLoaderConfig) => {
+ let url = `https://example.com/images/${config.src}?`;
+ let queryParams = [];
+ if (config.width) {
+ queryParams.push(`w=${config.width}`);
+ }
+ if (config.loaderParams?.roundedCorners) {
+ queryParams.push('mask=corners&corner-radius=5');
+ }
+ return url + queryParams.join('&');
+};
+
+
+Note that in the above example, we've invented the 'roundedCorners' property name to control a feature of our custom loader. We could then use this feature when creating an image, as follows:
+
+
+
+<img ngSrc="profile.jpg" width="300" height="300" [loaderParams]="{roundedCorners: true}">
+
+
+
+
+
+
+
+
+
+@reviewed 2022-11-07
diff --git a/aio/content/navigation.json b/aio/content/navigation.json
index 944b6898910..3e89ec3feff 100644
--- a/aio/content/navigation.json
+++ b/aio/content/navigation.json
@@ -355,6 +355,11 @@
"title": "HTTP Client",
"tooltip": "Use HTTP to talk to a remote server."
},
+ {
+ "url": "guide/image-directive",
+ "title": "Image Directive",
+ "tooltip": "Performant images with the Angular image directive."
+ },
{
"title": "Testing",
"tooltip": "Testing your Angular apps.",
diff --git a/goldens/public-api/common/errors.md b/goldens/public-api/common/errors.md
index b194a0b3ee7..262890732c4 100644
--- a/goldens/public-api/common/errors.md
+++ b/goldens/public-api/common/errors.md
@@ -6,10 +6,38 @@
// @public
export const enum RuntimeErrorCode {
+ // (undocumented)
+ INVALID_INPUT = 2952,
+ // (undocumented)
+ INVALID_LOADER_ARGUMENTS = 2959,
// (undocumented)
INVALID_PIPE_ARGUMENT = 2100,
// (undocumented)
- PARENT_NG_SWITCH_NOT_FOUND = 2000
+ INVALID_PRECONNECT_CHECK_BLOCKLIST = 2957,
+ // (undocumented)
+ LCP_IMG_MISSING_PRIORITY = 2955,
+ // (undocumented)
+ MISSING_BUILTIN_LOADER = 2962,
+ // (undocumented)
+ MISSING_NECESSARY_LOADER = 2963,
+ // (undocumented)
+ OVERSIZED_IMAGE = 2960,
+ // (undocumented)
+ PARENT_NG_SWITCH_NOT_FOUND = 2000,
+ // (undocumented)
+ PRIORITY_IMG_MISSING_PRECONNECT_TAG = 2956,
+ // (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,
+ // (undocumented)
+ UNEXPECTED_SRC_ATTR = 2950,
+ // (undocumented)
+ UNEXPECTED_SRCSET_ATTR = 2951
}
// (No @packageDocumentation comment for this package)
diff --git a/goldens/public-api/common/index.md b/goldens/public-api/common/index.md
index 78a90f2c9db..0d8d3f1a361 100644
--- a/goldens/public-api/common/index.md
+++ b/goldens/public-api/common/index.md
@@ -17,7 +17,9 @@ import { NgModuleFactory } from '@angular/core';
import { Observable } from 'rxjs';
import { OnChanges } from '@angular/core';
import { OnDestroy } from '@angular/core';
+import { OnInit } from '@angular/core';
import { PipeTransform } from '@angular/core';
+import { Provider } from '@angular/core';
import { Renderer2 } from '@angular/core';
import { SimpleChanges } from '@angular/core';
import { Subscribable } from 'rxjs';
@@ -254,6 +256,29 @@ export class I18nSelectPipe implements PipeTransform {
static ɵpipe: i0.ɵɵPipeDeclaration;
}
+// @public
+export const IMAGE_CONFIG: InjectionToken;
+
+// @public
+export const IMAGE_LOADER: InjectionToken;
+
+// @public
+export type ImageConfig = {
+ breakpoints?: number[];
+};
+
+// @public
+export type ImageLoader = (config: ImageLoaderConfig) => string;
+
+// @public
+export interface ImageLoaderConfig {
+ loaderParams?: {
+ [key: string]: any;
+ };
+ src: string;
+ width?: number;
+}
+
// @public
export function isPlatformBrowser(platformId: Object): boolean;
@@ -509,6 +534,53 @@ export abstract class NgLocalization {
static ɵprov: i0.ɵɵInjectableDeclaration;
}
+// @public
+export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
+ constructor(imageLoader: ImageLoader, config: ImageConfig, renderer: Renderer2, elementRef: ElementRef, injector: Injector, platformId: string, preloadLinkChecker: PreloadLinkCreator);
+ set disableOptimizedSrcset(value: string | boolean | undefined);
+ // (undocumented)
+ get disableOptimizedSrcset(): boolean;
+ set fill(value: string | boolean | undefined);
+ // (undocumented)
+ get fill(): boolean;
+ set height(value: string | number | undefined);
+ // (undocumented)
+ get height(): number | undefined;
+ loaderParams?: {
+ [key: string]: any;
+ };
+ loading?: 'lazy' | 'eager' | 'auto';
+ // (undocumented)
+ ngOnChanges(changes: SimpleChanges): void;
+ // (undocumented)
+ ngOnDestroy(): void;
+ // (undocumented)
+ ngOnInit(): void;
+ ngSrc: string;
+ ngSrcset: string;
+ set priority(value: string | boolean | undefined);
+ // (undocumented)
+ get priority(): boolean;
+ sizes?: string;
+ set width(value: string | number | undefined);
+ // (undocumented)
+ get width(): number | undefined;
+ // (undocumented)
+ static ɵdir: i0.ɵɵDirectiveDeclaration;
+ // (undocumented)
+ static ɵfac: i0.ɵɵFactoryDeclaration;
+}
+
+// @public @deprecated
+export class NgOptimizedImageModule {
+ // (undocumented)
+ static ɵfac: i0.ɵɵFactoryDeclaration;
+ // (undocumented)
+ static ɵinj: i0.ɵɵInjectorDeclaration;
+ // (undocumented)
+ static ɵmod: i0.ɵɵNgModuleDeclaration;
+}
+
// @public
export class NgPlural {
constructor(_localization: NgLocalization);
@@ -732,6 +804,21 @@ interface PopStateEvent_2 {
}
export { PopStateEvent_2 as PopStateEvent }
+// @public
+export const PRECONNECT_CHECK_BLOCKLIST: InjectionToken<(string | string[])[]>;
+
+// @public
+export const provideCloudflareLoader: (path: string) => Provider[];
+
+// @public
+export const provideCloudinaryLoader: (path: string) => Provider[];
+
+// @public
+export const provideImageKitLoader: (path: string) => Provider[];
+
+// @public
+export const provideImgixLoader: (path: string) => Provider[];
+
// @public
export function registerLocaleData(data: any, localeId?: string | any, extraData?: any): void;
diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json
index a08b5b3e9f8..15a00a02842 100644
--- a/goldens/size-tracking/integration-payloads.json
+++ b/goldens/size-tracking/integration-payloads.json
@@ -33,7 +33,7 @@
"cli-hello-world-lazy": {
"uncompressed": {
"runtime": 2835,
- "main": 230780,
+ "main": 230267,
"polyfills": 37244,
"src_app_lazy_lazy_module_ts": 795
}
diff --git a/packages/common/src/common.ts b/packages/common/src/common.ts
index c8579fb932a..597f59e68e8 100644
--- a/packages/common/src/common.ts
+++ b/packages/common/src/common.ts
@@ -27,3 +27,4 @@ export {PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID, PLATFORM_SERVER_ID as ɵPL
export {VERSION} from './version';
export {ViewportScroller, NullViewportScroller as ɵNullViewportScroller} from './viewport_scroller';
export {XhrFactory} from './xhr';
+export {IMAGE_CONFIG, ImageConfig, IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgOptimizedImage, NgOptimizedImageModule, PRECONNECT_CHECK_BLOCKLIST, provideCloudflareLoader, provideCloudinaryLoader, provideImageKitLoader, provideImgixLoader} from './directives/ng_optimized_image';
diff --git a/packages/common/src/directives/index.ts b/packages/common/src/directives/index.ts
index d700bd6787b..311e534d5a2 100644
--- a/packages/common/src/directives/index.ts
+++ b/packages/common/src/directives/index.ts
@@ -7,10 +7,12 @@
*/
import {Provider} from '@angular/core';
+
import {NgClass} from './ng_class';
import {NgComponentOutlet} from './ng_component_outlet';
import {NgForOf, NgForOfContext} from './ng_for_of';
import {NgIf, NgIfContext} from './ng_if';
+import {NgOptimizedImage, NgOptimizedImageModule} from './ng_optimized_image/ng_optimized_image';
import {NgPlural, NgPluralCase} from './ng_plural';
import {NgStyle} from './ng_style';
import {NgSwitch, NgSwitchCase, NgSwitchDefault} from './ng_switch';
@@ -23,13 +25,15 @@ export {
NgForOfContext,
NgIf,
NgIfContext,
+ NgOptimizedImage,
+ NgOptimizedImageModule,
NgPlural,
NgPluralCase,
NgStyle,
NgSwitch,
NgSwitchCase,
NgSwitchDefault,
- NgTemplateOutlet,
+ NgTemplateOutlet
};
diff --git a/packages/common/src/directives/ng_optimized_image/asserts.ts b/packages/common/src/directives/ng_optimized_image/asserts.ts
new file mode 100644
index 00000000000..b254e1ecb90
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/asserts.ts
@@ -0,0 +1,25 @@
+/**
+ * @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 {ɵRuntimeError as RuntimeError} from '@angular/core';
+
+import {RuntimeErrorCode} from '../../errors';
+
+/**
+ * Asserts that the application is in development mode. Throws an error if the application is in
+ * production mode. This assert can be used to make sure that there is no dev-mode code invoked in
+ * the prod mode accidentally.
+ */
+export function assertDevMode(checkName: string) {
+ if (!ngDevMode) {
+ throw new RuntimeError(
+ RuntimeErrorCode.UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE,
+ `Unexpected invocation of the ${checkName} in the prod mode. ` +
+ `Please make sure that the prod mode is enabled for production builds.`);
+ }
+}
diff --git a/packages/common/src/directives/ng_optimized_image/error_helper.ts b/packages/common/src/directives/ng_optimized_image/error_helper.ts
new file mode 100644
index 00000000000..faa5601c95d
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/error_helper.ts
@@ -0,0 +1,14 @@
+/**
+ * @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
+ */
+
+// Assembles directive details string, useful for error messages.
+export function imgDirectiveDetails(ngSrc: string, includeNgSrc = true) {
+ const ngSrcInfo =
+ includeNgSrc ? `(activated on an element with the \`ngSrc="${ngSrc}"\`) ` : '';
+ return `The NgOptimizedImage directive ${ngSrcInfo}has detected that`;
+}
diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader.ts
new file mode 100644
index 00000000000..f92d99142c6
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader.ts
@@ -0,0 +1,35 @@
+/**
+ * @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 {createImageLoader, ImageLoaderConfig} from './image_loader';
+
+/**
+ * Function that generates an ImageLoader for [Cloudflare Image
+ * Resizing](https://developers.cloudflare.com/images/image-resizing/) and turns it into an Angular
+ * provider. Note: Cloudflare has multiple image products - this provider is specifically for
+ * Cloudflare Image Resizing; it will not work with Cloudflare Images or Cloudflare Polish.
+ *
+ * @param path Your domain name, e.g. https://mysite.com
+ * @returns Provider that provides an ImageLoader function
+ *
+ * @publicApi
+ */
+export const provideCloudflareLoader = createImageLoader(
+ createCloudflareUrl,
+ ngDevMode ? ['https:///cdn-cgi/image//'] : undefined);
+
+// Exported for testing purposes in backport only. Not to be accessed except in unit tests.
+export function createCloudflareUrl(path: string, config: ImageLoaderConfig) {
+ let params = `format=auto`;
+ if (config.width) {
+ params += `,width=${config.width}`;
+ }
+ // Cloudflare image URLs format:
+ // https://developers.cloudflare.com/images/image-resizing/url-format/
+ return `${path}/cdn-cgi/image/${params}/${config.src}`;
+}
diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts
new file mode 100644
index 00000000000..2d8afe5a200
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts
@@ -0,0 +1,59 @@
+/**
+ * @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 {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';
+
+/**
+ * Name and URL tester for Cloudinary.
+ */
+export const cloudinaryLoaderInfo: ImageLoaderInfo = {
+ name: 'Cloudinary',
+ testUrl: isCloudinaryUrl
+};
+
+const CLOUDINARY_LOADER_REGEX = /https?\:\/\/[^\/]+\.cloudinary\.com\/.+/;
+/**
+ * Tests whether a URL is from Cloudinary CDN.
+ */
+function isCloudinaryUrl(url: string): boolean {
+ return CLOUDINARY_LOADER_REGEX.test(url);
+}
+
+/**
+ * Function that generates an ImageLoader for Cloudinary and turns it into an Angular provider.
+ *
+ * @param path Base URL of your Cloudinary images
+ * This URL should match one of the following formats:
+ * https://res.cloudinary.com/mysite
+ * https://mysite.cloudinary.com
+ * https://subdomain.mysite.com
+ * @returns Set of providers to configure the Cloudinary loader.
+ *
+ * @publicApi
+ */
+export const provideCloudinaryLoader = createImageLoader(
+ createCloudinaryUrl,
+ ngDevMode ?
+ [
+ 'https://res.cloudinary.com/mysite', 'https://mysite.cloudinary.com',
+ 'https://subdomain.mysite.com'
+ ] :
+ undefined);
+
+// Exported for testing purposes in backport only. Not to be accessed except in unit tests.
+export function createCloudinaryUrl(path: string, config: ImageLoaderConfig) {
+ // Cloudinary image URLformat:
+ // https://cloudinary.com/documentation/image_transformations#transformation_url_structure
+ // Example of a Cloudinary image URL:
+ // https://res.cloudinary.com/mysite/image/upload/c_scale,f_auto,q_auto,w_600/marketing/tile-topics-m.png
+ let params = `f_auto,q_auto`; // sets image format and quality to "auto"
+ if (config.width) {
+ params += `,w_${config.width}`;
+ }
+ return `${path}/image/upload/${params}/${config.src}`;
+}
diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts
new file mode 100644
index 00000000000..ca247f27473
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts
@@ -0,0 +1,130 @@
+/**
+ * @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, Provider, ɵRuntimeError as RuntimeError} from '@angular/core';
+
+import {RuntimeErrorCode} from '../../../errors';
+import {isAbsoluteUrl, isValidPath, normalizePath, normalizeSrc} from '../url';
+
+/**
+ * Config options recognized by the image loader function.
+ *
+ * @see `ImageLoader`
+ * @see `NgOptimizedImage`
+ * @publicApi
+ */
+export interface ImageLoaderConfig {
+ /**
+ * Image file name to be added to the image request URL.
+ */
+ src: string;
+ /**
+ * Width of the requested image (to be used when generating srcset).
+ */
+ width?: number;
+ /**
+ * Additional user-provided parameters for use by the ImageLoader.
+ */
+ loaderParams?: {[key: string]: any;};
+}
+
+/**
+ * Represents an image loader function. Image loader functions are used by the
+ * NgOptimizedImage directive to produce full image URL based on the image name and its width.
+ *
+ * @publicApi
+ */
+export type ImageLoader = (config: ImageLoaderConfig) => string;
+
+/**
+ * Noop image loader that does no transformation to the original src and just returns it as is.
+ * This loader is used as a default one if more specific logic is not provided in an app config.
+ *
+ * @see `ImageLoader`
+ * @see `NgOptimizedImage`
+ */
+export const noopImageLoader = (config: ImageLoaderConfig) => config.src;
+
+/**
+ * Metadata about the image loader.
+ */
+export type ImageLoaderInfo = {
+ name: string,
+ testUrl: (url: string) => boolean
+};
+
+/**
+ * Injection token that configures the image loader function.
+ *
+ * @see `ImageLoader`
+ * @see `NgOptimizedImage`
+ * @publicApi
+ */
+export const IMAGE_LOADER = new InjectionToken('ImageLoader', {
+ providedIn: 'root',
+ factory: () => noopImageLoader,
+});
+
+/**
+ * Internal helper function that makes it easier to introduce custom image loaders for the
+ * `NgOptimizedImage` directive. It is enough to specify a URL builder function to obtain full DI
+ * configuration for a given loader: a DI token corresponding to the actual loader function, plus DI
+ * tokens managing preconnect check functionality.
+ * @param buildUrlFn a function returning a full URL based on loader's configuration
+ * @param exampleUrls example of full URLs for a given loader (used in error messages)
+ * @returns a set of DI providers corresponding to the configured image loader
+ */
+export function createImageLoader(
+ buildUrlFn: (path: string, config: ImageLoaderConfig) => string, exampleUrls?: string[]) {
+ return function provideImageLoader(path: string) {
+ if (!isValidPath(path)) {
+ throwInvalidPathError(path, exampleUrls || []);
+ }
+
+ // The trailing / is stripped (if provided) to make URL construction (concatenation) easier in
+ // the individual loader functions.
+ path = normalizePath(path);
+
+ const loaderFn = (config: ImageLoaderConfig) => {
+ if (isAbsoluteUrl(config.src)) {
+ // Image loader functions expect an image file name (e.g. `my-image.png`)
+ // or a relative path + a file name (e.g. `/a/b/c/my-image.png`) as an input,
+ // so the final absolute URL can be constructed.
+ // When an absolute URL is provided instead - the loader can not
+ // build a final URL, thus the error is thrown to indicate that.
+ throwUnexpectedAbsoluteUrlError(path, config.src);
+ }
+
+ return buildUrlFn(path, {...config, src: normalizeSrc(config.src)});
+ };
+
+ const providers: Provider[] = [{provide: IMAGE_LOADER, useValue: loaderFn}];
+ return providers;
+ };
+}
+
+function throwInvalidPathError(path: unknown, exampleUrls: string[]): never {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_LOADER_ARGUMENTS,
+ ngDevMode &&
+ `Image loader has detected an invalid path (\`${path}\`). ` +
+ `To fix this, supply a path using one of the following formats: ${
+ exampleUrls.join(' or ')}`);
+}
+
+function throwUnexpectedAbsoluteUrlError(path: string, url: string): never {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_LOADER_ARGUMENTS,
+ ngDevMode &&
+ `Image loader has detected a \`\` tag with an invalid \`ngSrc\` attribute: ${
+ url}. ` +
+ `This image loader expects \`ngSrc\` to be a relative URL - ` +
+ `however the provided value is an absolute URL. ` +
+ `To fix this, provide \`ngSrc\` as a path relative to the base URL ` +
+ `configured for this loader (\`${path}\`).`);
+}
diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts
new file mode 100644
index 00000000000..a8a2ff39854
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts
@@ -0,0 +1,50 @@
+/**
+ * @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 {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';
+
+/**
+ * Name and URL tester for ImageKit.
+ */
+export const imageKitLoaderInfo: ImageLoaderInfo = {
+ name: 'ImageKit',
+ testUrl: isImageKitUrl
+};
+
+const IMAGE_KIT_LOADER_REGEX = /https?\:\/\/[^\/]+\.imagekit\.io\/.+/;
+/**
+ * Tests whether a URL is from ImageKit CDN.
+ */
+function isImageKitUrl(url: string): boolean {
+ return IMAGE_KIT_LOADER_REGEX.test(url);
+}
+
+/**
+ * Function that generates an ImageLoader for ImageKit and turns it into an Angular provider.
+ *
+ * @param path Base URL of your ImageKit images
+ * This URL should match one of the following formats:
+ * https://ik.imagekit.io/myaccount
+ * https://subdomain.mysite.com
+ * @returns Set of providers to configure the ImageKit loader.
+ *
+ * @publicApi
+ */
+export const provideImageKitLoader = createImageLoader(
+ createImagekitUrl,
+ ngDevMode ? ['https://ik.imagekit.io/mysite', 'https://subdomain.mysite.com'] : undefined);
+
+export function createImagekitUrl(path: string, config: ImageLoaderConfig) {
+ // Example of an ImageKit image URL:
+ // https://ik.imagekit.io/demo/tr:w-300,h-300/medium_cafe_B1iTdD0C.jpg
+ let params = `tr:q-auto`; // applies the "auto quality" transformation
+ if (config.width) {
+ params += `,w-${config.width}`;
+ }
+ return `${path}/${params}/${config.src}`;
+}
diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts
new file mode 100644
index 00000000000..b9ba3e34d5e
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts
@@ -0,0 +1,48 @@
+/**
+ * @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 {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';
+
+/**
+ * Name and URL tester for Imgix.
+ */
+export const imgixLoaderInfo: ImageLoaderInfo = {
+ name: 'Imgix',
+ testUrl: isImgixUrl
+};
+
+const IMGIX_LOADER_REGEX = /https?\:\/\/[^\/]+\.imgix\.net\/.+/;
+/**
+ * Tests whether a URL is from Imgix CDN.
+ */
+function isImgixUrl(url: string): boolean {
+ return IMGIX_LOADER_REGEX.test(url);
+}
+
+/**
+ * Function that generates an ImageLoader for Imgix and turns it into an Angular provider.
+ *
+ * @param path path to the desired Imgix origin,
+ * e.g. https://somepath.imgix.net or https://images.mysite.com
+ * @returns Set of providers to configure the Imgix loader.
+ *
+ * @publicApi
+ */
+export const provideImgixLoader =
+ createImageLoader(createImgixUrl, ngDevMode ? ['https://somepath.imgix.net/'] : undefined);
+
+// Exported for testing purposes in backport only. Not to be accessed except in unit tests.
+export function createImgixUrl(path: string, config: ImageLoaderConfig) {
+ const url = new URL(`${path}/${config.src}`);
+ // This setting ensures the smallest allowable format is set.
+ url.searchParams.set('auto', 'format');
+ if (config.width) {
+ url.searchParams.set('w', config.width.toString());
+ }
+ return url.href;
+}
diff --git a/packages/common/src/directives/ng_optimized_image/index.ts b/packages/common/src/directives/ng_optimized_image/index.ts
new file mode 100644
index 00000000000..256deee9812
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/index.ts
@@ -0,0 +1,16 @@
+/**
+ * @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
+ */
+
+// These exports represent the set of symbols exposed as a public API.
+export {provideCloudflareLoader} from './image_loaders/cloudflare_loader';
+export {provideCloudinaryLoader} from './image_loaders/cloudinary_loader';
+export {IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from './image_loaders/image_loader';
+export {provideImageKitLoader} from './image_loaders/imagekit_loader';
+export {provideImgixLoader} from './image_loaders/imgix_loader';
+export {IMAGE_CONFIG, ImageConfig, NgOptimizedImage, NgOptimizedImageModule} from './ng_optimized_image';
+export {PRECONNECT_CHECK_BLOCKLIST} from './preconnect_link_checker';
diff --git a/packages/common/src/directives/ng_optimized_image/lcp_image_observer.ts b/packages/common/src/directives/ng_optimized_image/lcp_image_observer.ts
new file mode 100644
index 00000000000..6e620fc552b
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/lcp_image_observer.ts
@@ -0,0 +1,104 @@
+/**
+ * @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, OnDestroy, ɵformatRuntimeError as formatRuntimeError} from '@angular/core';
+
+import {DOCUMENT} from '../../dom_tokens';
+import {RuntimeErrorCode} from '../../errors';
+
+import {assertDevMode} from './asserts';
+import {imgDirectiveDetails} from './error_helper';
+import {getUrl} from './url';
+
+/**
+ * Observer that detects whether an image with `NgOptimizedImage`
+ * is treated as a Largest Contentful Paint (LCP) element. If so,
+ * asserts that the image has the `priority` attribute.
+ *
+ * Note: this is a dev-mode only class and it does not appear in prod bundles,
+ * thus there is no `ngDevMode` use in the code.
+ *
+ * Based on https://web.dev/lcp/#measure-lcp-in-javascript.
+ */
+@Injectable({providedIn: 'root'})
+export class LCPImageObserver implements OnDestroy {
+ // Map of full image URLs -> original `ngSrc` values.
+ private images = new Map();
+ // Keep track of images for which `console.warn` was produced.
+ private alreadyWarned = new Set();
+
+ private window: Window|null = null;
+ private observer: PerformanceObserver|null = null;
+
+ constructor() {
+ assertDevMode('LCP checker');
+ const win = inject(DOCUMENT).defaultView;
+ if (typeof win !== 'undefined' && typeof PerformanceObserver !== 'undefined') {
+ this.window = win;
+ this.observer = this.initPerformanceObserver();
+ }
+ }
+
+ /**
+ * Inits PerformanceObserver and subscribes to LCP events.
+ * Based on https://web.dev/lcp/#measure-lcp-in-javascript
+ */
+ private initPerformanceObserver(): PerformanceObserver {
+ 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 not supported by the directive.
+ if (imgSrc.startsWith('data:') || imgSrc.startsWith('blob:')) return;
+
+ const imgNgSrc = this.images.get(imgSrc);
+ if (imgNgSrc && !this.alreadyWarned.has(imgSrc)) {
+ this.alreadyWarned.add(imgSrc);
+ logMissingPriorityWarning(imgSrc);
+ }
+ });
+ observer.observe({type: 'largest-contentful-paint', buffered: true});
+ return observer;
+ }
+
+ registerImage(rewrittenSrc: string, originalNgSrc: string) {
+ if (!this.observer) return;
+ this.images.set(getUrl(rewrittenSrc, this.window!).href, originalNgSrc);
+ }
+
+ unregisterImage(rewrittenSrc: string) {
+ if (!this.observer) return;
+ this.images.delete(getUrl(rewrittenSrc, this.window!).href);
+ }
+
+ ngOnDestroy() {
+ if (!this.observer) return;
+ this.observer.disconnect();
+ this.images.clear();
+ this.alreadyWarned.clear();
+ }
+}
+
+function logMissingPriorityWarning(ngSrc: string) {
+ const directiveDetails = imgDirectiveDetails(ngSrc);
+ console.warn(formatRuntimeError(
+ RuntimeErrorCode.LCP_IMG_MISSING_PRIORITY,
+ `${directiveDetails} this image is the Largest Contentful Paint (LCP) ` +
+ `element but was not marked "priority". This image should be marked ` +
+ `"priority" in order to prioritize its loading. ` +
+ `To fix this, add the "priority" attribute.`));
+}
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
new file mode 100644
index 00000000000..248b71e73a2
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts
@@ -0,0 +1,1010 @@
+/**
+ * @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 {Directive, ElementRef, Inject, InjectionToken, Injector, Input, NgModule, 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 {cloudinaryLoaderInfo} from './image_loaders/cloudinary_loader';
+import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, noopImageLoader} from './image_loaders/image_loader';
+import {imageKitLoaderInfo} from './image_loaders/imagekit_loader';
+import {imgixLoaderInfo} from './image_loaders/imgix_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,
+ * an error is thrown. The image content (as a string) might be very long, thus making
+ * it hard to read an error message if the entire string is included. This const defines
+ * the number of characters that should be included into the error message. The rest
+ * of the content is truncated.
+ */
+const BASE64_IMG_MAX_LENGTH_IN_ERROR = 50;
+
+/**
+ * RegExpr to determine whether a src in a srcset is using width descriptors.
+ * Should match something like: "100w, 200w".
+ */
+const VALID_WIDTH_DESCRIPTOR_SRCSET = /^((\s*\d+w\s*(,|$)){1,})$/;
+
+/**
+ * RegExpr to determine whether a src in a srcset is using density descriptors.
+ * Should match something like: "1x, 2x, 50x". Also supports decimals like "1.5x, 1.50x".
+ */
+const VALID_DENSITY_DESCRIPTOR_SRCSET = /^((\s*\d+(\.\d+)?x\s*(,|$)){1,})$/;
+
+/**
+ * Srcset values with a density descriptor higher than this value will actively
+ * throw an error. Such densities are not permitted as they cause image sizes
+ * to be unreasonably large and slow down LCP.
+ */
+export const ABSOLUTE_SRCSET_DENSITY_CAP = 3;
+
+/**
+ * Used only in error message text to communicate best practices, as we will
+ * only throw based on the slightly more conservative ABSOLUTE_SRCSET_DENSITY_CAP.
+ */
+export const RECOMMENDED_SRCSET_DENSITY_CAP = 2;
+
+/**
+ * Used in generating automatic density-based srcsets
+ */
+const DENSITY_SRCSET_MULTIPLIERS = [1, 2];
+
+/**
+ * Used to determine which breakpoints to use on full-width images
+ */
+const VIEWPORT_BREAKPOINT_CUTOFF = 640;
+/**
+ * Used to determine whether two aspect ratios are similar in value.
+ */
+const ASPECT_RATIO_TOLERANCE = .1;
+
+/**
+ * Used to determine whether the image has been requested at an overly
+ * large size compared to the actual rendered image size (after taking
+ * into account a typical device pixel ratio). In pixels.
+ */
+const OVERSIZED_IMAGE_TOLERANCE = 1000;
+
+/**
+ * Used to limit automatic srcset generation of very large sources for
+ * fixed-size images. In pixels.
+ */
+const FIXED_SRCSET_WIDTH_LIMIT = 1920;
+const FIXED_SRCSET_HEIGHT_LIMIT = 1080;
+
+
+/** Info about built-in loaders we can test for. */
+export const BUILT_IN_LOADERS = [imgixLoaderInfo, imageKitLoaderInfo, cloudinaryLoaderInfo];
+
+/**
+ * A configuration object for the NgOptimizedImage directive. Contains:
+ * - breakpoints: An array of integer breakpoints used to generate
+ * srcsets for responsive images.
+ *
+ * Learn more about the responsive image configuration in [the NgOptimizedImage
+ * guide](guide/image-directive).
+ * @publicApi
+ */
+export type ImageConfig = {
+ breakpoints?: number[]
+};
+
+const defaultConfig: ImageConfig = {
+ breakpoints: [16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840],
+};
+
+/**
+ * Injection token that configures the image optimized image functionality.
+ *
+ * @see `NgOptimizedImage`
+ * @publicApi
+ */
+export const IMAGE_CONFIG = new InjectionToken(
+ 'ImageConfig', {providedIn: 'root', factory: () => defaultConfig});
+
+/**
+ * @ngModule NgOptimizedImageModule
+ *
+ * @description
+ *
+ * Directive that improves image loading performance by enforcing best practices.
+ *
+ * `NgOptimizedImage` ensures that the loading of the Largest Contentful Paint (LCP) image is
+ * prioritized by:
+ * - Automatically setting the `fetchpriority` attribute on the `` tag
+ * - Lazy loading non-priority images by default
+ * - Asserting that there is a corresponding preconnect link tag in the document head
+ *
+ * In addition, the directive:
+ * - Generates appropriate asset URLs if a corresponding `ImageLoader` function is provided
+ * - Automatically generates a srcset
+ * - Requires that `width` and `height` are set
+ * - Warns if `width` or `height` have been set incorrectly
+ * - Warns if the image will be visually distorted when rendered
+ *
+ * @usageNotes
+ *
+ * Follow the steps below to enable and use the directive:
+ * 1. Import it into the necessary NgModule Component.
+ * 2. Optionally provide an `ImageLoader` if you use an image hosting service.
+ * 3. Update the necessary `` tags in templates and replace `src` attributes with `ngSrc`.
+ * Using a `ngSrc` allows the directive to control when the `src` gets set, which triggers an image
+ * download.
+ *
+ * Step 1: import the `NgOptimizedImage` directive.
+ *
+ * ```typescript
+ * import { NgOptimizedImageModule } from '@angular/common';
+ *
+ * // Include it into the necessary NgModule
+ * @NgModule({
+ * imports: [NgOptimizedImageModule],
+ * })
+ * class AppModule {}
+ *
+ *
+ * Step 2: configure a loader.
+ *
+ * To use the **default loader**: no additional code changes are necessary. The URL returned by the
+ * generic loader will always match the value of "src". In other words, this loader applies no
+ * transformations to the resource URL and the value of the `ngSrc` attribute will be used as is.
+ *
+ * To use an existing loader for a **third-party image service**: add the provider factory for your
+ * chosen service to the `providers` array. In the example below, the Imgix loader is used:
+ *
+ * ```typescript
+ * import {provideImgixLoader} from '@angular/common';
+ *
+ * // Call the function and add the result to the `providers` array:
+ * providers: [
+ * provideImgixLoader("https://my.base.url/"),
+ * ],
+ * ```
+ *
+ * The `NgOptimizedImage` directive provides the following functions:
+ * - `provideCloudflareLoader`
+ * - `provideCloudinaryLoader`
+ * - `provideImageKitLoader`
+ * - `provideImgixLoader`
+ *
+ * If you use a different image provider, you can create a custom loader function as described
+ * below.
+ *
+ * To use a **custom loader**: provide your loader function as a value for the `IMAGE_LOADER` DI
+ * token.
+ *
+ * ```typescript
+ * import {IMAGE_LOADER, ImageLoaderConfig} from '@angular/common';
+ *
+ * // Configure the loader using the `IMAGE_LOADER` token.
+ * providers: [
+ * {
+ * provide: IMAGE_LOADER,
+ * useValue: (config: ImageLoaderConfig) => {
+ * return `https://example.com/${config.src}-${config.width}.jpg}`;
+ * }
+ * },
+ * ],
+ * ```
+ *
+ * Step 3: update `` tags in templates to use `ngSrc` instead of `src`.
+ *
+ * ```
+ *
+ * ```
+ *
+ * @publicApi
+ */
+@Directive({selector: 'img[ngSrc]'})
+export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
+ /**
+ * Calculate the rewritten `src` once and store it.
+ * This is needed to avoid repetitive calculations and make sure the directive cleanup in the
+ * `ngOnDestroy` does not rely on the `IMAGE_LOADER` logic (which in turn can rely on some other
+ * instance that might be already destroyed).
+ */
+ private _renderedSrc: string|null = null;
+
+ /**
+ * Name of the source image.
+ * Image name will be processed by the image loader and the final URL will be applied as the `src`
+ * property of the image.
+ */
+ @Input() ngSrc!: string;
+
+ /**
+ * A comma separated list of width or density descriptors.
+ * The image name will be taken from `ngSrc` and combined with the list of width or density
+ * descriptors to generate the final `srcset` property of the image.
+ *
+ * Example:
+ * ```
+ * =>
+ *
+ * ```
+ */
+ @Input() ngSrcset!: string;
+
+ /**
+ * The base `sizes` attribute passed through to the `` element.
+ * Providing sizes causes the image to create an automatic responsive srcset.
+ */
+ @Input() sizes?: string;
+
+ /**
+ * For responsive images: the intrinsic width of the image in pixels.
+ * For fixed size images: the desired rendered width of the image in pixels.
+ */
+ @Input()
+ set width(value: string|number|undefined) {
+ ngDevMode && assertGreaterThanZero(this, value, 'width');
+ this._width = inputToInteger(value);
+ }
+ get width(): number|undefined {
+ return this._width;
+ }
+ private _width?: number;
+
+ /**
+ * For responsive images: the intrinsic height of the image in pixels.
+ * For fixed size images: the desired rendered height of the image in pixels.* The intrinsic
+ * height of the image in pixels.
+ */
+ @Input()
+ set height(value: string|number|undefined) {
+ ngDevMode && assertGreaterThanZero(this, value, 'height');
+ this._height = inputToInteger(value);
+ }
+ get height(): number|undefined {
+ return this._height;
+ }
+ private _height?: number;
+
+ /**
+ * The desired loading behavior (lazy, eager, or auto).
+ *
+ * Setting images as loading='eager' or loading='auto' marks them
+ * as non-priority images. Avoid changing this input for priority images.
+ */
+ @Input() loading?: 'lazy'|'eager'|'auto';
+
+ /**
+ * Indicates whether this image should have a high priority.
+ */
+ @Input()
+ set priority(value: string|boolean|undefined) {
+ this._priority = inputToBoolean(value);
+ }
+ get priority(): boolean {
+ return this._priority;
+ }
+ private _priority = false;
+
+ /**
+ * Data to pass through to custom loaders.
+ */
+ @Input() loaderParams?: {[key: string]: any};
+
+ /**
+ * Disables automatic srcset generation for this image.
+ */
+ @Input()
+ set disableOptimizedSrcset(value: string|boolean|undefined) {
+ this._disableOptimizedSrcset = inputToBoolean(value);
+ }
+ get disableOptimizedSrcset(): boolean {
+ return this._disableOptimizedSrcset;
+ }
+ private _disableOptimizedSrcset = false;
+
+ /**
+ * Sets the image to "fill mode", which eliminates the height/width requirement and adds
+ * styles such that the image fills its containing element.
+ */
+ @Input()
+ set fill(value: string|boolean|undefined) {
+ this._fill = inputToBoolean(value);
+ }
+ get fill(): boolean {
+ return this._fill;
+ }
+ private _fill = false;
+
+ /**
+ * Value of the `src` attribute if set on the host `` element.
+ * This input is exclusively read to assert that `src` is not set in conflict
+ * with `ngSrc` and that images don't start to load until a lazy loading strategy is set.
+ * @internal
+ */
+ @Input() src?: string;
+
+ /**
+ * Value of the `srcset` attribute if set on the host `` element.
+ * This input is exclusively read to assert that `srcset` is not set in conflict
+ * with `ngSrcset` and that images don't start to load until a lazy loading strategy is set.
+ * @internal
+ */
+ @Input() srcset?: string;
+
+ // a LCP image observer - should be injected only in the dev mode
+ private lcpObserver;
+ private imgElement: HTMLImageElement;
+
+ constructor(
+ @Inject(IMAGE_LOADER) private imageLoader: ImageLoader,
+ @Inject(IMAGE_CONFIG) private config: ImageConfig,
+ @Inject(Renderer2) private renderer: Renderer2,
+ @Inject(ElementRef) private elementRef: ElementRef,
+ @Inject(Injector) private injector: Injector,
+ @Inject(PLATFORM_ID) private platformId: string,
+ @Inject(PreloadLinkCreator) private preloadLinkChecker: PreloadLinkCreator,
+ ) {
+ this.config = processConfig(this.config);
+ this.imgElement = this.elementRef.nativeElement;
+ this.lcpObserver = ngDevMode ? this.injector.get(LCPImageObserver) : null;
+ }
+
+ /** @nodoc */
+ ngOnInit() {
+ if (ngDevMode) {
+ assertNonEmptyInput(this, 'ngSrc', this.ngSrc);
+ assertValidNgSrcset(this, this.ngSrcset);
+ assertNoConflictingSrc(this);
+ if (this.ngSrcset) {
+ assertNoConflictingSrcset(this);
+ }
+ assertNotBase64Image(this);
+ assertNotBlobUrl(this);
+ if (this.fill) {
+ assertEmptyWidthAndHeight(this);
+ assertNonZeroRenderedHeight(this, this.imgElement, this.renderer);
+ } else {
+ assertNonEmptyWidthAndHeight(this);
+ // Only check for distorted images when not in fill mode, where
+ // images may be intentionally stretched, cropped or letterboxed.
+ assertNoImageDistortion(this, this.imgElement, this.renderer);
+ }
+ assertValidLoadingInput(this);
+ if (!this.ngSrcset) {
+ assertNoComplexSizes(this);
+ }
+ assertNotMissingBuiltInLoader(this.ngSrc, this.imageLoader);
+ assertNoNgSrcsetWithoutLoader(this, this.imageLoader);
+ assertNoLoaderParamsWithoutLoader(this, this.imageLoader);
+ if (this.priority) {
+ const checker = this.injector.get(PreconnectLinkChecker);
+ checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc);
+ } else {
+ // Monitor whether an image is an LCP element only in case
+ // the `priority` attribute is missing. Otherwise, an image
+ // has the necessary settings and no extra checks are required.
+ if (this.lcpObserver !== null) {
+ const ngZone = this.injector.get(NgZone);
+ ngZone.runOutsideAngular(() => {
+ this.lcpObserver!.registerImage(this.getRewrittenSrc(), this.ngSrc);
+ });
+ }
+ }
+ }
+ this.setHostAttributes();
+ }
+
+ private setHostAttributes() {
+ // Must set width/height explicitly in case they are bound (in which case they will
+ // only be reflected and not found by the browser)
+ if (this.fill) {
+ if (!this.sizes) {
+ this.sizes = '100vw';
+ }
+ this.renderer.setStyle(this.imgElement, 'position', 'absolute');
+ this.renderer.setStyle(this.imgElement, 'width', '100%');
+ this.renderer.setStyle(this.imgElement, 'height', '100%');
+ this.renderer.setStyle(this.imgElement, 'inset', '0px');
+ } else {
+ this.setHostAttribute('width', this.width!.toString());
+ this.setHostAttribute('height', this.height!.toString());
+ }
+
+ this.setHostAttribute('loading', this.getLoadingBehavior());
+ this.setHostAttribute('fetchpriority', this.getFetchPriority());
+
+ // The `data-ng-img` attribute flags an image as using the directive, to allow
+ // for analysis of the directive's performance.
+ this.setHostAttribute('ng-img', 'true');
+
+ // The `src` and `srcset` attributes should be set last since other attributes
+ // could affect the image's loading behavior.
+ const rewrittenSrc = this.getRewrittenSrc();
+ this.setHostAttribute('src', rewrittenSrc);
+
+ let rewrittenSrcset: string|undefined = undefined;
+
+ if (this.sizes) {
+ this.setHostAttribute('sizes', this.sizes);
+ }
+
+ if (this.ngSrcset) {
+ rewrittenSrcset = this.getRewrittenSrcset();
+ } else if (this.shouldGenerateAutomaticSrcset()) {
+ rewrittenSrcset = this.getAutomaticSrcset();
+ }
+
+ if (rewrittenSrcset) {
+ this.setHostAttribute('srcset', rewrittenSrcset);
+ }
+
+ if (isPlatformServer(this.platformId) && this.priority) {
+ this.preloadLinkChecker.createPreloadLinkTag(
+ this.renderer, rewrittenSrc, rewrittenSrcset, this.sizes);
+ }
+ }
+
+ /** @nodoc */
+ ngOnChanges(changes: SimpleChanges) {
+ if (ngDevMode) {
+ assertNoPostInitInputChange(this, changes, [
+ 'ngSrc',
+ 'ngSrcset',
+ 'width',
+ 'height',
+ 'priority',
+ 'fill',
+ 'loading',
+ 'sizes',
+ 'loaderParams',
+ 'disableOptimizedSrcset',
+ ]);
+ }
+ }
+
+ private callImageLoader(configWithoutCustomParams: Omit):
+ string {
+ let augmentedConfig: ImageLoaderConfig = configWithoutCustomParams;
+ if (this.loaderParams) {
+ augmentedConfig.loaderParams = this.loaderParams;
+ }
+ return this.imageLoader(augmentedConfig);
+ }
+
+ private getLoadingBehavior(): string {
+ if (!this.priority && this.loading !== undefined) {
+ return this.loading;
+ }
+ return this.priority ? 'eager' : 'lazy';
+ }
+
+ private getFetchPriority(): string {
+ return this.priority ? 'high' : 'auto';
+ }
+
+ private getRewrittenSrc(): string {
+ // ImageLoaderConfig supports setting a width property. However, we're not setting width here
+ // because if the developer uses rendered width instead of intrinsic width in the HTML width
+ // attribute, the image requested may be too small for 2x+ screens.
+ if (!this._renderedSrc) {
+ const imgConfig = {src: this.ngSrc};
+ // Cache calculated image src to reuse it later in the code.
+ this._renderedSrc = this.callImageLoader(imgConfig);
+ }
+ return this._renderedSrc;
+ }
+
+ private getRewrittenSrcset(): string {
+ const widthSrcSet = VALID_WIDTH_DESCRIPTOR_SRCSET.test(this.ngSrcset);
+ const finalSrcs = this.ngSrcset.split(',').filter(src => src !== '').map(srcStr => {
+ srcStr = srcStr.trim();
+ const width = widthSrcSet ? parseFloat(srcStr) : parseFloat(srcStr) * this.width!;
+ return `${this.callImageLoader({src: this.ngSrc, width})} ${srcStr}`;
+ });
+ return finalSrcs.join(', ');
+ }
+
+ private getAutomaticSrcset(): string {
+ if (this.sizes) {
+ return this.getResponsiveSrcset();
+ } else {
+ return this.getFixedSrcset();
+ }
+ }
+
+ private getResponsiveSrcset(): string {
+ const {breakpoints} = this.config;
+
+ let filteredBreakpoints = breakpoints!;
+ if (this.sizes?.trim() === '100vw') {
+ // Since this is a full-screen-width image, our srcset only needs to include
+ // breakpoints with full viewport widths.
+ filteredBreakpoints = breakpoints!.filter(bp => bp >= VIEWPORT_BREAKPOINT_CUTOFF);
+ }
+
+ const finalSrcs = filteredBreakpoints.map(
+ bp => `${this.callImageLoader({src: this.ngSrc, width: bp})} ${bp}w`);
+ return finalSrcs.join(', ');
+ }
+
+ private getFixedSrcset(): string {
+ const finalSrcs = DENSITY_SRCSET_MULTIPLIERS.map(multiplier => {
+ const imgUrl = this.callImageLoader({src: this.ngSrc, width: this.width! * multiplier});
+ return `${imgUrl} ${multiplier}x`;
+ });
+ return finalSrcs.join(', ');
+ }
+
+ private shouldGenerateAutomaticSrcset(): boolean {
+ return !this._disableOptimizedSrcset && !this.srcset && this.imageLoader !== noopImageLoader &&
+ !(this.width! > FIXED_SRCSET_WIDTH_LIMIT || this.height! > FIXED_SRCSET_HEIGHT_LIMIT);
+ }
+
+ /** @nodoc */
+ ngOnDestroy() {
+ if (ngDevMode) {
+ if (!this.priority && this._renderedSrc !== null && this.lcpObserver !== null) {
+ this.lcpObserver.unregisterImage(this._renderedSrc);
+ }
+ }
+ }
+
+ private setHostAttribute(name: string, value: string): void {
+ this.renderer.setAttribute(this.imgElement, name, value);
+ }
+}
+
+/***** Helpers *****/
+
+/**
+ * Convert input value to integer.
+ */
+function inputToInteger(value: string|number|undefined): number|undefined {
+ return typeof value === 'string' ? parseInt(value, 10) : value;
+}
+
+/**
+ * Convert input value to boolean.
+ */
+function inputToBoolean(value: unknown): boolean {
+ return value != null && `${value}` !== 'false';
+}
+
+/**
+ * Sorts provided config breakpoints and uses defaults.
+ */
+function processConfig(config: ImageConfig): ImageConfig {
+ let sortedBreakpoints: {breakpoints?: number[]} = {};
+ if (config.breakpoints) {
+ sortedBreakpoints.breakpoints = config.breakpoints.sort((a, b) => a - b);
+ }
+ return Object.assign({}, defaultConfig, config, sortedBreakpoints);
+}
+
+/***** Assert functions *****/
+
+/**
+ * Verifies that there is no `src` set on a host element.
+ */
+function assertNoConflictingSrc(dir: NgOptimizedImage) {
+ if (dir.src) {
+ throw new RuntimeError(
+ RuntimeErrorCode.UNEXPECTED_SRC_ATTR,
+ `${imgDirectiveDetails(dir.ngSrc)} both \`src\` and \`ngSrc\` have been set. ` +
+ `Supplying both of these attributes breaks lazy loading. ` +
+ `The NgOptimizedImage directive sets \`src\` itself based on the value of \`ngSrc\`. ` +
+ `To fix this, please remove the \`src\` attribute.`);
+ }
+}
+
+/**
+ * Verifies that there is no `srcset` set on a host element.
+ */
+function assertNoConflictingSrcset(dir: NgOptimizedImage) {
+ if (dir.srcset) {
+ throw new RuntimeError(
+ RuntimeErrorCode.UNEXPECTED_SRCSET_ATTR,
+ `${imgDirectiveDetails(dir.ngSrc)} both \`srcset\` and \`ngSrcset\` have been set. ` +
+ `Supplying both of these attributes breaks lazy loading. ` +
+ `The NgOptimizedImage directive sets \`srcset\` itself based on the value of ` +
+ `\`ngSrcset\`. To fix this, please remove the \`srcset\` attribute.`);
+ }
+}
+
+/**
+ * Verifies that the `ngSrc` is not a Base64-encoded image.
+ */
+function assertNotBase64Image(dir: NgOptimizedImage) {
+ let ngSrc = dir.ngSrc.trim();
+ if (ngSrc.startsWith('data:')) {
+ if (ngSrc.length > BASE64_IMG_MAX_LENGTH_IN_ERROR) {
+ ngSrc = ngSrc.substring(0, BASE64_IMG_MAX_LENGTH_IN_ERROR) + '...';
+ }
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc, false)} \`ngSrc\` is a Base64-encoded string ` +
+ `(${ngSrc}). NgOptimizedImage does not support Base64-encoded strings. ` +
+ `To fix this, disable the NgOptimizedImage directive for this element ` +
+ `by removing \`ngSrc\` and using a standard \`src\` attribute instead.`);
+ }
+}
+
+/**
+ * Verifies that the 'sizes' only includes responsive values.
+ */
+function assertNoComplexSizes(dir: NgOptimizedImage) {
+ let sizes = dir.sizes;
+ if (sizes?.match(/((\)|,)\s|^)\d+px/)) {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc, false)} \`sizes\` was set to a string including ` +
+ `pixel values. For automatic \`srcset\` generation, \`sizes\` must only include responsive ` +
+ `values, such as \`sizes="50vw"\` or \`sizes="(min-width: 768px) 50vw, 100vw"\`. ` +
+ `To fix this, modify the \`sizes\` attribute, or provide your own \`ngSrcset\` value directly.`);
+ }
+}
+
+/**
+ * Verifies that the `ngSrc` is not a Blob URL.
+ */
+function assertNotBlobUrl(dir: NgOptimizedImage) {
+ const ngSrc = dir.ngSrc.trim();
+ if (ngSrc.startsWith('blob:')) {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc)} \`ngSrc\` was set to a blob URL (${ngSrc}). ` +
+ `Blob URLs are not supported by the NgOptimizedImage directive. ` +
+ `To fix this, disable the NgOptimizedImage directive for this element ` +
+ `by removing \`ngSrc\` and using a regular \`src\` attribute instead.`);
+ }
+}
+
+/**
+ * Verifies that the input is set to a non-empty string.
+ */
+function assertNonEmptyInput(dir: NgOptimizedImage, name: string, value: unknown) {
+ const isString = typeof value === 'string';
+ const isEmptyString = isString && value.trim() === '';
+ if (!isString || isEmptyString) {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc)} \`${name}\` has an invalid value ` +
+ `(\`${value}\`). To fix this, change the value to a non-empty string.`);
+ }
+}
+
+/**
+ * Verifies that the `ngSrcset` is in a valid format, e.g. "100w, 200w" or "1x, 2x".
+ */
+export function assertValidNgSrcset(dir: NgOptimizedImage, value: unknown) {
+ if (value == null) return;
+ assertNonEmptyInput(dir, 'ngSrcset', value);
+ const stringVal = value as string;
+ const isValidWidthDescriptor = VALID_WIDTH_DESCRIPTOR_SRCSET.test(stringVal);
+ const isValidDensityDescriptor = VALID_DENSITY_DESCRIPTOR_SRCSET.test(stringVal);
+
+ if (isValidDensityDescriptor) {
+ assertUnderDensityCap(dir, stringVal);
+ }
+
+ const isValidSrcset = isValidWidthDescriptor || isValidDensityDescriptor;
+ if (!isValidSrcset) {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc)} \`ngSrcset\` has an invalid value (\`${value}\`). ` +
+ `To fix this, supply \`ngSrcset\` using a comma-separated list of one or more width ` +
+ `descriptors (e.g. "100w, 200w") or density descriptors (e.g. "1x, 2x").`);
+ }
+}
+
+function assertUnderDensityCap(dir: NgOptimizedImage, value: string) {
+ const underDensityCap =
+ value.split(',').every(num => num === '' || parseFloat(num) <= ABSOLUTE_SRCSET_DENSITY_CAP);
+ if (!underDensityCap) {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${
+ imgDirectiveDetails(
+ dir.ngSrc)} the \`ngSrcset\` contains an unsupported image density:` +
+ `\`${value}\`. NgOptimizedImage generally recommends a max image density of ` +
+ `${RECOMMENDED_SRCSET_DENSITY_CAP}x but supports image densities up to ` +
+ `${ABSOLUTE_SRCSET_DENSITY_CAP}x. The human eye cannot distinguish between image densities ` +
+ `greater than ${RECOMMENDED_SRCSET_DENSITY_CAP}x - which makes them unnecessary for ` +
+ `most use cases. Images that will be pinch-zoomed are typically the primary use case for ` +
+ `${ABSOLUTE_SRCSET_DENSITY_CAP}x images. Please remove the high density descriptor and try again.`);
+ }
+}
+
+/**
+ * Creates a `RuntimeError` instance to represent a situation when an input is set after
+ * the directive has initialized.
+ */
+function postInitInputChangeError(dir: NgOptimizedImage, inputName: string): {} {
+ let reason!: string;
+ if (inputName === 'width' || inputName === 'height') {
+ reason = `Changing \`${inputName}\` may result in different attribute value ` +
+ `applied to the underlying image element and cause layout shifts on a page.`;
+ } else {
+ reason = `Changing the \`${inputName}\` would have no effect on the underlying ` +
+ `image element, because the resource loading has already occurred.`;
+ }
+ return new RuntimeError(
+ RuntimeErrorCode.UNEXPECTED_INPUT_CHANGE,
+ `${imgDirectiveDetails(dir.ngSrc)} \`${inputName}\` was updated after initialization. ` +
+ `The NgOptimizedImage directive will not react to this input change. ${reason} ` +
+ `To fix this, either switch \`${inputName}\` to a static value ` +
+ `or wrap the image element in an *ngIf that is gated on the necessary value.`);
+}
+
+/**
+ * Verify that none of the listed inputs has changed.
+ */
+function assertNoPostInitInputChange(
+ dir: NgOptimizedImage, changes: SimpleChanges, inputs: string[]) {
+ inputs.forEach(input => {
+ const isUpdated = changes.hasOwnProperty(input);
+ if (isUpdated && !changes[input].isFirstChange()) {
+ if (input === 'ngSrc') {
+ // When the `ngSrc` input changes, we detect that only in the
+ // `ngOnChanges` hook, thus the `ngSrc` is already set. We use
+ // `ngSrc` in the error message, so we use a previous value, but
+ // not the updated one in it.
+ dir = {ngSrc: changes[input].previousValue} as NgOptimizedImage;
+ }
+ throw postInitInputChangeError(dir, input);
+ }
+ });
+}
+
+/**
+ * Verifies that a specified input is a number greater than 0.
+ */
+function assertGreaterThanZero(dir: NgOptimizedImage, inputValue: unknown, inputName: string) {
+ const validNumber = typeof inputValue === 'number' && inputValue > 0;
+ const validString =
+ typeof inputValue === 'string' && /^\d+$/.test(inputValue.trim()) && parseInt(inputValue) > 0;
+ if (!validNumber && !validString) {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc)} \`${inputName}\` has an invalid value ` +
+ `(\`${inputValue}\`). To fix this, provide \`${inputName}\` ` +
+ `as a number greater than 0.`);
+ }
+}
+
+/**
+ * Verifies that the rendered image is not visually distorted. Effectively this is checking:
+ * - Whether the "width" and "height" attributes reflect the actual dimensions of the image.
+ * - Whether image styling is "correct" (see below for a longer explanation).
+ */
+function assertNoImageDistortion(
+ dir: NgOptimizedImage, img: HTMLImageElement, renderer: Renderer2) {
+ const removeListenerFn = renderer.listen(img, 'load', () => {
+ removeListenerFn();
+ const renderedWidth = img.clientWidth;
+ const renderedHeight = img.clientHeight;
+ const renderedAspectRatio = renderedWidth / renderedHeight;
+ const nonZeroRenderedDimensions = renderedWidth !== 0 && renderedHeight !== 0;
+
+ const intrinsicWidth = img.naturalWidth;
+ const intrinsicHeight = img.naturalHeight;
+ const intrinsicAspectRatio = intrinsicWidth / intrinsicHeight;
+
+ const suppliedWidth = dir.width!;
+ const suppliedHeight = dir.height!;
+ const suppliedAspectRatio = suppliedWidth / suppliedHeight;
+
+ // Tolerance is used to account for the impact of subpixel rendering.
+ // Due to subpixel rendering, the rendered, intrinsic, and supplied
+ // aspect ratios of a correctly configured image may not exactly match.
+ // For example, a `width=4030 height=3020` image might have a rendered
+ // size of "1062w, 796.48h". (An aspect ratio of 1.334... vs. 1.333...)
+ const inaccurateDimensions =
+ Math.abs(suppliedAspectRatio - intrinsicAspectRatio) > ASPECT_RATIO_TOLERANCE;
+ const stylingDistortion = nonZeroRenderedDimensions &&
+ Math.abs(intrinsicAspectRatio - renderedAspectRatio) > ASPECT_RATIO_TOLERANCE;
+
+ if (inaccurateDimensions) {
+ console.warn(formatRuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc)} the aspect ratio of the image does not match ` +
+ `the aspect ratio indicated by the width and height attributes. ` +
+ `\nIntrinsic image size: ${intrinsicWidth}w x ${intrinsicHeight}h ` +
+ `(aspect-ratio: ${intrinsicAspectRatio}). \nSupplied width and height attributes: ` +
+ `${suppliedWidth}w x ${suppliedHeight}h (aspect-ratio: ${suppliedAspectRatio}). ` +
+ `\nTo fix this, update the width and height attributes.`));
+ } else if (stylingDistortion) {
+ console.warn(formatRuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc)} the aspect ratio of the rendered image ` +
+ `does not match the image's intrinsic aspect ratio. ` +
+ `\nIntrinsic image size: ${intrinsicWidth}w x ${intrinsicHeight}h ` +
+ `(aspect-ratio: ${intrinsicAspectRatio}). \nRendered image size: ` +
+ `${renderedWidth}w x ${renderedHeight}h (aspect-ratio: ` +
+ `${renderedAspectRatio}). \nThis issue can occur if "width" and "height" ` +
+ `attributes are added to an image without updating the corresponding ` +
+ `image styling. To fix this, adjust image styling. In most cases, ` +
+ `adding "height: auto" or "width: auto" to the image styling will fix ` +
+ `this issue.`));
+ } else if (!dir.ngSrcset && nonZeroRenderedDimensions) {
+ // If `ngSrcset` hasn't been set, sanity check the intrinsic size.
+ const recommendedWidth = RECOMMENDED_SRCSET_DENSITY_CAP * renderedWidth;
+ const recommendedHeight = RECOMMENDED_SRCSET_DENSITY_CAP * renderedHeight;
+ const oversizedWidth = (intrinsicWidth - recommendedWidth) >= OVERSIZED_IMAGE_TOLERANCE;
+ const oversizedHeight = (intrinsicHeight - recommendedHeight) >= OVERSIZED_IMAGE_TOLERANCE;
+ if (oversizedWidth || oversizedHeight) {
+ console.warn(formatRuntimeError(
+ RuntimeErrorCode.OVERSIZED_IMAGE,
+ `${imgDirectiveDetails(dir.ngSrc)} the intrinsic image is significantly ` +
+ `larger than necessary. ` +
+ `\nRendered image size: ${renderedWidth}w x ${renderedHeight}h. ` +
+ `\nIntrinsic image size: ${intrinsicWidth}w x ${intrinsicHeight}h. ` +
+ `\nRecommended intrinsic image size: ${recommendedWidth}w x ${
+ recommendedHeight}h. ` +
+ `\nNote: Recommended intrinsic image size is calculated assuming a maximum DPR of ` +
+ `${RECOMMENDED_SRCSET_DENSITY_CAP}. To improve loading time, resize the image ` +
+ `or consider using the "ngSrcset" and "sizes" attributes.`));
+ }
+ }
+ });
+}
+
+/**
+ * Verifies that a specified input is set.
+ */
+function assertNonEmptyWidthAndHeight(dir: NgOptimizedImage) {
+ let missingAttributes = [];
+ if (dir.width === undefined) missingAttributes.push('width');
+ if (dir.height === undefined) missingAttributes.push('height');
+ if (missingAttributes.length > 0) {
+ throw new RuntimeError(
+ RuntimeErrorCode.REQUIRED_INPUT_MISSING,
+ `${imgDirectiveDetails(dir.ngSrc)} these required attributes ` +
+ `are missing: ${missingAttributes.map(attr => `"${attr}"`).join(', ')}. ` +
+ `Including "width" and "height" attributes will prevent image-related layout shifts. ` +
+ `To fix this, include "width" and "height" attributes on the image tag or turn on ` +
+ `"fill" mode with the \`fill\` attribute.`);
+ }
+}
+
+/**
+ * Verifies that width and height are not set. Used in fill mode, where those attributes don't make
+ * sense.
+ */
+function assertEmptyWidthAndHeight(dir: NgOptimizedImage) {
+ if (dir.width || dir.height) {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${
+ imgDirectiveDetails(
+ dir.ngSrc)} the attributes \`height\` and/or \`width\` are present ` +
+ `along with the \`fill\` attribute. Because \`fill\` mode causes an image to fill its containing ` +
+ `element, the size attributes have no effect and should be removed.`);
+ }
+}
+
+/**
+ * Verifies that the rendered image has a nonzero height. If the image is in fill mode, provides
+ * guidance that this can be caused by the containing element's CSS position property.
+ */
+function assertNonZeroRenderedHeight(
+ dir: NgOptimizedImage, img: HTMLImageElement, renderer: Renderer2) {
+ const removeListenerFn = renderer.listen(img, 'load', () => {
+ removeListenerFn();
+ const renderedHeight = img.clientHeight;
+ if (dir.fill && renderedHeight === 0) {
+ console.warn(formatRuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc)} the height of the fill-mode image is zero. ` +
+ `This is likely because the containing element does not have the CSS 'position' ` +
+ `property set to one of the following: "relative", "fixed", or "absolute". ` +
+ `To fix this problem, make sure the container element has the CSS 'position' ` +
+ `property defined and the height of the element is not zero.`));
+ }
+ });
+}
+
+/**
+ * Verifies that the `loading` attribute is set to a valid input &
+ * is not used on priority images.
+ */
+function assertValidLoadingInput(dir: NgOptimizedImage) {
+ if (dir.loading && dir.priority) {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc)} the \`loading\` attribute ` +
+ `was used on an image that was marked "priority". ` +
+ `Setting \`loading\` on priority images is not allowed ` +
+ `because these images will always be eagerly loaded. ` +
+ `To fix this, remove the “loading” attribute from the priority image.`);
+ }
+ const validInputs = ['auto', 'eager', 'lazy'];
+ if (typeof dir.loading === 'string' && !validInputs.includes(dir.loading)) {
+ throw new RuntimeError(
+ RuntimeErrorCode.INVALID_INPUT,
+ `${imgDirectiveDetails(dir.ngSrc)} the \`loading\` attribute ` +
+ `has an invalid value (\`${dir.loading}\`). ` +
+ `To fix this, provide a valid value ("lazy", "eager", or "auto").`);
+ }
+}
+
+/**
+ * Warns if NOT using a loader (falling back to the generic loader) and
+ * the image appears to be hosted on one of the image CDNs for which
+ * we do have a built-in image loader. Suggests switching to the
+ * built-in loader.
+ *
+ * @param ngSrc Value of the ngSrc attribute
+ * @param imageLoader ImageLoader provided
+ */
+function assertNotMissingBuiltInLoader(ngSrc: string, imageLoader: ImageLoader) {
+ if (imageLoader === noopImageLoader) {
+ let builtInLoaderName = '';
+ for (const loader of BUILT_IN_LOADERS) {
+ if (loader.testUrl(ngSrc)) {
+ builtInLoaderName = loader.name;
+ break;
+ }
+ }
+ if (builtInLoaderName) {
+ console.warn(formatRuntimeError(
+ RuntimeErrorCode.MISSING_BUILTIN_LOADER,
+ `NgOptimizedImage: It looks like your images may be hosted on the ` +
+ `${builtInLoaderName} CDN, but your app is not using Angular's ` +
+ `built-in loader for that CDN. We recommend switching to use ` +
+ `the built-in by calling \`provide${builtInLoaderName}Loader()\` ` +
+ `in your \`providers\` and passing it your instance's base URL. ` +
+ `If you don't want to use the built-in loader, define a custom ` +
+ `loader function using IMAGE_LOADER to silence this warning.`));
+ }
+ }
+}
+
+/**
+ * Warns if ngSrcset is present and no loader is configured (i.e. the default one is being used).
+ */
+function assertNoNgSrcsetWithoutLoader(dir: NgOptimizedImage, imageLoader: ImageLoader) {
+ if (dir.ngSrcset && imageLoader === noopImageLoader) {
+ console.warn(formatRuntimeError(
+ RuntimeErrorCode.MISSING_NECESSARY_LOADER,
+ `${imgDirectiveDetails(dir.ngSrc)} the \`ngSrcset\` attribute is present but ` +
+ `no image loader is configured (i.e. the default one is being used), ` +
+ `which would result in the same image being used for all configured sizes. ` +
+ `To fix this, provide a loader or remove the \`ngSrcset\` attribute from the image.`));
+ }
+}
+
+/**
+ * Warns if loaderParams is present and no loader is configured (i.e. the default one is being
+ * used).
+ */
+function assertNoLoaderParamsWithoutLoader(dir: NgOptimizedImage, imageLoader: ImageLoader) {
+ if (dir.loaderParams && imageLoader === noopImageLoader) {
+ console.warn(formatRuntimeError(
+ RuntimeErrorCode.MISSING_NECESSARY_LOADER,
+ `${imgDirectiveDetails(dir.ngSrc)} the \`loaderParams\` attribute is present but ` +
+ `no image loader is configured (i.e. the default one is being used), ` +
+ `which means that the loaderParams data will not be consumed and will not affect the URL. ` +
+ `To fix this, provide a custom loader or remove the \`loaderParams\` attribute from the image.`));
+ }
+}
+
+/**
+ * This NgModule exports the `NgOptimizedImage` directive.
+ * Import this module to enable the optimized image directive in your application.
+ *
+ * @publicApi
+ * @deprecated In Angular v15, this NgModule is removed in favor of the NgOptimizedImage directive,
+ * which is annotated as standalone.
+ */
+@NgModule({
+ declarations: [NgOptimizedImage],
+ exports: [NgOptimizedImage],
+})
+export class NgOptimizedImageModule {
+}
diff --git a/packages/common/src/directives/ng_optimized_image/preconnect_link_checker.ts b/packages/common/src/directives/ng_optimized_image/preconnect_link_checker.ts
new file mode 100644
index 00000000000..86aa847462e
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/preconnect_link_checker.ts
@@ -0,0 +1,151 @@
+/**
+ * @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, InjectFlags, InjectionToken, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
+
+import {DOCUMENT} from '../../dom_tokens';
+import {RuntimeErrorCode} from '../../errors';
+
+import {assertDevMode} from './asserts';
+import {imgDirectiveDetails} from './error_helper';
+import {extractHostname, getUrl} from './url';
+
+// Set of origins that are always excluded from the preconnect checks.
+const INTERNAL_PRECONNECT_CHECK_BLOCKLIST = new Set(['localhost', '127.0.0.1', '0.0.0.0']);
+
+/**
+ * Injection token to configure which origins should be excluded
+ * from the preconnect checks. It can either be a single string or an array of strings
+ * to represent a group of origins, for example:
+ *
+ * ```typescript
+ * {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'https://your-domain.com'}
+ * ```
+ *
+ * or:
+ *
+ * ```typescript
+ * {provide: PRECONNECT_CHECK_BLOCKLIST,
+ * useValue: ['https://your-domain-1.com', 'https://your-domain-2.com']}
+ * ```
+ *
+ * @publicApi
+ */
+export const PRECONNECT_CHECK_BLOCKLIST =
+ new InjectionToken>('PRECONNECT_CHECK_BLOCKLIST');
+
+/**
+ * Contains the logic to detect whether an image, marked with the "priority" attribute
+ * has a corresponding `` tag in the `document.head`.
+ *
+ * Note: this is a dev-mode only class, which should not appear in prod bundles,
+ * thus there is no `ngDevMode` use in the code.
+ */
+@Injectable({providedIn: 'root'})
+export class PreconnectLinkChecker {
+ private document;
+
+ /**
+ * Set of tags found on this page.
+ * The `null` value indicates that there was no DOM query operation performed.
+ */
+ private preconnectLinks: Set|null = null;
+
+ /*
+ * Keep track of all already seen origin URLs to avoid repeating the same check.
+ */
+ private alreadySeen = new Set();
+
+ private window: Window|null = null;
+
+ private blocklist = new Set(INTERNAL_PRECONNECT_CHECK_BLOCKLIST);
+
+ constructor() {
+ this.document = inject(DOCUMENT);
+ assertDevMode('preconnect link checker');
+ const win = this.document.defaultView;
+ if (typeof win !== 'undefined') {
+ this.window = win;
+ }
+ const blocklist = inject(PRECONNECT_CHECK_BLOCKLIST, InjectFlags.Optional);
+ if (blocklist) {
+ this.populateBlocklist(blocklist);
+ }
+ }
+
+ private populateBlocklist(origins: Array|string) {
+ if (Array.isArray(origins)) {
+ deepForEach(origins, origin => {
+ this.blocklist.add(extractHostname(origin));
+ });
+ } else {
+ this.blocklist.add(extractHostname(origins));
+ }
+ }
+
+ /**
+ * Checks that a preconnect resource hint exists in the head for the
+ * given src.
+ *
+ * @param rewrittenSrc src formatted with loader
+ * @param originalNgSrc ngSrc value
+ */
+ assertPreconnect(rewrittenSrc: string, originalNgSrc: string): void {
+ if (!this.window) return;
+
+ const imgUrl = getUrl(rewrittenSrc, this.window);
+ if (this.blocklist.has(imgUrl.hostname) || this.alreadySeen.has(imgUrl.origin)) return;
+
+ // Register this origin as seen, so we don't check it again later.
+ this.alreadySeen.add(imgUrl.origin);
+
+ if (!this.preconnectLinks) {
+ // Note: we query for preconnect links only *once* and cache the results
+ // for the entire lifespan of an application, since it's unlikely that the
+ // list would change frequently. This allows to make sure there are no
+ // performance implications of making extra DOM lookups for each image.
+ this.preconnectLinks = this.queryPreconnectLinks();
+ }
+
+ if (!this.preconnectLinks.has(imgUrl.origin)) {
+ console.warn(formatRuntimeError(
+ RuntimeErrorCode.PRIORITY_IMG_MISSING_PRECONNECT_TAG,
+ `${imgDirectiveDetails(originalNgSrc)} there is no preconnect tag present for this ` +
+ `image. Preconnecting to the origin(s) that serve priority images ensures that these ` +
+ `images are delivered as soon as possible. To fix this, please add the following ` +
+ `element into the of the document:\n` +
+ ` `));
+ }
+ }
+
+ private queryPreconnectLinks(): Set {
+ const preconnectUrls = new Set();
+ const selector = 'link[rel=preconnect]';
+ const links: HTMLLinkElement[] = Array.from(this.document.querySelectorAll(selector));
+ for (let link of links) {
+ const url = getUrl(link.href, this.window!);
+ preconnectUrls.add(url.origin);
+ }
+ return preconnectUrls;
+ }
+
+ ngOnDestroy() {
+ this.preconnectLinks?.clear();
+ this.alreadySeen.clear();
+ }
+}
+
+/**
+ * Invokes a callback for each element in the array. Also invokes a callback
+ * recursively for each nested array.
+ */
+function deepForEach(input: (T|any[])[], fn: (value: T) => void): void {
+ for (let value of input) {
+ Array.isArray(value) ? deepForEach(value, fn) : fn(value);
+ }
+}
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 00000000000..60e1ae09035
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/preload-link-creator.ts
@@ -0,0 +1,85 @@
+/**
+ * @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;
+ private readonly document;
+
+ constructor() {
+ this.preloadedImages = inject(PRELOADED_IMAGES);
+ this.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');
+ renderer.setAttribute(preload, 'fetchpriority', 'high');
+
+ 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 00000000000..ab09b9b6475
--- /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/directives/ng_optimized_image/url.ts b/packages/common/src/directives/ng_optimized_image/url.ts
new file mode 100644
index 00000000000..3b3aa48dce9
--- /dev/null
+++ b/packages/common/src/directives/ng_optimized_image/url.ts
@@ -0,0 +1,48 @@
+/**
+ * @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
+ */
+
+// Converts a string that represents a URL into a URL class instance.
+export function getUrl(src: string, win: Window): URL {
+ // Don't use a base URL is the URL is absolute.
+ return isAbsoluteUrl(src) ? new URL(src) : new URL(src, win.location.href);
+}
+
+// Checks whether a URL is absolute (i.e. starts with `http://` or `https://`).
+export function isAbsoluteUrl(src: string): boolean {
+ return /^https?:\/\//.test(src);
+}
+
+// Given a URL, extract the hostname part.
+// If a URL is a relative one - the URL is returned as is.
+export function extractHostname(url: string): string {
+ return isAbsoluteUrl(url) ? (new URL(url)).hostname : url;
+}
+
+export function isValidPath(path: unknown): boolean {
+ const isString = typeof path === 'string';
+
+ if (!isString || path.trim() === '') {
+ return false;
+ }
+
+ // Calling new URL() will throw if the path string is malformed
+ try {
+ const url = new URL(path);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function normalizePath(path: string): string {
+ return path.endsWith('/') ? path.slice(0, -1) : path;
+}
+
+export function normalizeSrc(src: string): string {
+ return src.startsWith('/') ? src.slice(1) : src;
+}
diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts
index 5e0e4ee3ce8..cad5c9d4d50 100644
--- a/packages/common/src/errors.ts
+++ b/packages/common/src/errors.ts
@@ -14,5 +14,21 @@ export const enum RuntimeErrorCode {
// NgSwitch errors
PARENT_NG_SWITCH_NOT_FOUND = 2000,
// Pipe errors
- INVALID_PIPE_ARGUMENT = 2100
+ INVALID_PIPE_ARGUMENT = 2100,
+
+ // Image directive errors
+ UNEXPECTED_SRC_ATTR = 2950,
+ UNEXPECTED_SRCSET_ATTR = 2951,
+ INVALID_INPUT = 2952,
+ UNEXPECTED_INPUT_CHANGE = 2953,
+ REQUIRED_INPUT_MISSING = 2954,
+ LCP_IMG_MISSING_PRIORITY = 2955,
+ PRIORITY_IMG_MISSING_PRECONNECT_TAG = 2956,
+ INVALID_PRECONNECT_CHECK_BLOCKLIST = 2957,
+ UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
+ INVALID_LOADER_ARGUMENTS = 2959,
+ OVERSIZED_IMAGE = 2960,
+ TOO_MANY_PRELOADED_IMAGES = 2961,
+ MISSING_BUILTIN_LOADER = 2962,
+ MISSING_NECESSARY_LOADER = 2963,
}
diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts
new file mode 100644
index 00000000000..a68ee05a834
--- /dev/null
+++ b/packages/common/test/directives/ng_optimized_image_spec.ts
@@ -0,0 +1,1799 @@
+/**
+ * @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 {CommonModule, DOCUMENT} from '@angular/common';
+import {RuntimeErrorCode} from '@angular/common/src/errors';
+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, NgOptimizedImageModule, 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`);
+ expect(preloadLink!.getAttribute('fetchpriority')).toEqual('high');
+
+ 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.
+ if (!isBrowser) return;
+
+ setupTestingModule();
+
+ const template = '';
+ TestBed.overrideComponent(TestComponent, {set: {template: template}});
+
+ const _document = TestBed.inject(DOCUMENT);
+ const _window = _document.defaultView!;
+ const setAttributeSpy =
+ spyOn(_window.HTMLImageElement.prototype, 'setAttribute').and.callThrough();
+
+ const fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('loading')).toBe('eager');
+
+ let _imgInstance = null;
+ let _loadingAttrId = -1;
+ let _fetchpriorityAttrId = -1;
+ let _srcAttrId = -1;
+ const count = setAttributeSpy.calls.count();
+ for (let i = 0; i < count; i++) {
+ if (!_imgInstance) {
+ _imgInstance = setAttributeSpy.calls.thisFor(i);
+ } else if (_imgInstance !== setAttributeSpy.calls.thisFor(i)) {
+ // Verify that the instance is the same during the test.
+ fail('Unexpected instance of a second instance present in a test.');
+ }
+
+ // Note: spy.calls.argsFor(i) returns args as an array: ['src', 'eager']
+ const attrName = setAttributeSpy.calls.argsFor(i)[0];
+ if (attrName == 'loading') _loadingAttrId = i;
+ if (attrName == 'fetchpriority') _fetchpriorityAttrId = i;
+ if (attrName == 'src') _srcAttrId = i;
+ }
+ // Verify that both `loading` and `fetchpriority` are set *before* `src`:
+ expect(_loadingAttrId).toBeGreaterThan(-1); // was actually set
+ expect(_loadingAttrId).toBeLessThan(_srcAttrId); // was set after `src`
+
+ expect(_fetchpriorityAttrId).toBeGreaterThan(-1); // was actually set
+ expect(_fetchpriorityAttrId).toBeLessThan(_srcAttrId); // was set after `src`
+ });
+
+ it('should always reflect the width/height attributes if bound', () => {
+ setupTestingModule();
+
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('width')).toBe('100');
+ expect(img.getAttribute('height')).toBe('50');
+ });
+
+ describe('setup error handling', () => {
+ it('should throw if both `src` and `ngSrc` are present', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02950: The NgOptimizedImage directive (activated on an ' +
+ 'element with the `ngSrc="path/img.png"`) has detected that both ' +
+ '`src` and `ngSrc` have been set. Supplying both of these attributes ' +
+ 'breaks lazy loading. The NgOptimizedImage directive sets `src` ' +
+ 'itself based on the value of `ngSrc`. To fix this, please remove ' +
+ 'the `src` attribute.');
+ });
+
+ it('should throw if both `ngSrcet` and `srcset` is present', () => {
+ setupTestingModule();
+
+ const template =
+ '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02951: The NgOptimizedImage directive (activated on an ' +
+ 'element with the `ngSrc="img-100.png"`) has detected that both ' +
+ '`srcset` and `ngSrcset` have been set. Supplying both of these ' +
+ 'attributes breaks lazy loading. ' +
+ 'The NgOptimizedImage directive sets `srcset` itself based ' +
+ 'on the value of `ngSrcset`. To fix this, please remove the `srcset` ' +
+ 'attribute.');
+ });
+
+ it('should throw if `ngSrc` contains a Base64-encoded image (that starts with `data:`)', () => {
+ setupTestingModule();
+
+ expect(() => {
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive has detected that `ngSrc` ' +
+ 'is a Base64-encoded string (data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDov...). ' +
+ 'NgOptimizedImage does not support Base64-encoded strings. ' +
+ 'To fix this, disable the NgOptimizedImage directive for this element ' +
+ 'by removing `ngSrc` and using a standard `src` attribute instead.');
+ });
+
+ it('should throw if `ngSrc` contains a `blob:` URL', (done) => {
+ // Domino does not support canvas elements properly,
+ // so run this test only in a browser.
+ if (!isBrowser) {
+ done();
+ return;
+ }
+
+ const canvas = document.createElement('canvas');
+ canvas.toBlob(function(blob) {
+ const blobURL = URL.createObjectURL(blob!);
+
+ setupTestingModule();
+
+ // Note: use RegExp to partially match the error message, since the blob URL
+ // is created dynamically, so it might be different for each invocation.
+ const errorMessageRegExp =
+ /NG02952: The NgOptimizedImage directive (.*?) has detected that `ngSrc` was set to a blob URL \(blob:/;
+ expect(() => {
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ }).toThrowError(errorMessageRegExp);
+ done();
+ });
+ });
+
+ it('should throw if `width` and `height` are not set', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02954: The NgOptimizedImage directive (activated on an ' +
+ 'element with the `ngSrc="img.png"`) has detected that these ' +
+ 'required attributes are missing: "width", "height". Including "width" and ' +
+ '"height" attributes will prevent image-related layout shifts. ' +
+ 'To fix this, include "width" and "height" attributes on the image tag or turn on ' +
+ '"fill" mode with the `fill` attribute.');
+ });
+
+ it('should throw if `width` is not set', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02954: The NgOptimizedImage directive (activated on an ' +
+ 'element with the `ngSrc="img.png"`) has detected that these ' +
+ 'required attributes are missing: "width". Including "width" and ' +
+ '"height" attributes will prevent image-related layout shifts. ' +
+ 'To fix this, include "width" and "height" attributes on the image tag or turn on ' +
+ '"fill" mode with the `fill` attribute.');
+ });
+
+ it('should throw if `width` is 0', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an ' +
+ 'element with the `ngSrc="img.png"`) has detected that `width` ' +
+ 'has an invalid value (`0`). To fix this, provide `width` as ' +
+ 'a number greater than 0.');
+ });
+
+ it('should throw if `width` has an invalid value', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an ' +
+ 'element with the `ngSrc="img.png"`) has detected that `width` ' +
+ 'has an invalid value (`10px`). To fix this, provide `width` ' +
+ 'as a number greater than 0.');
+ });
+
+ it('should throw if `height` is not set', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02954: The NgOptimizedImage directive (activated on an ' +
+ 'element with the `ngSrc="img.png"`) has detected that these required ' +
+ 'attributes are missing: "height". Including "width" and "height" ' +
+ 'attributes will prevent image-related layout shifts. ' +
+ 'To fix this, include "width" and "height" attributes on the image tag or turn on ' +
+ '"fill" mode with the `fill` attribute.');
+ });
+
+ it('should throw if `height` is 0', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an ' +
+ 'element with the `ngSrc="img.png"`) has detected that `height` ' +
+ 'has an invalid value (`0`). To fix this, provide `height` as a number ' +
+ 'greater than 0.');
+ });
+
+ it('should throw if `height` has an invalid value', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an element ' +
+ 'with the `ngSrc="img.png"`) has detected that `height` has an invalid ' +
+ 'value (`10%`). To fix this, provide `height` as a number greater than 0.');
+ });
+
+ it('should throw if `ngSrc` value is not provided', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an ' +
+ 'element with the `ngSrc=""`) has detected that `ngSrc` has an ' +
+ 'invalid value (``). ' +
+ 'To fix this, change the value to a non-empty string.');
+ });
+
+ it('should throw if `ngSrc` value is set to an empty string', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an element ' +
+ 'with the `ngSrc=" "`) has detected that `ngSrc` has an invalid value ' +
+ '(` `). To fix this, change the value to a non-empty string.');
+ });
+
+ describe('invalid `ngSrcset` values', () => {
+ const mockDirectiveInstance = {ngSrc: 'img.png'} as NgOptimizedImage;
+
+ it('should throw for empty ngSrcSet', () => {
+ const imageLoader = (config: ImageLoaderConfig) => {
+ const width = config.width ? `-${config.width}` : ``;
+ return window.location.origin + `/path/${config.src}${width}.png`;
+ };
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an ' +
+ 'element with the `ngSrc="img"`) has detected that `ngSrcset` ' +
+ 'has an invalid value (``). ' +
+ 'To fix this, change the value to a non-empty string.');
+ });
+
+ it('should throw for invalid ngSrcSet', () => {
+ const imageLoader = (config: ImageLoaderConfig) => {
+ const width = config.width ? `-${config.width}` : ``;
+ return window.location.origin + `/path/${config.src}${width}.png`;
+ };
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an element ' +
+ 'with the `ngSrc="img"`) has detected that `ngSrcset` has an invalid value ' +
+ '(`100q, 200q`). To fix this, supply `ngSrcset` using a comma-separated list ' +
+ 'of one or more width descriptors (e.g. "100w, 200w") or density descriptors ' +
+ '(e.g. "1x, 2x").');
+ });
+
+ it('should throw if ngSrcset exceeds the density cap', () => {
+ const imageLoader = (config: ImageLoaderConfig) => {
+ const width = config.width ? `-${config.width}` : ``;
+ return window.location.origin + `/path/${config.src}${width}.png`;
+ };
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ `NG0${
+ RuntimeErrorCode
+ .INVALID_INPUT}: The NgOptimizedImage directive (activated on an element with the \`ngSrc="img"\`) ` +
+ `has detected that the \`ngSrcset\` contains an unsupported image density:` +
+ `\`1x, 2x, 3x, 4x, 5x\`. NgOptimizedImage generally recommends a max image density of ` +
+ `${RECOMMENDED_SRCSET_DENSITY_CAP}x but supports image densities up to ` +
+ `${ABSOLUTE_SRCSET_DENSITY_CAP}x. The human eye cannot distinguish between image densities ` +
+ `greater than ${
+ RECOMMENDED_SRCSET_DENSITY_CAP}x - which makes them unnecessary for ` +
+ `most use cases. Images that will be pinch-zoomed are typically the primary use case for ` +
+ `${ABSOLUTE_SRCSET_DENSITY_CAP}x images. Please remove the high density descriptor and try again.`);
+ });
+
+
+ it('should throw if ngSrcset exceeds the density cap with multiple digits', () => {
+ const imageLoader = (config: ImageLoaderConfig) => {
+ const width = config.width ? `-${config.width}` : ``;
+ return window.location.origin + `/path/${config.src}${width}.png`;
+ };
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ `NG0${
+ RuntimeErrorCode
+ .INVALID_INPUT}: The NgOptimizedImage directive (activated on an element with the \`ngSrc="img"\`) ` +
+ `has detected that the \`ngSrcset\` contains an unsupported image density:` +
+ `\`1x, 200x\`. NgOptimizedImage generally recommends a max image density of ` +
+ `${RECOMMENDED_SRCSET_DENSITY_CAP}x but supports image densities up to ` +
+ `${ABSOLUTE_SRCSET_DENSITY_CAP}x. The human eye cannot distinguish between image densities ` +
+ `greater than ${
+ RECOMMENDED_SRCSET_DENSITY_CAP}x - which makes them unnecessary for ` +
+ `most use cases. Images that will be pinch-zoomed are typically the primary use case for ` +
+ `${ABSOLUTE_SRCSET_DENSITY_CAP}x images. Please remove the high density descriptor and try again.`);
+ });
+
+ it('should throw if width srcset is missing a comma', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, '100w 200w');
+ }).toThrowError();
+ });
+
+ it('should throw if density srcset is missing a comma', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, '1x 2x');
+ }).toThrowError();
+ });
+
+ it('should throw if density srcset has too many digits', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, '100x, 2x');
+ }).toThrowError();
+ });
+
+ it('should throw if width srcset includes a file name', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, 'a.png 100w, b.png 200w');
+ }).toThrowError();
+ });
+
+ it('should throw if density srcset includes a file name', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, 'a.png 1x, b.png 2x');
+ }).toThrowError();
+ });
+
+ it('should throw if srcset starts with a letter', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, 'a100w, 200w');
+ }).toThrowError();
+ });
+
+ it('should throw if srcset starts with another non-digit', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, '--100w, 200w');
+ }).toThrowError();
+ });
+
+ it('should throw if first descriptor in srcset is junk', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, 'foo, 1x');
+ }).toThrowError();
+ });
+
+ it('should throw if later descriptors in srcset are junk', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, '100w, foo');
+ }).toThrowError();
+ });
+
+ it('should throw if srcset has a density descriptor after a width descriptor', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, '100w, 1x');
+ }).toThrowError();
+ });
+
+ it('should throw if srcset has a width descriptor after a density descriptor', () => {
+ expect(() => {
+ assertValidNgSrcset(mockDirectiveInstance, '1x, 200w');
+ }).toThrowError();
+ });
+ });
+
+ const inputs = [
+ ['ngSrc', 'new-img.png'], ['width', 10], ['height', 20], ['priority', true], ['fill', true],
+ ['loading', true], ['sizes', '90vw'], ['disableOptimizedSrcset', true],
+ ['loaderParams', '{foo: "test1"}']
+ ];
+ inputs.forEach(([inputName, value]) => {
+ it(`should throw if the \`${inputName}\` input changed after directive initialized the input`,
+ () => {
+ @Component({
+ selector: 'test-cmp',
+ template: ``
+ })
+ class TestComponent {
+ width = 100;
+ height = 50;
+ ngSrc = 'img.png';
+ priority = false;
+ fill = false;
+ loading = false;
+ sizes = '100vw';
+ disableOptimizedSrcset = false;
+ loaderParams = {bar: 'test2'};
+ }
+
+ setupTestingModule({component: TestComponent});
+
+ // Initial render
+ const fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+
+ const expectedErrorMessage = //
+ `NG02953: The NgOptimizedImage directive (.*)? ` +
+ `has detected that \`${inputName}\` was updated after initialization`;
+ expect(() => {
+ // Update input (expect to throw)
+ (fixture.componentInstance as unknown as
+ {[key: string]: unknown})[inputName as string] = value;
+ fixture.detectChanges();
+ }).toThrowError(new RegExp(expectedErrorMessage));
+ });
+ });
+ });
+
+ describe('lazy loading', () => {
+ it('should eagerly load priority images', () => {
+ setupTestingModule();
+
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('loading')).toBe('eager');
+ });
+
+ it('should lazily load non-priority images', () => {
+ setupTestingModule();
+
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('loading')).toBe('lazy');
+ });
+ });
+
+ describe('loading attribute', () => {
+ it('should override the default loading behavior for non-priority images', () => {
+ setupTestingModule();
+
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('loading')).toBe('eager');
+ });
+
+ it('should throw if used with priority images', () => {
+ setupTestingModule();
+
+ const template =
+ '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an element ' +
+ 'with the `ngSrc="path/img.png"`) has detected that the `loading` attribute ' +
+ 'was used on an image that was marked "priority". Setting `loading` on priority ' +
+ 'images is not allowed because these images will always be eagerly loaded. ' +
+ 'To fix this, remove the “loading” attribute from the priority image.');
+ });
+
+ it('should support setting loading priority to "auto"', () => {
+ setupTestingModule();
+
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('loading')).toBe('auto');
+ });
+
+ it('should throw for invalid loading inputs', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an element ' +
+ 'with the `ngSrc="path/img.png"`) has detected that the `loading` attribute ' +
+ 'has an invalid value (`fast`). To fix this, provide a valid value ("lazy", ' +
+ '"eager", or "auto").');
+ });
+ });
+
+ describe('fetch priority', () => {
+ it('should be "high" for priority images', () => {
+ setupTestingModule();
+
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('fetchpriority')).toBe('high');
+ });
+
+ it('should be "auto" for non-priority images', () => {
+ setupTestingModule();
+
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('fetchpriority')).toBe('auto');
+ });
+ });
+
+ describe('meta data', () => {
+ it('should add a data attribute to the element for identification', () => {
+ setupTestingModule();
+ const template = '';
+
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('ng-img')).not.toBeNull();
+ });
+ it('should add a data attribute to the element for identification, when ngSrc bound', () => {
+ setupTestingModule();
+ const template = ``;
+
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('ng-img')).not.toBeNull();
+ });
+ });
+
+ describe('fill mode', () => {
+ it('should allow unsized images in fill mode', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ }).not.toThrow();
+ });
+ it('should throw if width is provided for fill mode image', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an element with the ' +
+ '`ngSrc="path/img.png"`) has detected that the attributes `height` and/or `width` ' +
+ 'are present along with the `fill` attribute. Because `fill` mode causes an image ' +
+ 'to fill its containing element, the size attributes have no effect and should be removed.');
+ });
+ it('should throw if height is provided for fill mode image', () => {
+ setupTestingModule();
+
+ const template = '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive (activated on an element with the ' +
+ '`ngSrc="path/img.png"`) has detected that the attributes `height` and/or `width` ' +
+ 'are present along with the `fill` attribute. Because `fill` mode causes an image ' +
+ 'to fill its containing element, the size attributes have no effect and should be removed.');
+ });
+ it('should apply appropriate styles in fill mode', () => {
+ setupTestingModule();
+
+ const template = '';
+
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('style')?.replace(/\s/g, ''))
+ .toBe('position:absolute;width:100%;height:100%;inset:0px;');
+ });
+ it('should augment existing styles in fill mode', () => {
+ setupTestingModule();
+
+ const template = '';
+
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('style')?.replace(/\s/g, ''))
+ .toBe(
+ 'border-radius:5px;padding:10px;position:absolute;width:100%;height:100%;inset:0px;');
+ });
+ it('should not add fill styles if not in fill mode', () => {
+ setupTestingModule();
+
+ const template =
+ '';
+
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('style')?.replace(/\s/g, ''))
+ .toBe('position:relative;border-radius:5px;');
+ });
+ it('should add default sizes value in fill mode', () => {
+ setupTestingModule();
+
+ const template = '';
+
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('sizes')).toBe('100vw');
+ });
+ it('should not overwrite sizes value in fill mode', () => {
+ setupTestingModule();
+
+ const template = '';
+
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('sizes')).toBe('50vw');
+ });
+ it('should cause responsive srcset to be generated in fill mode', () => {
+ setupTestingModule();
+
+ const template = '';
+
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('srcset'))
+ .toBe(
+ `${IMG_BASE_URL}/path/img.png 640w, ${IMG_BASE_URL}/path/img.png 750w, ${
+ IMG_BASE_URL}/path/img.png 828w, ` +
+ `${IMG_BASE_URL}/path/img.png 1080w, ${IMG_BASE_URL}/path/img.png 1200w, ${
+ IMG_BASE_URL}/path/img.png 1920w, ` +
+ `${IMG_BASE_URL}/path/img.png 2048w, ${IMG_BASE_URL}/path/img.png 3840w`);
+ });
+ });
+
+ describe('preconnect detector', () => {
+ 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';
+ };
+
+ it('should log a warning if there is no preconnect link for a priority image',
+ withHead('', () => {
+ setupTestingModule({imageLoader});
+
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toBe(
+ 'NG02956: The NgOptimizedImage directive (activated on an element ' +
+ 'with the `ngSrc="a.png"`) has detected that there is no preconnect tag ' +
+ 'present for this image. Preconnecting to the origin(s) that serve ' +
+ 'priority images ensures that these images are delivered as soon as ' +
+ 'possible. To fix this, please add the following element into the ' +
+ 'of the document:' +
+ '\n ');
+ }));
+
+ it('should not log a warning if there is no preconnect link, but the image is not set as a priority',
+ withHead('', () => {
+ setupTestingModule({imageLoader});
+
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ // Expect no warnings in the console.
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ }));
+
+ it('should log a warning if there is a preconnect, but it doesn\'t match the priority image',
+ withHead('', () => {
+ setupTestingModule({imageLoader});
+
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toBe(
+ 'NG02956: The NgOptimizedImage directive (activated on an element ' +
+ 'with the `ngSrc="a.png"`) has detected that there is no preconnect tag ' +
+ 'present for this image. Preconnecting to the origin(s) that serve priority ' +
+ 'images ensures that these images are delivered as soon as possible. ' +
+ 'To fix this, please add the following element into the of the document:' +
+ '\n ');
+ }));
+
+ it('should log a warning if there is no matching preconnect link for a priority image, but there is a preload tag',
+ withHead(
+ '',
+ () => {
+ setupTestingModule({imageLoader});
+
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toBe(
+ 'NG02956: The NgOptimizedImage directive (activated on an element ' +
+ 'with the `ngSrc="a.png"`) has detected that there is no preconnect tag ' +
+ 'present for this image. Preconnecting to the origin(s) that serve priority ' +
+ 'images ensures that these images are delivered as soon as possible. ' +
+ 'To fix this, please add the following element into the of the document:' +
+ '\n ');
+ }));
+
+ it('should not log a warning if there is a matching preconnect link for a priority image (with an extra `/` at the end)',
+ withHead('', () => {
+ setupTestingModule({imageLoader});
+
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ // Expect no warnings in the console.
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ }));
+
+ ['localhost', '127.0.0.1', '0.0.0.0'].forEach(blocklistedHostname => {
+ it(`should not log a warning if an origin domain is blocklisted ` +
+ `(checking ${blocklistedHostname})`,
+ withHead('', () => {
+ const imageLoader = () => {
+ return `http://${blocklistedHostname}/a.png`;
+ };
+ setupTestingModule({imageLoader});
+
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ // Expect no warnings in the console.
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ }));
+ });
+
+ describe('PRECONNECT_CHECK_BLOCKLIST token', () => {
+ it(`should allow passing host names`, withHead('', () => {
+ const providers = [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'angular.io'}];
+ setupTestingModule({imageLoader, extraProviders: providers});
+
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ // Expect no warnings in the console.
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ }));
+
+ it(`should allow passing origins`, withHead('', () => {
+ const providers =
+ [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'https://angular.io'}];
+ setupTestingModule({imageLoader, extraProviders: providers});
+
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ // Expect no warnings in the console.
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ }));
+
+ it(`should allow passing arrays of host names`, withHead('', () => {
+ const providers =
+ [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: ['https://angular.io']}];
+ setupTestingModule({imageLoader, extraProviders: providers});
+
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ // Expect no warnings in the console.
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ }));
+
+ it(`should allow passing nested arrays of host names`, withHead('', () => {
+ const providers =
+ [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: [['https://angular.io']]}];
+ setupTestingModule({imageLoader, extraProviders: providers});
+
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ // Expect no warnings in the console.
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ }));
+ });
+ });
+
+ describe('loaders', () => {
+ const imageLoaderWithData = (config: ImageLoaderConfig) => {
+ let paramsString = '';
+ if (config.loaderParams) {
+ paramsString =
+ Object.entries(config.loaderParams).map(entry => `${entry[0]}=${entry[1]}`).join('&');
+ }
+ let queryString = `${config.width ? 'w=' + config.width + '&' : ''}${paramsString}`;
+ return `${config.src}?${queryString}`;
+ };
+
+ // Test complex loaderParams schema with nesting:
+ // loaderParams = {
+ // transforms1: {example1: "foo"},
+ // transforms2: {example2: "bar"}
+ // }
+ const nestedImageLoader = (config: ImageLoaderConfig) => {
+ return `${config.src}/${config.loaderParams?.transforms1.example1}/${
+ config.loaderParams?.transforms2.example2}`;
+ };
+
+ it('should set `src` to match `ngSrc` if image loader is not provided', () => {
+ setupTestingModule();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
+ });
+
+ it('should warn if there is no image loader but using Imgix URL', () => {
+ setUpModuleNoLoader();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ const consoleWarnSpy = spyOn(console, 'warn');
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toMatch(/your images may be hosted on the Imgix CDN/);
+ });
+
+ it('should warn if there is no image loader but using ImageKit URL', () => {
+ setUpModuleNoLoader();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ const consoleWarnSpy = spyOn(console, 'warn');
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toMatch(/your images may be hosted on the ImageKit CDN/);
+ });
+
+ it('should warn if there is no image loader but using Cloudinary URL', () => {
+ setUpModuleNoLoader();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ const consoleWarnSpy = spyOn(console, 'warn');
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toMatch(/your images may be hosted on the Cloudinary CDN/);
+ });
+
+ it('should NOT warn if there is a custom loader but using CDN URL', () => {
+ setupTestingModule();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ const consoleWarnSpy = spyOn(console, 'warn');
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ });
+
+ it('should warn if there is no image loader but `ngSrcset` is present', () => {
+ setUpModuleNoLoader();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ const consoleWarnSpy = spyOn(console, 'warn');
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toBe(
+ `NG0${
+ RuntimeErrorCode
+ .MISSING_NECESSARY_LOADER}: The NgOptimizedImage directive (activated on an element ` +
+ 'with the `ngSrc="img.png"`) has detected that the `ngSrcset` attribute is ' +
+ 'present but no image loader is configured (i.e. the default one is being used), ' +
+ `which would result in the same image being used for all configured sizes. ` +
+ 'To fix this, provide a loader or remove the `ngSrcset` attribute from the image.');
+ });
+
+ it('should warn if there is no image loader but `loaderParams` is present', () => {
+ setUpModuleNoLoader();
+
+ const template =
+ ``;
+ const fixture = createTestComponent(template);
+ const consoleWarnSpy = spyOn(console, 'warn');
+ fixture.detectChanges();
+
+ expect(consoleWarnSpy.calls.count()).toBe(1);
+ expect(consoleWarnSpy.calls.argsFor(0)[0])
+ .toBe(
+ `NG0${
+ RuntimeErrorCode
+ .MISSING_NECESSARY_LOADER}: The NgOptimizedImage directive (activated on an element ` +
+ 'with the `ngSrc="img.png"`) has detected that the `loaderParams` attribute is ' +
+ 'present but no image loader is configured (i.e. the default one is being used), ' +
+ `which means that the loaderParams data will not be consumed and will not affect the URL. ` +
+ 'To fix this, provide a custom loader or remove the `loaderParams` attribute from the image.');
+ });
+
+ it('should set `src` using the image loader provided via the `IMAGE_LOADER` token to compose src URL',
+ () => {
+ const imageLoader = (config: ImageLoaderConfig) => `${IMG_BASE_URL}/${config.src}`;
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const imgs = nativeElement.querySelectorAll('img')!;
+ expect(imgs[0].src.trim()).toBe(`${IMG_BASE_URL}/img.png`);
+ expect(imgs[1].src.trim()).toBe(`${IMG_BASE_URL}/img-2.png`);
+ });
+
+ it('should pass absolute URLs defined in the `ngSrc` to custom image loaders provided via the `IMAGE_LOADER` token',
+ () => {
+ const imageLoader = (config: ImageLoaderConfig) => `${config.src}?rewritten=true`;
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const imgs = nativeElement.querySelectorAll('img')!;
+ expect(imgs[0].src.trim()).toBe(`${IMG_BASE_URL}/img.png?rewritten=true`);
+ });
+
+ it('should pass data payload from loaderParams to custom image loaders', () => {
+ setupTestingModule({imageLoader: imageLoaderWithData});
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const imgs = nativeElement.querySelectorAll('img')!;
+ expect(imgs[0].src).toBe(`${IMG_BASE_URL}/img.png?testProp1=testValue1&testProp2=testValue2`);
+ });
+
+ it('should pass nested data payloads from loaderParams to custom image loaders', () => {
+ @Component({
+ selector: 'test-cmp',
+ template: ``
+ })
+ class TestComponent {
+ ngSrc = `${IMG_BASE_URL}/img.png`;
+ width = 300;
+ height = 300;
+ params = {transforms1: {example1: 'foo'}, transforms2: {example2: 'bar'}};
+ }
+ setupTestingModule({imageLoader: nestedImageLoader, component: TestComponent});
+ const fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const imgs = nativeElement.querySelectorAll('img')!;
+ expect(imgs[0].src).toBe(`${IMG_BASE_URL}/img.png/foo/bar`);
+ });
+
+ it('should pass data payload from loaderParams to loader when generating srcsets', () => {
+ setupTestingModule({imageLoader: imageLoaderWithData});
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const imgs = nativeElement.querySelectorAll('img')!;
+ expect(imgs[0].srcset)
+ .toBe(`${IMG_BASE_URL}/img.png?w=150&testProp1=testValue1&testProp2=testValue2 1x, ${
+ IMG_BASE_URL}/img.png?w=300&testProp1=testValue1&testProp2=testValue2 2x`);
+ });
+
+ it('should pass data payload from loaderParams to loader when generating responsive srcsets',
+ () => {
+ setupTestingModule({imageLoader: imageLoaderWithData});
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const imgs = nativeElement.querySelectorAll('img')!;
+ expect(imgs[0].srcset)
+ .toBe(`${IMG_BASE_URL}/img.png?w=640&testProp1=testValue1&testProp2=testValue2 640w, ${
+ IMG_BASE_URL}/img.png?w=750&testProp1=testValue1&testProp2=testValue2 750w, ${
+ IMG_BASE_URL}/img.png?w=828&testProp1=testValue1&testProp2=testValue2 828w, ${
+ IMG_BASE_URL}/img.png?w=1080&testProp1=testValue1&testProp2=testValue2 1080w, ${
+ IMG_BASE_URL}/img.png?w=1200&testProp1=testValue1&testProp2=testValue2 1200w, ${
+ IMG_BASE_URL}/img.png?w=1920&testProp1=testValue1&testProp2=testValue2 1920w, ${
+ IMG_BASE_URL}/img.png?w=2048&testProp1=testValue1&testProp2=testValue2 2048w, ${
+ IMG_BASE_URL}/img.png?w=3840&testProp1=testValue1&testProp2=testValue2 3840w`);
+ });
+
+ it('should set `src` to an image URL that does not include a default width parameter', () => {
+ const imageLoader = (config: ImageLoaderConfig) => {
+ const widthStr = config.width ? `?w=${config.width}` : ``;
+ return `${IMG_BASE_URL}/${config.src}${widthStr}`;
+ };
+ setupTestingModule({imageLoader});
+
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
+ });
+
+ it(`should allow providing image loaders via Component providers`, withHead('', () => {
+ const createImgUrl = (path: string, config: ImageLoaderConfig) => `${path}/${config.src}`;
+ const loaderWithPath = createImageLoader(createImgUrl);
+
+ @Component({
+ selector: 'test-cmp',
+ template: '',
+ providers: [loaderWithPath('https://component.io')]
+ })
+ class TestComponent {
+ }
+
+ setupTestingModule(
+ {component: TestComponent, extraProviders: [loaderWithPath('https://default.io')]});
+
+ const fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+
+ const defaultLoader = TestBed.inject(IMAGE_LOADER);
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+
+ expect(defaultLoader({src: 'a.png'})).toBe('https://default.io/a.png');
+ expect(img.src).toBe('https://component.io/a.png');
+ }));
+
+ describe('`ngSrcset` values', () => {
+ let imageLoader!: ImageLoader;
+
+ beforeEach(() => {
+ imageLoader = (config: ImageLoaderConfig) => {
+ const width = config.width ? `?w=${config.width}` : ``;
+ return `${IMG_BASE_URL}/${config.src}${width}`;
+ };
+ });
+
+ it('should set the `srcset` using the `ngSrcset` value with width descriptors', () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
+ expect(img.srcset)
+ .toBe(`${IMG_BASE_URL}/img.png?w=100 100w, ${IMG_BASE_URL}/img.png?w=200 200w`);
+ });
+
+ it('should set the `srcset` using the `ngSrcset` value with density descriptors', () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
+ expect(img.srcset)
+ .toBe(`${IMG_BASE_URL}/img.png?w=100 1x, ${IMG_BASE_URL}/img.png?w=200 2x`);
+ });
+
+ it('should set the `srcset` if `ngSrcset` has only one src defined', () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.src.trim()).toBe(`${IMG_BASE_URL}/img.png`);
+ expect(img.srcset.trim()).toBe(`${IMG_BASE_URL}/img.png?w=100 100w`);
+ });
+
+ it('should set the `srcset` if `ngSrcSet` has extra spaces', () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
+ expect(img.srcset)
+ .toBe(`${IMG_BASE_URL}/img.png?w=100 100w, ${IMG_BASE_URL}/img.png?w=200 200w`);
+ });
+
+ it('should set the `srcset` if `ngSrcSet` has a trailing comma', () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
+ expect(img.srcset)
+ .toBe(`${IMG_BASE_URL}/img.png?w=100 1x, ${IMG_BASE_URL}/img.png?w=200 2x`);
+ });
+
+ it('should set the `srcset` if `ngSrcSet` has 3+ srcs', () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
+ expect(img.srcset)
+ .toBe(
+ `${IMG_BASE_URL}/img.png?w=100 100w, ` +
+ `${IMG_BASE_URL}/img.png?w=200 200w, ` +
+ `${IMG_BASE_URL}/img.png?w=300 300w`);
+ });
+
+ it('should set the `srcset` if `ngSrcSet` has decimal density descriptors', () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
+ expect(img.srcset)
+ .toBe(
+ `${IMG_BASE_URL}/img.png?w=175 1.75x, ` +
+ `${IMG_BASE_URL}/img.png?w=250 2.5x, ` +
+ `${IMG_BASE_URL}/img.png?w=300 3x`);
+ });
+ });
+
+ describe('sizes attribute', () => {
+ it('should pass through the sizes attribute', () => {
+ setupTestingModule();
+
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+
+ expect(img.getAttribute('sizes'))
+ .toBe('(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw');
+ });
+
+ it('should throw if a complex `sizes` is used', () => {
+ setupTestingModule();
+
+ const template =
+ '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive has detected that `sizes` was set to a string including pixel values. ' +
+ 'For automatic `srcset` generation, `sizes` must only include responsive values, such as `sizes="50vw"` or ' +
+ '`sizes="(min-width: 768px) 50vw, 100vw"`. To fix this, modify the `sizes` attribute, or provide your own \`ngSrcset\` value directly.');
+ });
+ it('should throw if a complex `sizes` is used with srcset', () => {
+ setupTestingModule();
+
+ const template =
+ '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive has detected that `sizes` was set to a string including pixel values. ' +
+ 'For automatic `srcset` generation, `sizes` must only include responsive values, such as `sizes="50vw"` or ' +
+ '`sizes="(min-width: 768px) 50vw, 100vw"`. To fix this, modify the `sizes` attribute, or provide your own \`ngSrcset\` value directly.');
+ });
+ it('should not throw if a complex `sizes` is used with ngSrcset', () => {
+ setupTestingModule();
+
+ const template =
+ '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ }).not.toThrow();
+ });
+ });
+
+ describe('automatic srcset generation', () => {
+ const imageLoader = (config: ImageLoaderConfig) => {
+ const width = config.width ? `?w=${config.width}` : ``;
+ return `${IMG_BASE_URL}/${config.src}${width}`;
+ };
+
+ it('should not generate a srcset if the default noop loader is used', () => {
+ setupTestingModule({noLoader: true});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('srcset')).toBeNull();
+ });
+
+ it('should add a responsive srcset to the img element if sizes attribute exists', () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('srcset'))
+ .toBe(`${IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=750 750w, ${
+ IMG_BASE_URL}/img?w=828 828w, ${IMG_BASE_URL}/img?w=1080 1080w, ${
+ IMG_BASE_URL}/img?w=1200 1200w, ${IMG_BASE_URL}/img?w=1920 1920w, ${
+ IMG_BASE_URL}/img?w=2048 2048w, ${IMG_BASE_URL}/img?w=3840 3840w`);
+ });
+
+ it('should use the long responsive srcset if sizes attribute exists and is less than 100vw',
+ () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('srcset'))
+ .toBe(`${IMG_BASE_URL}/img?w=16 16w, ${IMG_BASE_URL}/img?w=32 32w, ${
+ IMG_BASE_URL}/img?w=48 48w, ${IMG_BASE_URL}/img?w=64 64w, ${
+ IMG_BASE_URL}/img?w=96 96w, ${IMG_BASE_URL}/img?w=128 128w, ${
+ IMG_BASE_URL}/img?w=256 256w, ${IMG_BASE_URL}/img?w=384 384w, ${
+ IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=750 750w, ${
+ IMG_BASE_URL}/img?w=828 828w, ${IMG_BASE_URL}/img?w=1080 1080w, ${
+ IMG_BASE_URL}/img?w=1200 1200w, ${IMG_BASE_URL}/img?w=1920 1920w, ${
+ IMG_BASE_URL}/img?w=2048 2048w, ${IMG_BASE_URL}/img?w=3840 3840w`);
+ });
+
+ it('should add a fixed srcset to the img element if sizes attribute does not exist', () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('srcset'))
+ .toBe(`${IMG_BASE_URL}/img?w=100 1x, ${IMG_BASE_URL}/img?w=200 2x`);
+ });
+
+ it('should not add a fixed srcset to the img element if height is too large', () => {
+ setupTestingModule({imageLoader});
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('srcset')).toBeNull();
+ });
+
+ it('should not add a fixed srcset to the img element if width is too large', () => {
+ setupTestingModule({imageLoader});
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('srcset')).toBeNull();
+ });
+
+ it('should use a custom breakpoint set if one is provided', () => {
+ const imageConfig = {
+ breakpoints: [16, 32, 48, 64, 96, 128, 256, 384, 640, 1280, 3840],
+ };
+ setupTestingModule({imageLoader, imageConfig});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('srcset'))
+ .toBe(`${IMG_BASE_URL}/img?w=16 16w, ${IMG_BASE_URL}/img?w=32 32w, ${
+ IMG_BASE_URL}/img?w=48 48w, ${IMG_BASE_URL}/img?w=64 64w, ${
+ IMG_BASE_URL}/img?w=96 96w, ${IMG_BASE_URL}/img?w=128 128w, ${
+ IMG_BASE_URL}/img?w=256 256w, ${IMG_BASE_URL}/img?w=384 384w, ${
+ IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=1280 1280w, ${
+ IMG_BASE_URL}/img?w=3840 3840w`);
+ });
+
+ it('should sort custom breakpoint set', () => {
+ const imageConfig = {
+ breakpoints: [48, 16, 3840, 640, 1280],
+ };
+ setupTestingModule({imageLoader, imageConfig});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('srcset'))
+ .toBe(`${IMG_BASE_URL}/img?w=16 16w, ${IMG_BASE_URL}/img?w=48 48w, ${
+ IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=1280 1280w, ${
+ IMG_BASE_URL}/img?w=3840 3840w`);
+ });
+
+ it('should disable automatic srcset generation if "disableOptimizedSrcset" attribute is set',
+ () => {
+ setupTestingModule({imageLoader});
+
+ const template = `
+
+ `;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('srcset')).toBeNull();
+ });
+ });
+ });
+});
+
+// Helpers
+
+// Base URL that can be used in tests to construct absolute URLs.
+const IMG_BASE_URL = {
+ // Use `toString` here to delay referencing the `window` until the tests
+ // execution starts, otherwise the `window` might not be defined in Node env.
+ toString: () => window.location.origin
+};
+
+const ANGULAR_LOGO_BASE64 =
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg==';
+
+@Component({selector: 'test-cmp', template: ''})
+class TestComponent {
+ width = 100;
+ height = 50;
+ ngSrc = 'img.png';
+ priority = false;
+}
+
+function setupTestingModule(config?: {
+ imageConfig?: ImageConfig,
+ imageLoader?: ImageLoader,
+ noLoader?: boolean,
+ extraProviders?: Provider[],
+ component?: Type
+}) {
+ const defaultLoader = (config: ImageLoaderConfig) => {
+ const isAbsolute = /^https?:\/\//.test(config.src);
+ return isAbsolute ? config.src : window.location.origin + '/' + config.src;
+ };
+ const loader = config?.imageLoader || defaultLoader;
+ const extraProviders = config?.extraProviders || [];
+ const providers: Provider[] = [
+ {provide: DOCUMENT, useValue: window.document},
+ ...(config?.noLoader ? [] : [{provide: IMAGE_LOADER, useValue: loader}]), ...extraProviders
+ ];
+ if (config?.imageConfig) {
+ providers.push({provide: IMAGE_CONFIG, useValue: config.imageConfig});
+ }
+
+ TestBed.configureTestingModule({
+ declarations: [config?.component ?? TestComponent],
+ // Note: the `NgOptimizedImage` directive is experimental and is not a part of the
+ // `CommonModule` yet, so it's imported separately.
+ imports: [CommonModule, NgOptimizedImageModule],
+ providers
+ });
+}
+
+// Same as above but explicitly doesn't provide a custom loader,
+// so the noopImageLoader should be used.
+function setUpModuleNoLoader() {
+ TestBed.configureTestingModule({
+ declarations: [TestComponent],
+ imports: [CommonModule, NgOptimizedImageModule],
+ providers: [{provide: DOCUMENT, useValue: window.document}]
+ });
+}
+
+function createTestComponent(template: string): ComponentFixture {
+ return TestBed.overrideComponent(TestComponent, {set: {template: template}})
+ .createComponent(TestComponent);
+}
diff --git a/packages/common/test/image_loaders/image_loader_spec.ts b/packages/common/test/image_loaders/image_loader_spec.ts
new file mode 100644
index 00000000000..8ab74449908
--- /dev/null
+++ b/packages/common/test/image_loaders/image_loader_spec.ts
@@ -0,0 +1,217 @@
+/**
+ * @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 {ImageLoader, ImageLoaderConfig} from '@angular/common/src/directives/ng_optimized_image';
+import {createCloudflareUrl} from '@angular/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader';
+import {createCloudinaryUrl, provideCloudinaryLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader';
+import {createImageLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/image_loader';
+import {createImagekitUrl, provideImageKitLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader';
+import {createImgixUrl} from '@angular/common/src/directives/ng_optimized_image/image_loaders/imgix_loader';
+import {isValidPath} from '@angular/common/src/directives/ng_optimized_image/url';
+
+const absoluteUrlError = (src: string, path: string) =>
+ `NG02959: Image loader has detected a \`\` tag with an invalid ` +
+ `\`ngSrc\` attribute: ${src}. This image loader expects \`ngSrc\` ` +
+ `to be a relative URL - however the provided value is an absolute URL. ` +
+ `To fix this, provide \`ngSrc\` as a path relative to the base URL ` +
+ `configured for this loader (\`${path}\`).`;
+
+const invalidPathError = (path: string, formats: string) =>
+ `NG02959: Image loader has detected an invalid path (\`${path}\`). ` +
+ `To fix this, supply a path using one of the following formats: ${formats}`;
+
+describe('Built-in image directive loaders', () => {
+ describe('Imgix loader', () => {
+ function createImgixLoader(path: string) {
+ const stubLoader = (createImageLoader(createImgixUrl)(path)[0] as {
+ provide: any,
+ useValue: (config: ImageLoaderConfig) => string
+ }).useValue;
+ return (config: ImageLoaderConfig) => stubLoader(config);
+ }
+
+ it('should construct an image loader with the given path', () => {
+ const path = 'https://somesite.imgix.net';
+ const loader = createImgixLoader(path);
+ const config = {src: 'img.png'};
+ expect(loader(config)).toBe(`${path}/img.png?auto=format`);
+ });
+
+ it('should handle a trailing forward slash on the path', () => {
+ const path = 'https://somesite.imgix.net';
+ const loader = createImgixLoader(`${path}/`);
+ const config = {src: 'img.png'};
+ expect(loader(config)).toBe(`${path}/img.png?auto=format`);
+ });
+
+ it('should handle a leading forward slash on the image src', () => {
+ const path = 'https://somesite.imgix.net';
+ const loader = createImgixLoader(path);
+ const config = {src: '/img.png'};
+ expect(loader(config)).toBe(`${path}/img.png?auto=format`);
+ });
+
+ it('should construct an image loader with the given path', () => {
+ const path = 'https://somesite.imgix.net';
+ const loader = createImgixLoader(path);
+ const config = {src: 'img.png', width: 100};
+ expect(loader(config)).toBe(`${path}/img.png?auto=format&w=100`);
+ });
+
+ it('should throw if an absolute URL is provided as a loader input', () => {
+ const path = 'https://somesite.imgix.net';
+ const src = 'https://angular.io/img.png';
+ const loader = createImgixLoader(path);
+ expect(() => loader({src})).toThrowError(absoluteUrlError(src, path));
+ });
+ });
+
+ describe('Cloudinary loader', () => {
+ function createCloudinaryLoader(path: string): ImageLoader {
+ const stubLoader = (createImageLoader(createCloudinaryUrl)(path)[0] as {
+ provide: any,
+ useValue: (config: ImageLoaderConfig) => string
+ }).useValue;
+ return (config: ImageLoaderConfig) => stubLoader(config);
+ }
+
+ it('should construct an image loader with the given path', () => {
+ const path = 'https://res.cloudinary.com/mysite';
+ const loader = createCloudinaryLoader(path);
+ expect(loader({src: 'img.png'})).toBe(`${path}/image/upload/f_auto,q_auto/img.png`);
+ expect(loader({
+ src: 'marketing/img-2.png'
+ })).toBe(`${path}/image/upload/f_auto,q_auto/marketing/img-2.png`);
+ });
+
+ describe('input validation', () => {
+ it('should throw if an absolute URL is provided as a loader input', () => {
+ const path = 'https://res.cloudinary.com/mysite';
+ const src = 'https://angular.io/img.png';
+ const loader = createCloudinaryLoader(path);
+ expect(() => loader({src})).toThrowError(absoluteUrlError(src, path));
+ });
+
+ it('should throw if the path is invalid', () => {
+ expect(() => provideCloudinaryLoader('my-cloudinary-account'))
+ .toThrowError(invalidPathError(
+ 'my-cloudinary-account',
+ 'https://res.cloudinary.com/mysite or https://mysite.cloudinary.com ' +
+ 'or https://subdomain.mysite.com'));
+ });
+
+ it('should handle a trailing forward slash on the path', () => {
+ const path = 'https://res.cloudinary.com/mysite';
+ const loader = createCloudinaryLoader(`${path}/`);
+ expect(loader({src: 'img.png'})).toBe(`${path}/image/upload/f_auto,q_auto/img.png`);
+ });
+
+ it('should handle a leading forward slash on the image src', () => {
+ const path = 'https://res.cloudinary.com/mysite';
+ const loader = createCloudinaryLoader(path);
+ expect(loader({src: '/img.png'})).toBe(`${path}/image/upload/f_auto,q_auto/img.png`);
+ });
+ });
+ });
+
+ describe('ImageKit loader', () => {
+ function createImageKitLoader(path: string): ImageLoader {
+ const stubLoader = (createImageLoader(createImagekitUrl)(path)[0] as {
+ provide: any,
+ useValue: (config: ImageLoaderConfig) => string
+ }).useValue;
+ return (config: ImageLoaderConfig) => stubLoader(config);
+ }
+
+ it('should construct an image loader with the given path', () => {
+ const path = 'https://ik.imageengine.io/imagetest';
+ const loader = createImageKitLoader(path);
+ expect(loader({src: 'img.png'})).toBe(`${path}/tr:q-auto/img.png`);
+ expect(loader({src: 'marketing/img-2.png'})).toBe(`${path}/tr:q-auto/marketing/img-2.png`);
+ });
+
+ describe('input validation', () => {
+ it('should throw if an absolute URL is provided as a loader input', () => {
+ const path = 'https://ik.imageengine.io/imagetest';
+ const src = 'https://angular.io/img.png';
+ const loader = createImageKitLoader(path);
+ expect(() => loader({src})).toThrowError(absoluteUrlError(src, path));
+ });
+
+ it('should throw if the path is invalid', () => {
+ expect(() => provideImageKitLoader('my-imagekit-account'))
+ .toThrowError(invalidPathError(
+ 'my-imagekit-account',
+ 'https://ik.imagekit.io/mysite or https://subdomain.mysite.com'));
+ });
+
+ it('should handle a trailing forward slash on the path', () => {
+ const path = 'https://ik.imageengine.io/imagetest';
+ const loader = createImageKitLoader(`${path}/`);
+ expect(loader({src: 'img.png'})).toBe(`${path}/tr:q-auto/img.png`);
+ });
+
+ it('should handle a leading forward slash on the image src', () => {
+ const path = 'https://ik.imageengine.io/imagetest';
+ const loader = createImageKitLoader(path);
+ expect(loader({src: '/img.png'})).toBe(`${path}/tr:q-auto/img.png`);
+ });
+ });
+ });
+
+ describe('Cloudflare loader', () => {
+ function createCloudflareLoader(path: string): ImageLoader {
+ const stubLoader = (createImageLoader(createCloudflareUrl)(path)[0] as {
+ provide: any,
+ useValue: (config: ImageLoaderConfig) => string
+ }).useValue;
+ return (config: ImageLoaderConfig) => stubLoader(config);
+ }
+ it('should construct an image loader with the given path', () => {
+ const loader = createCloudflareLoader('https://mysite.com');
+ let config = {src: 'img.png'};
+ expect(loader(config)).toBe('https://mysite.com/cdn-cgi/image/format=auto/img.png');
+ });
+ it('should construct an image loader with the given path', () => {
+ const loader = createCloudflareLoader('https://mysite.com');
+ const config = {src: 'img.png', width: 100};
+ expect(loader(config)).toBe('https://mysite.com/cdn-cgi/image/format=auto,width=100/img.png');
+ });
+
+ it('should throw if an absolute URL is provided as a loader input', () => {
+ const path = 'https://mysite.com';
+ const src = 'https://angular.io/img.png';
+ const loader = createCloudflareLoader(path);
+ expect(() => loader({src})).toThrowError(absoluteUrlError(src, path));
+ });
+ });
+
+ describe('loader utils', () => {
+ it('should identify valid paths', () => {
+ expect(isValidPath('https://cdn.imageprovider.com/image-test')).toBe(true);
+ expect(isValidPath('https://cdn.imageprovider.com')).toBe(true);
+ expect(isValidPath('https://imageprovider.com')).toBe(true);
+ });
+
+ it('should reject empty paths', () => {
+ expect(isValidPath('')).toBe(false);
+ });
+
+ it('should reject path if it is not a URL', () => {
+ expect(isValidPath('myaccount')).toBe(false);
+ });
+
+ it('should reject path if it does not include a protocol', () => {
+ expect(isValidPath('myaccount.imageprovider.com')).toBe(false);
+ });
+
+ it('should reject path if is malformed', () => {
+ expect(isValidPath('somepa\th.imageprovider.com? few')).toBe(false);
+ });
+ });
+});
diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts
index 52500949b64..d4d2ca105c5 100644
--- a/packages/core/src/core_private_export.ts
+++ b/packages/core/src/core_private_export.ts
@@ -16,7 +16,7 @@ export {getDebugNodeR2 as ɵgetDebugNodeR2} from './debug/debug_node';
export {setCurrentInjector as ɵsetCurrentInjector} from './di/injector_compatibility';
export {getInjectableDef as ɵgetInjectableDef, ɵɵInjectableDeclaration, ɵɵInjectorDef} from './di/interface/defs';
export {INJECTOR_SCOPE as ɵINJECTOR_SCOPE} from './di/scope';
-export {RuntimeError as ɵRuntimeError} from './errors';
+export {formatRuntimeError as ɵformatRuntimeError, RuntimeError as ɵRuntimeError} from './errors';
export {CurrencyIndex as ɵCurrencyIndex, ExtraLocaleDataIndex as ɵExtraLocaleDataIndex, findLocaleData as ɵfindLocaleData, getLocaleCurrencyCode as ɵgetLocaleCurrencyCode, getLocalePluralCase as ɵgetLocalePluralCase, LocaleDataIndex as ɵLocaleDataIndex, registerLocaleData as ɵregisterLocaleData, unregisterAllLocaleData as ɵunregisterLocaleData} from './i18n/locale_data_api';
export {DEFAULT_LOCALE_ID as ɵDEFAULT_LOCALE_ID} from './i18n/localization';
export {ComponentFactory as ɵComponentFactory} from './linker/component_factory';
diff --git a/packages/core/test/bundling/image-directive/BUILD.bazel b/packages/core/test/bundling/image-directive/BUILD.bazel
new file mode 100644
index 00000000000..7787657e27d
--- /dev/null
+++ b/packages/core/test/bundling/image-directive/BUILD.bazel
@@ -0,0 +1,91 @@
+load("//tools:defaults.bzl", "app_bundle", "ng_module", "protractor_web_test_suite", "ts_devserver", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ng_module(
+ name = "image-directive",
+ srcs = [
+ "e2e/basic/basic.ts",
+ "e2e/fill-mode/fill-mode.ts",
+ "e2e/image-distortion/image-distortion.ts",
+ "e2e/lcp-check/lcp-check.ts",
+ "e2e/oversized-image/oversized-image.ts",
+ "e2e/preconnect-check/preconnect-check.ts",
+ "index.ts",
+ "playground.ts",
+ ],
+ deps = [
+ "//packages/common",
+ "//packages/core",
+ "//packages/platform-browser",
+ "//packages/router",
+ ],
+)
+
+app_bundle(
+ name = "bundle",
+ entry_point = ":index.ts",
+ deps = [
+ ":image-directive",
+ "//packages/common",
+ "//packages/core",
+ "//packages/platform-browser",
+ "//packages/router",
+ "@npm//rxjs",
+ ],
+)
+
+genrule(
+ name = "tslib",
+ srcs = [
+ "@npm//:node_modules/tslib/tslib.js",
+ ],
+ outs = [
+ "tslib.js",
+ ],
+ cmd = "cp $< $@",
+)
+
+ts_devserver(
+ name = "devserver",
+ bootstrap = ["//packages/zone.js/bundles:zone.umd.js"],
+ entry_module = "@angular/core/test/bundling/image-directive",
+ port = 4200,
+ scripts = [
+ "//tools/rxjs:rxjs_umd_modules",
+ ],
+ serving_path = "/bundle.min.js",
+ static_files = [
+ "index.html",
+ ":tslib",
+ "e2e/a.png",
+ "e2e/b.png",
+ "e2e/logo-500w.jpg",
+ "e2e/logo-1500w.jpg",
+ ],
+ deps = [":image-directive"],
+)
+
+ts_library(
+ name = "img_dir_e2e_tests_lib",
+ testonly = True,
+ srcs = ["e2e/browser-logs-util.ts"] + glob([
+ "e2e/**/*.e2e-spec.ts",
+ ]),
+ tsconfig = ":e2e/tsconfig-e2e.json",
+ deps = [
+ "//packages/private/testing",
+ "@npm//@types/selenium-webdriver",
+ "@npm//protractor",
+ ],
+)
+
+protractor_web_test_suite(
+ name = "protractor_tests",
+ on_prepare = ":e2e/start-server.js",
+ server = ":devserver",
+ deps = [
+ ":img_dir_e2e_tests_lib",
+ "@npm//selenium-webdriver",
+ ],
+)
diff --git a/packages/core/test/bundling/image-directive/README.md b/packages/core/test/bundling/image-directive/README.md
new file mode 100644
index 00000000000..bcdcf848e78
--- /dev/null
+++ b/packages/core/test/bundling/image-directive/README.md
@@ -0,0 +1,13 @@
+* NgOptimizedImage directive testing
+
+This folder contains a simple application that can be used as a playground for the `NgOptimizedImage` directive testing. You can run the following command to start the dev server:
+
+```
+yarn ibazel run packages/core/test/bundling/image-directive:devserver
+```
+
+There is also a set of e2e tests (powered by Protractor), which can be invoked by running:
+
+```
+yarn bazel test packages/core/test/bundling/image-directive:protractor_tests
+```
diff --git a/packages/core/test/bundling/image-directive/e2e/a.png b/packages/core/test/bundling/image-directive/e2e/a.png
new file mode 100644
index 00000000000..c5102939182
Binary files /dev/null and b/packages/core/test/bundling/image-directive/e2e/a.png differ
diff --git a/packages/core/test/bundling/image-directive/e2e/b.png b/packages/core/test/bundling/image-directive/e2e/b.png
new file mode 100644
index 00000000000..c5102939182
Binary files /dev/null and b/packages/core/test/bundling/image-directive/e2e/b.png differ
diff --git a/packages/core/test/bundling/image-directive/e2e/basic/basic.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/basic/basic.e2e-spec.ts
new file mode 100644
index 00000000000..3dbd7652933
--- /dev/null
+++ b/packages/core/test/bundling/image-directive/e2e/basic/basic.e2e-spec.ts
@@ -0,0 +1,29 @@
+/**
+ * @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 {browser, by, element} from 'protractor';
+import {logging} from 'selenium-webdriver';
+
+import {collectBrowserLogs} from '../browser-logs-util';
+
+describe('NgOptimizedImage directive', () => {
+ it('should render an image with an updated `src`', async () => {
+ await browser.get('/e2e/basic');
+ const imgs = element.all(by.css('img'));
+ const src = await imgs.get(0).getAttribute('src');
+ expect(/angular\.svg/.test(src)).toBe(true);
+
+ // Since there are no preconnect tags on a page,
+ // we expect a log in a console that mentions that.
+ const logs = await collectBrowserLogs(logging.Level.WARNING);
+ expect(logs.length).toEqual(1);
+
+ // Verify that the error code and a raw image src are present.
+ expect(logs[0].message).toMatch(/NG02956.*?a\.png/);
+ });
+});
diff --git a/packages/core/test/bundling/image-directive/e2e/basic/basic.ts b/packages/core/test/bundling/image-directive/e2e/basic/basic.ts
new file mode 100644
index 00000000000..470a8223190
--- /dev/null
+++ b/packages/core/test/bundling/image-directive/e2e/basic/basic.ts
@@ -0,0 +1,35 @@
+/**
+ * @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_LOADER, NgOptimizedImageModule} from '@angular/common';
+import {Component, NgModule} from '@angular/core';
+import {RouterModule} from '@angular/router';
+
+@Component({
+ selector: 'basic',
+ template: ``,
+})
+export class BasicComponent {
+}
+
+@NgModule({
+ declarations: [BasicComponent],
+ imports: [
+ NgOptimizedImageModule,
+ RouterModule.forChild([{
+ path: '',
+ component: BasicComponent,
+ }]),
+ ],
+ providers: [{
+ provide: IMAGE_LOADER,
+ useValue: () => 'https://angular.io/assets/images/logos/angular/angular.svg'
+ }]
+})
+export class BasicModule {
+}
diff --git a/packages/core/test/bundling/image-directive/e2e/browser-logs-util.ts b/packages/core/test/bundling/image-directive/e2e/browser-logs-util.ts
new file mode 100644
index 00000000000..0c730f28531
--- /dev/null
+++ b/packages/core/test/bundling/image-directive/e2e/browser-logs-util.ts
@@ -0,0 +1,36 @@
+/**
+ * @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
+ */
+
+/* tslint:disable:no-console */
+import {browser} from 'protractor';
+import {logging} from 'selenium-webdriver';
+
+export async function collectBrowserLogs(
+ loggingLevel: logging.Level,
+ collectMoreSevereErrors: boolean = false): Promise {
+ const browserLog = await browser.manage().logs().get('browser');
+ const collectedLogs: logging.Entry[] = [];
+
+ browserLog.forEach(logEntry => {
+ const msg = logEntry.message;
+
+ console.log('>> ' + msg, logEntry);
+
+ if ((!collectMoreSevereErrors && logEntry.level.value === loggingLevel.value) ||
+ (collectMoreSevereErrors && logEntry.level.value >= loggingLevel.value)) {
+ collectedLogs.push(logEntry);
+ }
+ });
+ return collectedLogs;
+}
+
+export async function verifyNoBrowserErrors() {
+ const logs =
+ await collectBrowserLogs(logging.Level.INFO, true /* collect more severe errors too */);
+ expect(logs).toEqual([]);
+}
diff --git a/packages/core/test/bundling/image-directive/e2e/fill-mode/fill-mode.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/fill-mode/fill-mode.e2e-spec.ts
new file mode 100644
index 00000000000..ed5f7f5306d
--- /dev/null
+++ b/packages/core/test/bundling/image-directive/e2e/fill-mode/fill-mode.e2e-spec.ts
@@ -0,0 +1,45 @@
+/**
+ * @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
+ */
+
+/* tslint:disable:no-console */
+import {browser} from 'protractor';
+import {logging} from 'selenium-webdriver';
+
+import {collectBrowserLogs} from '../browser-logs-util';
+
+describe('NgOptimizedImage directive', () => {
+ it('should not warn when an image in the fill mode is rendered correctly', async () => {
+ await browser.get('/e2e/fill-mode-passing');
+ const logs = await collectBrowserLogs(logging.Level.WARNING);
+ expect(logs.length).toEqual(0);
+ });
+
+ it('should warn if an image in the fill mode has zero height after rendering', async () => {
+ await browser.get('/e2e/fill-mode-failing');
+ const logs = await collectBrowserLogs(logging.Level.WARNING);
+
+ expect(logs.length).toEqual(1);
+ // Image loading order is not guaranteed, so all logs, rather than single entry
+ // needs to be checked in order to test whether a given error message is present.
+ const expectErrorMessageInLogs = (logs: logging.Entry[], message: string) => {
+ expect(logs.some((log) => {
+ return log.message.includes(message);
+ })).toBeTruthy();
+ };
+
+ expectErrorMessageInLogs(
+ logs,
+ 'NG02952: The NgOptimizedImage directive (activated on an \\u003Cimg> element ' +
+ 'with the `ngSrc=\\"/e2e/logo-500w.jpg\\"`) has detected that the height ' +
+ 'of the fill-mode image is zero. This is likely because the containing element ' +
+ 'does not have the CSS \'position\' property set to one of the following: ' +
+ '\\"relative\\", \\"fixed\\", or \\"absolute\\". To fix this problem, ' +
+ 'make sure the container element has the CSS \'position\' ' +
+ 'property defined and the height of the element is not zero.');
+ });
+});
diff --git a/packages/core/test/bundling/image-directive/e2e/fill-mode/fill-mode.ts b/packages/core/test/bundling/image-directive/e2e/fill-mode/fill-mode.ts
new file mode 100644
index 00000000000..6ec5fcdb0d1
--- /dev/null
+++ b/packages/core/test/bundling/image-directive/e2e/fill-mode/fill-mode.ts
@@ -0,0 +1,60 @@
+/**
+ * @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 {NgOptimizedImageModule} from '@angular/common';
+import {Component, NgModule} from '@angular/core';
+import {RouterModule} from '@angular/router';
+
+@Component({
+ selector: 'fill-mode-passing',
+ template: `
+
+