From ae34dbca1be10b0245a751bb0596599e95d7b4aa Mon Sep 17 00:00:00 2001 From: Alex Castle Date: Mon, 20 Mar 2023 02:07:37 -0700 Subject: [PATCH] feat(common): Backport NgOptimizedImage to v13 Backport the image optimization features from verion 15 to version 13 --- .pullapprove.yml | 1 + aio/content/guide/image-directive.md | 323 +++ aio/content/navigation.json | 5 + goldens/public-api/common/errors.md | 30 +- goldens/public-api/common/index.md | 87 + .../size-tracking/integration-payloads.json | 2 +- packages/common/src/common.ts | 1 + packages/common/src/directives/index.ts | 6 +- .../directives/ng_optimized_image/asserts.ts | 25 + .../ng_optimized_image/error_helper.ts | 14 + .../image_loaders/cloudflare_loader.ts | 35 + .../image_loaders/cloudinary_loader.ts | 59 + .../image_loaders/image_loader.ts | 130 ++ .../image_loaders/imagekit_loader.ts | 50 + .../image_loaders/imgix_loader.ts | 48 + .../directives/ng_optimized_image/index.ts | 16 + .../ng_optimized_image/lcp_image_observer.ts | 104 + .../ng_optimized_image/ng_optimized_image.ts | 1010 +++++++++ .../preconnect_link_checker.ts | 151 ++ .../preload-link-creator.ts | 85 + .../directives/ng_optimized_image/tokens.ts | 27 + .../src/directives/ng_optimized_image/url.ts | 48 + packages/common/src/errors.ts | 18 +- .../directives/ng_optimized_image_spec.ts | 1799 +++++++++++++++++ .../test/image_loaders/image_loader_spec.ts | 217 ++ packages/core/src/core_private_export.ts | 2 +- .../test/bundling/image-directive/BUILD.bazel | 91 + .../test/bundling/image-directive/README.md | 13 + .../test/bundling/image-directive/e2e/a.png | Bin 0 -> 2385 bytes .../test/bundling/image-directive/e2e/b.png | Bin 0 -> 2385 bytes .../e2e/basic/basic.e2e-spec.ts | 29 + .../image-directive/e2e/basic/basic.ts | 35 + .../image-directive/e2e/browser-logs-util.ts | 36 + .../e2e/fill-mode/fill-mode.e2e-spec.ts | 45 + .../e2e/fill-mode/fill-mode.ts | 60 + .../image-distortion.e2e-spec.ts | 89 + .../e2e/image-distortion/image-distortion.ts | 105 + .../e2e/lcp-check/lcp-check.e2e-spec.ts | 36 + .../e2e/lcp-check/lcp-check.ts | 50 + .../image-directive/e2e/logo-1500w.jpg | Bin 0 -> 55407 bytes .../image-directive/e2e/logo-500w.jpg | Bin 0 -> 13843 bytes .../oversized-image.e2e-spec.ts | 31 + .../e2e/oversized-image/oversized-image.ts | 73 + .../preconnect-check.e2e-spec.ts | 51 + .../e2e/preconnect-check/preconnect-check.ts | 67 + .../image-directive/e2e/start-server.js | 21 + .../image-directive/e2e/tsconfig-e2e.json | 6 + .../test/bundling/image-directive/index.html | 33 + .../test/bundling/image-directive/index.ts | 80 + .../bundling/image-directive/playground.ts | 63 + packages/private/testing/src/render3.ts | 48 + 51 files changed, 5350 insertions(+), 5 deletions(-) create mode 100644 aio/content/guide/image-directive.md create mode 100644 packages/common/src/directives/ng_optimized_image/asserts.ts create mode 100644 packages/common/src/directives/ng_optimized_image/error_helper.ts create mode 100644 packages/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader.ts create mode 100644 packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts create mode 100644 packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts create mode 100644 packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts create mode 100644 packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts create mode 100644 packages/common/src/directives/ng_optimized_image/index.ts create mode 100644 packages/common/src/directives/ng_optimized_image/lcp_image_observer.ts create mode 100644 packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts create mode 100644 packages/common/src/directives/ng_optimized_image/preconnect_link_checker.ts create mode 100644 packages/common/src/directives/ng_optimized_image/preload-link-creator.ts create mode 100644 packages/common/src/directives/ng_optimized_image/tokens.ts create mode 100644 packages/common/src/directives/ng_optimized_image/url.ts create mode 100644 packages/common/test/directives/ng_optimized_image_spec.ts create mode 100644 packages/common/test/image_loaders/image_loader_spec.ts create mode 100644 packages/core/test/bundling/image-directive/BUILD.bazel create mode 100644 packages/core/test/bundling/image-directive/README.md create mode 100644 packages/core/test/bundling/image-directive/e2e/a.png create mode 100644 packages/core/test/bundling/image-directive/e2e/b.png create mode 100644 packages/core/test/bundling/image-directive/e2e/basic/basic.e2e-spec.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/basic/basic.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/browser-logs-util.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/fill-mode/fill-mode.e2e-spec.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/fill-mode/fill-mode.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.e2e-spec.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/lcp-check/lcp-check.e2e-spec.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/lcp-check/lcp-check.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/logo-1500w.jpg create mode 100644 packages/core/test/bundling/image-directive/e2e/logo-500w.jpg create mode 100644 packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/preconnect-check/preconnect-check.e2e-spec.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/preconnect-check/preconnect-check.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/start-server.js create mode 100644 packages/core/test/bundling/image-directive/e2e/tsconfig-e2e.json create mode 100644 packages/core/test/bundling/image-directive/index.html create mode 100644 packages/core/test/bundling/image-directive/index.ts create mode 100644 packages/core/test/bundling/image-directive/playground.ts 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 0000000000000000000000000000000000000000..c5102939182281314e8f79f1d7f7cd2145cd81f5 GIT binary patch literal 2385 zcma)7Yg7{07RIs3%FxPQR!&yqtJ0hZ5q!V36s>HM!9+u$2FwSRhKg2B<&>E{G!#O# zwA6e=j)|gO?V+h4plBtal?v$qDI=3vgaK)w- z0000O;O~7%hw3kTnZE99-CJR-15QX_sE-c&4ED4e*$MUSznU)mkNyA5Kl8u(I!A2c z^2*FfcJ9u|rGiEI)VyNq({Ykkt9^SSZGL!kc4TzSGn%*mxawJ#qP=Tm$NsL}{!{ci z>HRw0XkGb-y+_$jMHy)#GgHe@3YgxRlDsIzilq0|Sw&Y(Mu@wkSyKF}g2KfF85E0efvcWi^e-(3c~#)b>`TQ2)>6zHAmrMd-Stf-V=Oj zU|bgI<@i-s9DGM~8e77!JU&BVo2CnzGbOe#tP;Vul%}y2h;}~u-%*q<+MSR8BC3L7 z8XSt4URzS*4V`AssYpj|Vzz9q78l55t9WVHt=T2?Gut}JIRsIh z1%d!9ZTJz0B;Ds&fZx58g;kJ+7Y{6mdr%8A^3Vy)yS)| zsbKXMQWg=wJBnuq3Ta7hXa)wz;|x+dHDw#{0*dc&FU3?ws*lXV1bG|vkTrJc?lHjN zxV+q;QM+`wt7km|b_ZnGa6omy8ka&`>FSU(Au&;`u4v~q>eRrPTlEkZxaSP+X!}zh z6N!0pnRbDHC4JX!?2S_{tb*eRhOZP-d-2#)A`^8SoC@*u8x4$*m2wC>aof*q=r%;( zmNzNY*S9Ky_l(sSQ#xn(hSs>Z_KQE=1bH>kWPX7H=X26Js;1gbJRzO;(y*u^y40Wj zaWqDG)+ibnjM@fe#20WE*K`(wgRBxQx1!**kM4qD<>d}Cy@?|8^A>n3FYDA^i2&%{ zJq|%w`VNpzg;y{@>$8Vq`_*}j%^zd6LAyr^4t$aoy!<>Yi?--az z<(m^N4e1+Zs#fz#YbgQY%Y@}o2$PfR^Fc-SYQbHwr}|Mb#im`NE9?#4bFoai`C7OS z*kl*k}6{1T5!RH3W5Y$66%T7aX^xOg+9-zpuWmVZpO|{MrF-usr&Ys6i zc*u*CfGr-Xhud&(J=JJ0Hh!a8a2*_+r5se)NN&(Mz^w(ca4>6fz?QI5xZwh`r>3w) zJ#_cjpv1__V{MBY2(%$D)L#CVn0F(Ed=1PK@&C1~<@p)aI19Qs-VZhFOeJ2=fw%BE zeD@;Mv2wA42gDf$Z|yGe?&rm-E3O@ib%OKfFGcD%-)uc~D|u=lX9})i9u#E{^z=J@ zW;PP7!XToi!*f#X&k&K07^%;&u@{jD4b}2*X(> zHAR)l?@tr{f;XS4{zoHs$fb(Uae=f|%+IzkH>wDm1X93VnR*{`#WYgXrDQ*3x*D+Y)Ihu{Q7!rfGMwO?^v ljI?SQ9hwt}{Ftxy#)W*NndmOdGy3wQ2l&9ft34yn{|yqu>aG9) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c5102939182281314e8f79f1d7f7cd2145cd81f5 GIT binary patch literal 2385 zcma)7Yg7{07RIs3%FxPQR!&yqtJ0hZ5q!V36s>HM!9+u$2FwSRhKg2B<&>E{G!#O# zwA6e=j)|gO?V+h4plBtal?v$qDI=3vgaK)w- z0000O;O~7%hw3kTnZE99-CJR-15QX_sE-c&4ED4e*$MUSznU)mkNyA5Kl8u(I!A2c z^2*FfcJ9u|rGiEI)VyNq({Ykkt9^SSZGL!kc4TzSGn%*mxawJ#qP=Tm$NsL}{!{ci z>HRw0XkGb-y+_$jMHy)#GgHe@3YgxRlDsIzilq0|Sw&Y(Mu@wkSyKF}g2KfF85E0efvcWi^e-(3c~#)b>`TQ2)>6zHAmrMd-Stf-V=Oj zU|bgI<@i-s9DGM~8e77!JU&BVo2CnzGbOe#tP;Vul%}y2h;}~u-%*q<+MSR8BC3L7 z8XSt4URzS*4V`AssYpj|Vzz9q78l55t9WVHt=T2?Gut}JIRsIh z1%d!9ZTJz0B;Ds&fZx58g;kJ+7Y{6mdr%8A^3Vy)yS)| zsbKXMQWg=wJBnuq3Ta7hXa)wz;|x+dHDw#{0*dc&FU3?ws*lXV1bG|vkTrJc?lHjN zxV+q;QM+`wt7km|b_ZnGa6omy8ka&`>FSU(Au&;`u4v~q>eRrPTlEkZxaSP+X!}zh z6N!0pnRbDHC4JX!?2S_{tb*eRhOZP-d-2#)A`^8SoC@*u8x4$*m2wC>aof*q=r%;( zmNzNY*S9Ky_l(sSQ#xn(hSs>Z_KQE=1bH>kWPX7H=X26Js;1gbJRzO;(y*u^y40Wj zaWqDG)+ibnjM@fe#20WE*K`(wgRBxQx1!**kM4qD<>d}Cy@?|8^A>n3FYDA^i2&%{ zJq|%w`VNpzg;y{@>$8Vq`_*}j%^zd6LAyr^4t$aoy!<>Yi?--az z<(m^N4e1+Zs#fz#YbgQY%Y@}o2$PfR^Fc-SYQbHwr}|Mb#im`NE9?#4bFoai`C7OS z*kl*k}6{1T5!RH3W5Y$66%T7aX^xOg+9-zpuWmVZpO|{MrF-usr&Ys6i zc*u*CfGr-Xhud&(J=JJ0Hh!a8a2*_+r5se)NN&(Mz^w(ca4>6fz?QI5xZwh`r>3w) zJ#_cjpv1__V{MBY2(%$D)L#CVn0F(Ed=1PK@&C1~<@p)aI19Qs-VZhFOeJ2=fw%BE zeD@;Mv2wA42gDf$Z|yGe?&rm-E3O@ib%OKfFGcD%-)uc~D|u=lX9})i9u#E{^z=J@ zW;PP7!XToi!*f#X&k&K07^%;&u@{jD4b}2*X(> zHAR)l?@tr{f;XS4{zoHs$fb(Uae=f|%+IzkH>wDm1X93VnR*{`#WYgXrDQ*3x*D+Y)Ihu{Q7!rfGMwO?^v ljI?SQ9hwt}{Ftxy#)W*NndmOdGy3wQ2l&9ft34yn{|yqu>aG9) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7dc15e977b6bb1a5871d75d5b20cbbc0e4bbba52 GIT binary patch literal 55407 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 Iz@K;i562pR9RL6T literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..18c0282d1545394cebe2a982fdee6c64d8fcb714 GIT binary patch literal 13843 zcmch61z1#D*YKI5B&3vXq*Pi$VW=4xLTUg(3>dmwK>@`c2?az#x>H2~!3GsUL`fA8 zQE5<-Q0Z?UV3h0YeeeCg=YRe^&pvydb#|<^_S!4C|u)}mrhL)74n1=N!nNJ?+SbO!1d48B2{=Gp`TXw6A}j*edbF5UslpCDxu(0YRx z9|8I8GLW(s!3Yhn2g(X8x&m%&=Z$<51C^skj?pHL|Ii9s{;-jTpsjtt#LO5^FvP8} z%=E*~$HxP?5rRCu0{qN$wFLH9+Xzq(0>0QFdhn#OBl-JinwS{Eb5h!s`F}pXb*^v* zwQYmv`icH`0E>g8KM9D&7Ie3F@F#f!_yGi=uaJEF0w9PA#tR<`@PXlD0A}+81Ohl7 zhMia80vNVmh3~BNA)0A}J}<%M(c9ViIYSU*55Nlqk(|Lij0*sk@p5qWf*_`?09Gft z*f{_grhTiYr#B2I0$3dM_W|$&04v+Co6~t6Y-eZxbACHJ=b!K@UtlC4LdVtL$HOjo z<>mk7!_y-W$Zn-<0Z)1tKRq+hvH=;scK0@bVR`_Md)eyzQYNjN5b0tD8>*Mq7}f?K@dtL$klQ; zfEfY2-QV3DrU%f5+7;rW4dYk(_ywB5^oszznWLAk7JvaSs30dlJj^@XpX})mpcURx z87{5_cr1_usyx7js0UymAJij~-3tAH7t~8fFU>WXpn@Fz_rSD){>VxPM;#o10q>|` zM=uMQUwED$J^@f&y0i9?kp8ykB zhJa3Vh?||h34j4T=nIe`qz&mn0#G2R_K-K^2DvtmSB-BNPPii$AfeW4A$0D_?_FMz}+RUoA&~+tn{HuTA#FZh4c>@GJttJKf1+C79h8qm95=`KvaYB5MFeDE6k%M+X zJ0T4S2kAq`5D~J5_5*!&0ebES9fFQR$Dz~EdElcjLCH`$lnoU?rBFFk1>J!jLQkM) z&`YQT>IT{~0F6Nt&^Krvfk03r7!a%oP6R(f6d{F>LntHE5jcb)!VF=JIEZjYcp(B2 zM-e9w=Mk}pWJD&S5OEz*jd+M?K(rv6$4nmq=Ge z_n5ASZfe7Z4MH1KHkfX3-EeF};)ZJ*o^E)*VV0hSUXos$-j+U)KAJwC{sH|P`Y8qm zhAj*l3^oh_4ABgQ40Q~>3_loI8MiYM7#$eH8B-W58Cw}YGtn}MGHEjHX9{6TV7kHd zf@yps?Zzz|wKv*r4BMEpv3ldHjWf)w%(BeJ%%04V%tg!%%)>0yEL&J`EDkIuS+ZH` zSU$3%ScO@&SxKxXSaVn(vktIPv5Bz}*xcC8vz4+nvrVwGuq&`zvL9woVZY1Xw+Xdr z%O=7mk4-U~$~Sdxn%m5~S#z`F=5w3NHn(p6&cV&0!Qse3=D5ny!7;}vz=`8@=e)>S z#rc*C%_YfY%5{h;jqs9s-F1j|3(Jc?9(Y0|he$n+4~E z#Dpw_B809Ay%nYtRu*;=ju);Ko)F;|F%mf{QY_LXN-L@?>LPksv|jZ47O^c>Th45$ z+A=K0Ax01j5i1t!5oZv`iu;OZiMLBoNhnLWOQcG?ltfA@O1emSz1KeM*5<3z4VfdqKt=3j!d^K zi>$tEglvuMq@1K2NiIdMU7kT6FCQ*{Oa7~Zl!Bu|n!;;ERz)MlGm5o}i#wEd`0Xg$ zF`^`*bWrJv(ko>)WfSE{<)shwZmp%KWGSRIB4W)3~5Sex@neZe%6xF^3$r&`mU|2 z9jg662c@H{L)K}=vEVFl$+%v;5Z)PIjGxd|)IFm6Ko6s5pm$O46@iyPA`}uP^p*5O z^&c6~8<-m;8+=xbawY$m~GA0kNq=p&>fwMPYy z9zXg%R5LU)3=w7@RvRuHelmREnC`KH<8;T}k3Wx)iHMDuK4EpD<|OaQV<$hJ(mhpt zn(?&X=~riVo=HE8KI?k6`JDW@q;pGT2XfqZB5fiD@*51Kc7CA;hOO}Q#Z3RYfDx_HdS^|_F#@>PJOO& zZebo*-ub-6e6Rez0<(fgg-V5mMLb0@#mM5o;*k>Dl9#1ArMJqqm1SPtboKl-=vv^l z(d!4Vca|HJ*WOURQFc@G=9LQ83UVc)GPrW0%B8CBmi4WcYW?cknw>Qjx3}NUzaw}j z@B{>b!E^J9a@PoLnQ)YWU&-+!w1 z^iG3nLv^EaW7V@A&nlY~n<}0wKCfur(OmgL>BX&=DlcnVcD3AV#kM|d(`kFsPH2DD zvAd(C)1veBE8ACnuN__wzwvnUwJWG=zWZ1Yric7?Zm4;2o}4c{5TkGve+J327tGd4ed`V;e~E1$(b-~6KS zrDQ=C)z(+bnIGbS^=-#ov~e<%Oo{82EgJX=3!H8(VWXn}emaZzHi zX31cwXW4Uk*~iY$ZY36gGk2sD$mb{XA&9{mf>>XI_+jsAF1XTTC9hwDK#H^it+wmn z)qHW~+YSgS1M$1*IEZduLD01v2-*VXXR?JL69xz(sz6A2`JZ)dI)5j55b=P5pa*#h zrgwRH3gm3maS*h4VR?BzYk7Gw8{`u`5cI%nH3x+|Q-ORV!(`Fk(;j~Ny{zV^088QV z_ZDz2FRuU(`%An00Ai*=Oe3aI2q6f`j6g9XmLEa-s}>vz7@Fd3(PI zTDnb}IXJmQw}^>LNGd4qP*PS=)z-n`b@d4P=0poiD-g<&92}jTU0mJ#0|J8%9S#mT zaq`sZGiT3{W8>m4C0tHSO3up8$<50zC@dQ;44ZQY~CPwJmGG`6<2cXYmb z{if^vhmZXOgG0k3qhBYdrf0r=|1moU^M!y=t8D$`>^Huc0bfWo8il5Y`9dHMfr?^A zV+3WXSTs$k?R;5<9v?rB-0r@@-oi z0Tt^b#bt!Ye^8`TGu~1_5Q)9=^zps>@6DS$xfnhNxcZ~dxT(gCB&StV4V4=6wtw}B z)|=i%RyiZdo78T)=d0VnuW}#X2J?TkF*RnZlfC9Ww0ltQ#NN0T99sjOc-FE1OT{j~ z{MXz`h!^A}E1z~ac;5yg72A^Bc-hSKk^bJE3G9;sgFs(t`qzBt>zASZ`H7th4$m9B z?9P9Yd6Ch-WRbo-FGMH(eE2CxQ-j1_1^X(|*kH%`alPqbtR$H%X%t)e5XWXBn_ns_ zF-9=FkbUn+3l6#Maj5TR?kXny(9D>P?5M923-ZjXf)Z7Uo^$5sRUcgv<-T}=$t|`T z<78e0^Zh=zGLqyr56{*Py|TWP zlXmIa4~qkulHGl>$l@DKact0ksfgv)KT1bm@@AFEnRh3_T_WDlmcc&xdrYw?J)Kr-ovkC`yL@|!H#+hEabxteByrGDNAd)X9>s=~G zwo-~@c>|=NcMZ@4{h=a&`WS3ABuBsHa3B(&-ugR|(`~MUqZL}O431U=8IkjShfoLN zgGGJK)SPAHp^Z&)kxW(Ljn!^Dhgwsca&cQ;G zZeja`m=k3qsSvAzAAT+KtxzI~vP-|W{L5`WWuz|=BZOB{fsS<0Dso&t`_O-u&Pr0o z+rgD+X2hnw{b5+eDbfB(-`g(J2fh}i?lwMPWBa5f^KJ5^o4)cn!uMTFUyE4w8H<6# zcH+3jaAEZw*T)_aWbqsqHd7q*-zvL;Y;bEyXv~59kkgd`udkgZb=>0DG!j;g9f)f9 zA~{d!cHrdeW(04`v%LGN1qww*CsZJKg+Zsix;XQDeCq&Eyj%a*-UeWhwC<9 zrY}Fk8NS7ES)xnEZi&wU%x*GRrg-&5tm>VT61g1aPqtAiB8Q_%_W4~!gK}9lyn*Z| zHs#9siZiN(@K2O-T>UigF{o|0$GkeSuwipSPIHJ^o4|Slyxcw_L0i8iA-PUcw-hpo zGdZqoerrBpVf19x6qfcRUr%~-!ABmx9#iYLsn>Jaq~uk4`r}4}bF#?zOmOOD{-8g9 z)Tx$iy)mvC5_yyvDS#WM!Z?gv8%QQ~5Ab(Zx_yXNoxFXmBBh{i5I4wJ_~L!<&B7d{ zObA!D`0brnPO)!o;c#?`A=S54;H*;{de)QwPjG@n?)k;N?B71kTAsAYjuthXzsjFk zQY6unq^Z`N#$6!xo*O=*7R@8sN-ERli&B|IhW&-?n;o{XnHnAg%%PS$Rupc)k=rqF znzS&gdLwAZTRpX@+2a<)@-63U41YH)zpLzD-XRtZp122DS4X}E$IhO~3YRxbYHS>Q z#7sllQ*US9YSwus?d=S3KgT)du2@?n2K6m6=|3qe*Rdu!cty(E7H8uJxQniEB?OvY z>NwqkW8VVX`+sYGsmkK9!NMUBHr2CpcY->ByW`lxDR+y~Xw|E|_Q3Zw)dGcNTJBt7 zcxVQ3J~osXZj)^jlDPPR-$Zv*O76yy)aiFM5&!0pP!14#OBb&H0o%;3^uiK%<)b4{ z!bTTBXs&C;=J)FIn@wc#A{8)S2J%{ka^u*jB@(*~1MFsnA^%CPT8w>r%L0uW&UJO~ewbWtHodR2XAq(ExS1X)0 zwAVMDSBwF2#n+F?Ht#crM#S^dT4(S)Zo|$93NqgJP z!}V*h&lj}=>2(z@CREC76-B#dcisjIYYMLI7qv=(_JGlyfUt1B8GMbw>!ZUoyjB-~ zSSgt_H=5M|4Y-)`cXoF@-i+^b!X+zz%#1)-0HUMzwh(UGMAAmQlxY{`WUPfHg(328 zOLf>w{+HSfZ+{4!F_TK*Ip!u3_`WyNMm#HL_+N!QZ`$m-^2}jlXDyp~v14;FkJWB6 zv({CJo~|G}4Ok=sj?LoA@pUSRv|r z@vMyTf3?yD2Iua3ohBGz^TE!k_qiJiif2L4h)rc+iXg7Hz z7)9beWn?4bL%b{xJI1GU`Zia0MToz7VORgE0;NC480~QJ?dBpLuH@=sl}Ib-`uiW) znDFKr8$wUbg@GWzYf_@Q9(rR4S0Edr;W_7NeB?4j`*EZzt2A}@)>A7d-njF65$D_8 zR#vbY{3EG0s`i6@RawiBymd!uuC$!e%WxYPa?D$&w```Cz7#FxUkfhuhor4MpUw9z zU23(Y%D8of--18aFscB1I1`o&c>H&3Vm1Ba(DT8xa|_Q+%A#APg`oVx{zW4*ykf3P z!f+P(##vbH^y}Mm8}{gWCoV(!o%ZYmwc^Vco61`GKd3B2bV>3d%(ttV$Mz|f^d6NQ zx~e_H>z<_MZeV!XBZ_&rC#GN-8Z*OJWC!|(kj0CYYr)B;$>1%3g2(#)>zXo%e`v z9PZ&SD0FIfbSBBcjy2*BX5cTTp-Q3R(eB>Tg<~bE!vSB6eBbzeS^GYB^jeYO65@3qnA{b?TKy0}^0_pfG(I#QfN#k+)! zZHPfpkIr}O#tDuQG!4g0J9QZE(mXAc5;MH)$6-wD4`pymZsB2xN3mY*{}c`%rgK)m39mq z6?%$A`n{EOKfEwvsa_v9&CcNI+Ty7#JKDpPk#{kb;P+%OT_0F8MmVHENLKXp8g8w- zsyUHz~>Q$J@$pw*ptg*XvoLu$D%SDDjp<8pq zh!(r!w&Pq1AeA1Nq0_xwNrh#-gue_5v0FhU|)Nm#l-C>;m+ zp)%&o+OBv89l#6E zPI;E{OpQP0Q9uz}#wEea1^uC}aiW2wYgw76O}83|F9{|mm-NBJAuWTlnUdB_cx&DT z8i8pA_RY@z7qW7b<>{k1mstNzly*D6%qxBl>7G-Ki!=~J%8XuazxpIvh90&P{gqvAsfR+VJrDZ!RaIH*@MN76KuGS z?i`TgJJ&Yqn&m7dm9p`XP!#FFd-iQ?6`4ayKJZ#O3Kmjtjuz1H2j4T*_)s=(-$NK| zYz-P#kI2j-KbDINPk9<(+aY-C*WwviSat1}S*1d*PGT2Bo|k%>hJ6gx zoheCv{`rvEs=Nx;#=zr#Rx5)0<~SPdSA}!QPs-0fzv7&A6hvw`5UE+MMQXLMfxYIP z)Sm*e6v@7v`Lee?F{k6%$?AR0=A>mP@V+p)%pUG8p5G_f-~tiJMKnZL)X!Q|3@M;!KvZ!{^sv0W=8Q zR_+b>*WK|vZ@(;493r!L7`PJYC!Q(Y)RT%Y+70~6Pkh$`zDNnS?0>C=r5~np_sj=k zH8oPot8b8)F45SlJM{x6c^9|MEx7g2A<+Lg#bN(b%QKkCbZN)emf3|P9bCg-vZgWw zLI-Bgytf_D-YKC!43q#5>nvh@#mhhVQa^+v(7ctCE@m3i;N8Okg_sv)-P|<+AA+@t zT6arx)$jJFx1q#W@~@IdUpwlR8FPGWBcJ2lb0`~VUZE=BboJtd(pKIauFQ;|2}{?s zW(E-=wWV@dDpBaveu#tJR97w7dJpg&zZ@-+GVH&!XvL;|kNcY+1ni2*EX%r~$Z_cL zYK|DEkSirmMHVmE`5)7iT1UO|jXAoc(P8tl4exbhIDgJu=lOieunP!LPQvGdQrBI% zXxrY*_9(j-8J-%b_%FJjlT3r7&%i zU4{a}ZQc}Qp2cxJxnC=5W_gZ>6-0}>i(He~|#%ec*U#ECFxU5Va^LuTW50egQOk# zo?NA%#FskamV#HhIhx{L9})ki7+&G945g8#uETCcmm?uVq(+NrkF&X_K`XOtM}g?-)Ip||Mfc)f!93n$nY&91*J zk(rOXXD&FLs-C|Lm0HiV+C1*BEee^zniDTZx!Ua51_B1>l?%K}Jgqd@u1ph=?G-VF zgzxseOQatGId6PoRm6hy|9m9ZBZvJ zN@W%8keM}!2%GW?&)r|jqAb}s3410dxVxDCpxEW+OF84l{F<$m-no-~0)gJ~fU1Hx z)dJm5fMm0PQ|t2TKVHmM-fSo@rCs9F1-G#?W1I4-;u?}1K3(C@%sX4iUCg`hXR2da zeb$Ioa5NZrz}bn!Zjwi@1kUM zBgU^tN#=kV_0YbOvfw<(U=#8-J51 zuP1aa2U{zzA!7f|V9@AVz-}ySXTi%grIM0wi73AYG>E+3t8j(1nx);0hyS(si|63w zjlAc9!Ul(#|FGy9njSe?y>O$YepD)>SeIR|ORELWT!DjYxv z$-=IS+a#ET639@$UYGtHPZ*JSgOZK2JA@G4nXDZXgotU91Cjrc?~-C$(J7PS2Rw9x zc1anbPZ7%Xir(}3A6vqOmlO%Fhl*(l!``NQuwC+V(*5C2Vm@&$Zl1(`v}lS9jf@(V zqMpZx;}&9mv@B+0vzBxhTn07A9lSd6xzcaX9)ex%8OmI{34B)D-x}v`d6rMe57LFR zU%Pc@0`{9I@c)2$XKa!nfGH6v>?9EuebQ7xZk@q&+bn{v?hRr6T69zCO`q;Fa}P@y0DVRgG=d=Xv^0bK$wCMRs)YcaR85 zB>hxDaJu-{1&6TqKDvD){G-B47w-uP(ZG(z) zKSS~2$3=|iLuY!Pf@k4}XYwvyDH2bTah+_KB1}q;&04na_^Khx^|AdsHp&=hrmIk- z0$&>Z<^G<+;JTQNf}*rhEh78M5Y*R8l5T!bcVSvUFGJdU6>cVPm-l_$pJdHZ96>Yg zDwUtWF=(s&Vyfia%~|2utl3v>_rX7qx!HN_aXl}xB1tE31laogF^hK{Bj|^;nu~xH z(d#|LPuRRwMbz_bZp`eM&4g>geT5<4rQs2fkLKGuD)KfxJ(%D>NguPn<2qG3+aBlH z?spk;boU_BV=@@kLg569y>Rr9l(=Syex@ty-v92eGl~e{wot9ae6o&VRJC(gr@!`7 z?pN*Mru%qBw?qkAJnAnfogL3g*?M0%BxY2K&pr4cR_5b;U9Pq+QBneS`%$niQ}i2d z|6a%0GpW+ICc1{<7Q^S-AN3~;U@U{^GGdR=wVyB|P3`u<)~GIRsSvCpW4hP2)<`(n zBIUJ%Z;nB^3|k0YELk%0_vO|vO@S%bRO^}i6D&c4KG@o#!RhZh{7&J3#PlbSYnLPm z#ct^q`Z&vGiqmk{E z+&DI~t&}DHbJNWqdMiF$eEqR~I)l5ipWSbRME>mcicsTe_Cm}_llLh@-sUeuF)kJ& z{*6_Wmk!<_GZG!|-wElc(YMK_>h6HeRm5*%rttcEOSyHT5c5y1{^>oWq@btOg(KqV zal04!Ptz3+$V`8MuS7o3D68QDD{PMJ$>*0DY7Z>*RTVst6YWK=R*Y})QxBZs4QD2f z?!F{418kX@_%F6ES)@i}h@{mXr!~WIH+GvE@DI$CiYs&~yEbFr@VA3_+g-$!Yg_~3 zJ!OWMD@k|~z~v_WT&XE^XZXyR2}DJWOh_oI-<8w*9@s=UHvE%j{~ZpoE)w}C=GT9h zJo(MkD1l!J=(*&gD9X&8=x^R_ { + 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`. *