diff --git a/.pullapprove.yml b/.pullapprove.yml index bf8396a1ace..1803e574237 100644 --- a/.pullapprove.yml +++ b/.pullapprove.yml @@ -340,6 +340,7 @@ groups: 'aio/content/guide/dependency-injection-navtree.md', 'aio/content/guide/dependency-injection-providers.md', 'aio/content/guide/lightweight-injection-tokens.md', + 'aio/content/guide/image-directive.md', 'aio/content/guide/displaying-data.md', 'aio/content/examples/displaying-data/**', 'aio/content/images/guide/displaying-data/**', diff --git a/aio/content/guide/image-directive.md b/aio/content/guide/image-directive.md new file mode 100644 index 00000000000..b4ee42022d7 --- /dev/null +++ b/aio/content/guide/image-directive.md @@ -0,0 +1,323 @@ +
+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: ` + +
+ +
+ `, +}) +export class FillModePassingComponent { +} + +@NgModule({ + declarations: [FillModePassingComponent], + imports: [ + NgOptimizedImageModule, + RouterModule.forChild([{ + path: '', + component: FillModePassingComponent, + }]), + ], +}) +export class FillModePassingModule { +} + +@Component({ + selector: 'fill-mode-failing', + template: ` +
+ +
+ `, +}) +export class FillModeFailingComponent { +} + +@NgModule({ + declarations: [FillModeFailingComponent], + imports: [ + NgOptimizedImageModule, + RouterModule.forChild([{ + path: '', + component: FillModeFailingComponent, + }]), + ], +}) +export class FillModeFailingModule { +} diff --git a/packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.e2e-spec.ts new file mode 100644 index 00000000000..a057ee9588f --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.e2e-spec.ts @@ -0,0 +1,89 @@ +/** + * @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 if there is no image distortion', async () => { + await browser.get('/e2e/image-distortion-passing'); + const logs = await collectBrowserLogs(logging.Level.WARNING); + expect(logs.length).toEqual(0); + }); + + it('should warn if there is image distortion', async () => { + await browser.get('/e2e/image-distortion-failing'); + const logs = await collectBrowserLogs(logging.Level.WARNING); + + expect(logs.length).toEqual(5); + // 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(); + }; + + // Images with incorrect width/height attributes + expectErrorMessageInLogs( + logs, + 'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' + + 'with the \`ngSrc=\\"/e2e/b.png\\"`) has detected that ' + + 'the aspect ratio of the image does not match the aspect ratio indicated by the width and height attributes. ' + + '\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' + + '\\nSupplied width and height attributes: 26w x 30h (aspect-ratio: 0.8666666666666667). ' + + '\\nTo fix this, update the width and height attributes.'); + + expectErrorMessageInLogs( + logs, + 'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' + + 'with the \`ngSrc=\\"/e2e/b.png\\"`) has detected that ' + + 'the aspect ratio of the image does not match the aspect ratio indicated by the width and height attributes. ' + + '\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' + + '\\nSupplied width and height attributes: 24w x 240h (aspect-ratio: 0.1). ' + + '\\nTo fix this, update the width and height attributes.'); + + // Images with incorrect styling + expectErrorMessageInLogs( + logs, + 'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' + + 'with the \`ngSrc=\\"/e2e/b.png\\"`) has detected that ' + + 'the aspect ratio of the rendered image does not match the image\'s intrinsic aspect ratio. ' + + '\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' + + '\\nRendered image size: 250w x 30h (aspect-ratio: 8.333333333333334). ' + + '\\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.'); + + expectErrorMessageInLogs( + logs, + 'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' + + 'with the \`ngSrc=\\"/e2e/b.png\\"`) has detected that ' + + 'the aspect ratio of the rendered image does not match the image\'s intrinsic aspect ratio. ' + + '\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' + + '\\nRendered image size: 30w x 250h (aspect-ratio: 0.12). ' + + '\\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.'); + + // Image with incorrect width/height attributes AND incorrect styling + // This only generate only one error to ensure that users first fix the width and height + // attributes. + expectErrorMessageInLogs( + logs, + 'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' + + 'with the \`ngSrc=\\"/e2e/b.png\\"`) has detected that ' + + 'the aspect ratio of the image does not match the aspect ratio indicated by the width and height attributes. ' + + '\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' + + '\\nSupplied width and height attributes: 150w x 250h (aspect-ratio: 0.6). ' + + '\\nTo fix this, update the width and height attributes.'); + }); +}); diff --git a/packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.ts b/packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.ts new file mode 100644 index 00000000000..abd10b4c6da --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.ts @@ -0,0 +1,105 @@ +/** + * @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: 'image-distortion-passing', + template: ` + + + +
+ + +
+ + + + +
+ + + + + + + + + +
+ + + +
+ + +
+ + + + +
+ `, +}) +export class ImageDistortionPassingComponent { +} +@NgModule({ + declarations: [ImageDistortionPassingComponent], + imports: [ + NgOptimizedImageModule, + RouterModule.forChild([{ + path: '', + component: ImageDistortionPassingComponent, + }]), + ], +}) +export class ImageDistortionPassingModule { +} + +@Component({ + selector: 'image-distortion-failing', + template: ` + + + +
+ + + + + + + + +
+ +
+ + + + +
+ `, +}) +export class ImageDistortionFailingComponent { +} +@NgModule({ + declarations: [ImageDistortionFailingComponent], + imports: [ + NgOptimizedImageModule, + RouterModule.forChild([{ + path: '', + component: ImageDistortionFailingComponent, + }]), + ], +}) +export class ImageDistortionFailingModule { +} diff --git a/packages/core/test/bundling/image-directive/e2e/lcp-check/lcp-check.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/lcp-check/lcp-check.e2e-spec.ts new file mode 100644 index 00000000000..22dba4fdbb7 --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/lcp-check/lcp-check.e2e-spec.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, by, element} from 'protractor'; +import {logging} from 'selenium-webdriver'; + +import {collectBrowserLogs} from '../browser-logs-util'; + +describe('NgOptimizedImage directive', () => { + it('should log a warning when a `priority` is missing on an LCP image', async () => { + await browser.get('/e2e/lcp-check'); + + // Verify that both images were rendered. + const imgs = element.all(by.css('img')); + let srcB = await imgs.get(0).getAttribute('src'); + expect(srcB.endsWith('b.png')).toBe(true); + const srcA = await imgs.get(1).getAttribute('src'); + expect(srcA.endsWith('a.png')).toBe(true); + // The `b.png` image is used twice in a template. + srcB = await imgs.get(2).getAttribute('src'); + expect(srcB.endsWith('b.png')).toBe(true); + + // Make sure that only one warning is in the console for image `a.png`, + // since the `b.png` should be below the fold and not treated as an LCP element. + const logs = await collectBrowserLogs(logging.Level.WARNING); + expect(logs.length).toEqual(1); + // Verify that the error code and the image src are present in the error message. + expect(logs[0].message).toMatch(/NG02955.*?a\.png/); + }); +}); diff --git a/packages/core/test/bundling/image-directive/e2e/lcp-check/lcp-check.ts b/packages/core/test/bundling/image-directive/e2e/lcp-check/lcp-check.ts new file mode 100644 index 00000000000..74b050b51e1 --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/lcp-check/lcp-check.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 {NgOptimizedImageModule} from '@angular/common'; +import {Component, NgModule} from '@angular/core'; +import {RouterModule} from '@angular/router'; + +@Component({ + selector: 'lcp-check', + template: ` + + + +
+ + + + +
+ + + + `, +}) +export class LcpCheckComponent { +} + +@NgModule({ + declarations: [LcpCheckComponent], + imports: [ + NgOptimizedImageModule, + RouterModule.forChild([{ + path: '', + component: LcpCheckComponent, + }]), + ], +}) +export class LcpCheckModule { +} diff --git a/packages/core/test/bundling/image-directive/e2e/logo-1500w.jpg b/packages/core/test/bundling/image-directive/e2e/logo-1500w.jpg new file mode 100644 index 00000000000..7dc15e977b6 Binary files /dev/null and b/packages/core/test/bundling/image-directive/e2e/logo-1500w.jpg differ diff --git a/packages/core/test/bundling/image-directive/e2e/logo-500w.jpg b/packages/core/test/bundling/image-directive/e2e/logo-500w.jpg new file mode 100644 index 00000000000..18c0282d154 Binary files /dev/null and b/packages/core/test/bundling/image-directive/e2e/logo-500w.jpg differ diff --git a/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts new file mode 100644 index 00000000000..03481496463 --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts @@ -0,0 +1,31 @@ +/** + * @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, by, element, ExpectedConditions} from 'protractor'; +import {logging} from 'selenium-webdriver'; + +import {collectBrowserLogs} from '../browser-logs-util'; + +describe('NgOptimizedImage directive', () => { + it('should not warn if there is no oversized image', async () => { + await browser.get('/e2e/oversized-image-passing'); + const logs = await collectBrowserLogs(logging.Level.WARNING); + expect(logs.length).toEqual(0); + }); + + it('should warn if rendered image size is much smaller than intrinsic size', async () => { + await browser.get('/e2e/oversized-image-failing'); + const logs = await collectBrowserLogs(logging.Level.WARNING); + + expect(logs.length).toEqual(1); + + const expectedMessageRegex = /the intrinsic image is significantly larger than necessary\./; + expect(expectedMessageRegex.test(logs[0].message)).toBeTruthy(); + }); +}); diff --git a/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.ts b/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.ts new file mode 100644 index 00000000000..4a77abdd8a8 --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.ts @@ -0,0 +1,73 @@ +/** + * @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, ImageLoaderConfig, NgOptimizedImageModule} from '@angular/common'; +import {Component, NgModule} from '@angular/core'; +import {RouterModule} from '@angular/router'; + +const imageLoader = { + provide: IMAGE_LOADER, + useFactory: () => (config: ImageLoaderConfig) => config.src +}; + +@Component({ + selector: 'oversized-image-passing', + providers: [imageLoader], + template: ` + +
+ +
+ +
+ +
+ `, +}) +export class OversizedImagePassingComponent { +} +@NgModule({ + declarations: [OversizedImagePassingComponent], + imports: [ + NgOptimizedImageModule, + RouterModule.forChild([{ + path: '', + component: OversizedImagePassingComponent, + }]), + ], +}) +export class OversizedImagePassingModule { +} + + +@Component({ + selector: 'oversized-image-failing', + providers: [imageLoader], + template: ` + +
+ +
+ `, +}) +export class OversizedImageFailingComponent { +} + +@NgModule({ + declarations: [OversizedImageFailingComponent], + imports: [ + NgOptimizedImageModule, + RouterModule.forChild([{ + path: '', + component: OversizedImageFailingComponent, + }]), + ], +}) +export class OversizedImageFailingModule { +} diff --git a/packages/core/test/bundling/image-directive/e2e/preconnect-check/preconnect-check.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/preconnect-check/preconnect-check.e2e-spec.ts new file mode 100644 index 00000000000..4e17e3ef14f --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/preconnect-check/preconnect-check.e2e-spec.ts @@ -0,0 +1,51 @@ +/** + * @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, by, element, ElementHelper} from 'protractor'; +import {logging} from 'selenium-webdriver'; + +import {collectBrowserLogs} from '../browser-logs-util'; + +// Verifies that both images used in a component were rendered. +async function verifyImagesPresent(element: ElementHelper) { + const imgs = element.all(by.css('img')); + const srcA = await imgs.get(0).getAttribute('src'); + expect(srcA.endsWith('a.png')).toBe(true); + const srcB = await imgs.get(1).getAttribute('src'); + expect(srcB.endsWith('b.png')).toBe(true); +} + +describe('NgOptimizedImage directive', () => { + it('should log a warning when there is no preconnect for priority images', async () => { + await browser.get('/e2e/preconnect-check'); + + await verifyImagesPresent(element); + + // Make sure that only one warning is in the console for both images, + // because they both have the same base URL (which is used to look for + // corresponding `` tags). + const logs = await collectBrowserLogs(logging.Level.WARNING); + expect(logs.length).toEqual(1); + + // Verify that the error code and a raw image src are present in the + // error message. + expect(logs[0].message).toMatch(/NG02956.*?a\.png/); + }); + + it('should not produce any warnings in the console when a preconnect tag is present', + async () => { + await browser.get('/e2e/preconnect-check?preconnect'); + + await verifyImagesPresent(element); + + // Make sure there are no browser logs. + const logs = await collectBrowserLogs(logging.Level.WARNING); + expect(logs.length).toEqual(0); + }); +}); diff --git a/packages/core/test/bundling/image-directive/e2e/preconnect-check/preconnect-check.ts b/packages/core/test/bundling/image-directive/e2e/preconnect-check/preconnect-check.ts new file mode 100644 index 00000000000..35a9f090c0e --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/preconnect-check/preconnect-check.ts @@ -0,0 +1,67 @@ +/** + * @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 {DOCUMENT, IMAGE_LOADER, NgOptimizedImageModule} from '@angular/common'; +import {Component, Inject, NgModule} from '@angular/core'; +import {RouterModule} from '@angular/router'; + +@Component({ + selector: 'preconnect-check', + template: ` + + + + `, +}) +export class PreconnectCheckComponent { + constructor(@Inject(DOCUMENT) private doc: Document) { + this.createRequestedLinkElements(); + } + + /** + * Setup an environment required for e2e testing: create the necessary `` elements in the + * `document.head`, so that the `NgOptimizedImage` logic can be verified in various scenarios. + */ + private createRequestedLinkElements() { + const win = this.doc.defaultView; + if (!win) return; + const url = new URL(win.location.href).searchParams; + const preconnect = url.get('preconnect'); + if (preconnect !== null) { + const link = this.createLinkElement('preconnect', 'https://angular.io'); + this.doc.head.appendChild(link); + } + } + + /** + * Helper method to create a simple `` element based on inputs. + */ + private createLinkElement(rel: string, href: string, as?: string): HTMLLinkElement { + const link = this.doc.createElement('link'); + link.rel = rel; + link.href = href; + if (as) link.as = as; + return link; + } +} +@NgModule({ + declarations: [PreconnectCheckComponent], + imports: [ + NgOptimizedImageModule, + RouterModule.forChild([{ + path: '', + component: PreconnectCheckComponent, + }]), + ], + providers: [{ + provide: IMAGE_LOADER, + useValue: (config: {src: string}) => `https://angular.io/assets/images/${config.src}` + }] +}) +export class PreconnectCheckModule { +} diff --git a/packages/core/test/bundling/image-directive/e2e/start-server.js b/packages/core/test/bundling/image-directive/e2e/start-server.js new file mode 100644 index 00000000000..706091a753c --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/start-server.js @@ -0,0 +1,21 @@ +/** + * @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 + */ + +const protractorUtils = require('@bazel/protractor/protractor-utils'); +const protractor = require('protractor'); + +/** + * Helper function to start up a server for testing using Protractor utils. + * Used as a part of the `protractor_web_test_suite` rule configuration. + */ +module.exports = async function(config) { + const {port} = await protractorUtils.runServer(config.workspace, config.server, '--port', []); + const serverUrl = `http://localhost:${port}`; + + protractor.browser.baseUrl = serverUrl; +}; diff --git a/packages/core/test/bundling/image-directive/e2e/tsconfig-e2e.json b/packages/core/test/bundling/image-directive/e2e/tsconfig-e2e.json new file mode 100644 index 00000000000..ed38112bb4a --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/tsconfig-e2e.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "lib": ["es2015"], + "types": ["node", "jasminewd2"] + } +} diff --git a/packages/core/test/bundling/image-directive/index.html b/packages/core/test/bundling/image-directive/index.html new file mode 100644 index 00000000000..5645bb830e3 --- /dev/null +++ b/packages/core/test/bundling/image-directive/index.html @@ -0,0 +1,33 @@ + + + + + Image Directive Example + + + + + + + + + + + diff --git a/packages/core/test/bundling/image-directive/index.ts b/packages/core/test/bundling/image-directive/index.ts new file mode 100644 index 00000000000..11936ebd1ce --- /dev/null +++ b/packages/core/test/bundling/image-directive/index.ts @@ -0,0 +1,80 @@ +/** + * @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 {Component, NgModule} from '@angular/core'; +import {BrowserModule, platformBrowser} from '@angular/platform-browser'; +import {RouterModule} from '@angular/router'; + +const ROUTES = [ + // Paths that contain components for test/demo purposes: + { + path: '', + loadChildren: () => import('./playground').then(mod => mod.PlaygroundModule), + }, + + // Paths below are used for e2e testing: + { + path: 'e2e/basic', + loadChildren: () => import('./e2e/basic/basic').then(mod => mod.BasicModule), + }, + { + path: 'e2e/lcp-check', + loadChildren: () => import('./e2e/lcp-check/lcp-check').then(mod => mod.LcpCheckModule), + }, + { + path: 'e2e/preconnect-check', + loadChildren: () => + import('./e2e/preconnect-check/preconnect-check').then(mod => mod.PreconnectCheckModule), + }, + { + path: 'e2e/image-distortion-passing', + loadChildren: () => import('./e2e/image-distortion/image-distortion') + .then(mod => mod.ImageDistortionPassingModule), + }, + { + path: 'e2e/image-distortion-failing', + loadChildren: () => import('./e2e/image-distortion/image-distortion') + .then(mod => mod.ImageDistortionFailingModule), + }, + { + path: 'e2e/oversized-image-passing', + loadChildren: () => import('./e2e/oversized-image/oversized-image') + .then(mod => mod.OversizedImagePassingModule), + }, + { + path: 'e2e/oversized-image-failing', + loadChildren: () => import('./e2e/oversized-image/oversized-image') + .then(mod => mod.OversizedImageFailingModule), + }, + { + path: 'e2e/fill-mode-passing', + loadChildren: () => import('./e2e/fill-mode/fill-mode').then(mod => mod.FillModePassingModule), + }, + { + path: 'e2e/fill-mode-failing', + loadChildren: () => import('./e2e/fill-mode/fill-mode').then(mod => mod.FillModeFailingModule), + }, +]; + +@Component({ + selector: 'app-root', + template: '', +}) +export class RootComponent { +} +@NgModule({ + declarations: [RootComponent], + imports: [BrowserModule, RouterModule.forRoot(ROUTES)], +}) +class RootModule { + ngDoBootstrap(app: any) { + app.bootstrap(RootComponent); + } +} + +(window as any).waitForApp = platformBrowser().bootstrapModule(RootModule); diff --git a/packages/core/test/bundling/image-directive/playground.ts b/packages/core/test/bundling/image-directive/playground.ts new file mode 100644 index 00000000000..ba5304cbedd --- /dev/null +++ b/packages/core/test/bundling/image-directive/playground.ts @@ -0,0 +1,63 @@ +/** + * @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, provideImgixLoader} from '@angular/common'; +import {Component, NgModule} from '@angular/core'; +import {RouterModule} from '@angular/router'; + +@Component({ + selector: 'basic', + styles: [` + h1 { + display: flex; + align-items: center; + } + + main { + border: 1px solid blue; + margin: 16px; + padding: 16px; + } + + .spacer { + height: 3000px; + } + + main img { + width: 100%; + height: auto; + } + `], + template: ` +

+ + Angular image app +

+
+
+ +
+ `, + providers: [provideImgixLoader('https://aurora-project.imgix.net')], +}) +export class PlaygroundComponent { +} + +@NgModule({ + declarations: [PlaygroundComponent], + imports: [ + NgOptimizedImageModule, + RouterModule.forChild([{ + path: '', + component: PlaygroundComponent, + }]), + ], + providers: [provideImgixLoader('https://aurora-project.imgix.net')], +}) +export class PlaygroundModule { +} diff --git a/packages/private/testing/src/render3.ts b/packages/private/testing/src/render3.ts index c1105dc6340..f9dddbe0896 100644 --- a/packages/private/testing/src/render3.ts +++ b/packages/private/testing/src/render3.ts @@ -50,6 +50,54 @@ export function withBody(html: string, blockFn: T): T { } as any; } +/** + * Wraps a function in a new function which sets up document and HTML for running a test. + * + * This function wraps an existing testing function. The wrapper adds HTML to the `head` element of + * the `document` and subsequently tears it down. + * + * This function can be used with `async await` and `Promise`s. If the wrapped function returns a + * promise (or is `async`) then the teardown is delayed until that `Promise` is resolved. + * + * In the NodeJS environment this function detects if `document` is present and if not, it creates + * one by loading `domino` and installing it. + * + * Example: + * + * ``` + * describe('something', () => { + * it('should do something', withHead('', async () => { + * // ... + * })); + * }); + * ``` + * + * @param html HTML which should be inserted into the `head` of the `document`. + * @param blockFn function to wrap. The function can return promise or be `async`. + */ +export function withHead(html: string, blockFn: T): T { + return wrapTestFn(() => document.head, html, blockFn); +} + +/** + * Wraps provided function (which typically contains the code of a test) into a new function that + * performs the necessary setup of the environment. + */ +function wrapTestFn( + elementGetter: () => HTMLElement, html: string, blockFn: T): T { + return function(done: DoneFn) { + if (typeof blockFn === 'function') { + elementGetter().innerHTML = html; + const blockReturn = blockFn(); + if (blockReturn instanceof Promise) { + blockReturn.then(done, done.fail); + } else { + done(); + } + } + } as any; +} + /** * Runs jasmine expectations against the provided keys for `ngDevMode`. *