diff --git a/aio/content/guide/image-directive.md b/aio/content/guide/image-directive.md
index 06ce9d3dc3f385..ddca51c255bc8c 100644
--- a/aio/content/guide/image-directive.md
+++ b/aio/content/guide/image-directive.md
@@ -89,7 +89,40 @@ You can typically fix this by adding `height: auto` or `width: auto` to your ima
### Handling `srcset` attributes
-If your `` tag defines a `srcset` attribute, replace it with `ngSrcset`.
+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 directly, or use the `ngSrcset` attribute:
@@ -97,9 +130,7 @@ If your `` tag defines a `srcset` attribute, replace it with `ngSrcset`.
-If the `ngSrcset` attribute is present, `NgOptimizedImage` generates and sets the [`srcset` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset) using the configured image loader. 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`) are supported.
-
-You can also use `ngSrcset` with the standard image [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes).
+If the `ngSrcset` attribute is present, `NgOptimizedImage` generates and sets the `srcset` using the configured image loader. 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`) are supported.
@@ -107,6 +138,16 @@ You can also use `ngSrcset` with the standard image [`sizes` attribute](https://
+### Disabling automatic srcset generation
+
+To disable srcset generation for a single image, you can add the `unoptimized` attribute on the image:
+
+
+
+<img ngSrc="about.jpg" unoptimized>
+
+
+
### 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).
@@ -117,6 +158,20 @@ By default, `NgOptimizedImage` sets `loading=lazy` for all images that are not m
+### 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).
+
diff --git a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts
index fca335c95e4fa8..ac5cc2c59d7ec4 100644
--- a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts
+++ b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {Directive, ElementRef, inject, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
+import {Directive, ElementRef, inject, InjectionToken, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
import {RuntimeErrorCode} from '../../errors';
@@ -49,6 +49,15 @@ export const ABSOLUTE_SRCSET_DENSITY_CAP = 3;
*/
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.
*/
@@ -61,6 +70,34 @@ const ASPECT_RATIO_TOLERANCE = .1;
*/
const OVERSIZED_IMAGE_TOLERANCE = 1000;
+/**
+ * A configuration object for the NgOptimizedImage directive. Contains:
+ * - viewportBreakpoints: An array of integer breakpoints used to generate
+ * srcsets for responsive images.
+ * - subViewportBreakpoints: An array of smaller breakpoints. Added to the
+ * srcset when `sizes` indicates a sub-full-width responsive image.
+ *
+ * Learn more about the responsive image configuration in [the NgOptimizedImage
+ * guide](guide/image-directive).
+ * @developerPreview
+ */
+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`
+ * @developerPreview
+ */
+export const IMAGE_CONFIG = new InjectionToken(
+ 'ImageConfig', {providedIn: 'root', factory: () => defaultConfig});
+
/**
* Directive that improves image loading performance by enforcing best practices.
*
@@ -72,6 +109,7 @@ const OVERSIZED_IMAGE_TOLERANCE = 1000;
*
* 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
@@ -165,6 +203,7 @@ const OVERSIZED_IMAGE_TOLERANCE = 1000;
})
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
private imageLoader = inject(IMAGE_LOADER);
+ private config: ImageConfig = processConfig(inject(IMAGE_CONFIG));
private renderer = inject(Renderer2);
private imgElement: HTMLImageElement = inject(ElementRef).nativeElement;
private injector = inject(Injector);
@@ -223,6 +262,12 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
*/
@Input() ngSrcset!: string;
+ /**
+ * The base `sizes` attribute passed through to the `` element.
+ * Providing sizes causes the image to use an automatic responsive srcset.
+ */
+ @Input() sizes?: string;
+
/**
* The intrinsic width of the image in pixels.
*/
@@ -269,6 +314,18 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
}
private _priority = false;
+ /**
+ * Disables automatic srcset generation for this image.
+ */
+ @Input()
+ set unoptimized(value: string|boolean|undefined) {
+ this._unoptimized = inputToBoolean(value);
+ }
+ get unoptimized(): boolean {
+ return this._unoptimized;
+ }
+ private _unoptimized = 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
@@ -290,12 +347,17 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
assertNonEmptyInput(this, 'ngSrc', this.ngSrc);
assertValidNgSrcset(this, this.ngSrcset);
assertNoConflictingSrc(this);
- assertNoConflictingSrcset(this);
+ if (this.ngSrcset) {
+ assertNoConflictingSrcset(this);
+ }
assertNotBase64Image(this);
assertNotBlobUrl(this);
assertNonEmptyWidthAndHeight(this);
assertValidLoadingInput(this);
assertNoImageDistortion(this, this.imgElement, this.renderer);
+ if (!this.srcset) {
+ assertNoComplexSizes(this);
+ }
if (this.priority) {
const checker = this.injector.get(PreconnectLinkChecker);
checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc);
@@ -325,8 +387,13 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
// The `src` and `srcset` attributes should be set last since other attributes
// could affect the image's loading behavior.
this.setHostAttribute('src', this.getRewrittenSrc());
+ if (this.sizes) {
+ this.setHostAttribute('sizes', this.sizes);
+ }
if (this.ngSrcset) {
this.setHostAttribute('srcset', this.getRewrittenSrcset());
+ } else if (!this._unoptimized && !this.srcset) {
+ this.setHostAttribute('srcset', this.getAutomaticSrcset());
}
}
@@ -370,6 +437,36 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
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.imageLoader({src: this.ngSrc, width: bp})} ${bp}w`);
+ return finalSrcs.join(', ');
+ }
+
+ private getFixedSrcset(): string {
+ const finalSrcs = DENSITY_SRCSET_MULTIPLIERS.map(
+ multiplier => `${this.imageLoader({src: this.ngSrc, width: this.width! * multiplier})} ${
+ multiplier}x`);
+ return finalSrcs.join(', ');
+ }
+
ngOnDestroy() {
if (ngDevMode) {
if (!this.priority && this._renderedSrc !== null && this.lcpObserver !== null) {
@@ -399,6 +496,16 @@ 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 *****/
@@ -448,6 +555,23 @@ function assertNotBase64Image(dir: NgOptimizedImage) {
}
}
+/**
+ * Verifies that the 'sizes' only includes responsive values.
+ */
+function assertNoComplexSizes(dir: NgOptimizedImage) {
+ let sizes = dir.sizes;
+ if (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 \`srcset\` value directly.`);
+ }
+ }
+}
+
/**
* Verifies that the `ngSrc` is not a Blob URL.
*/
diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts
index 0cfb640220eaf8..6cf97f55a3ec18 100644
--- a/packages/common/test/directives/ng_optimized_image_spec.ts
+++ b/packages/common/test/directives/ng_optimized_image_spec.ts
@@ -14,681 +14,663 @@ import {expect} from '@angular/platform-browser/testing/src/matchers';
import {withHead} from '@angular/private/testing';
import {createImageLoader, IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from '../../src/directives/ng_optimized_image/image_loaders/image_loader';
-import {ABSOLUTE_SRCSET_DENSITY_CAP, assertValidNgSrcset, NgOptimizedImage, RECOMMENDED_SRCSET_DENSITY_CAP} from '../../src/directives/ng_optimized_image/ng_optimized_image';
+import {ABSOLUTE_SRCSET_DENSITY_CAP, assertValidNgSrcset, IMAGE_CONFIG, ImageConfig, NgOptimizedImage, RECOMMENDED_SRCSET_DENSITY_CAP} from '../../src/directives/ng_optimized_image/ng_optimized_image';
import {PRECONNECT_CHECK_BLOCKLIST} from '../../src/directives/ng_optimized_image/preconnect_link_checker';
-describe('Image directive', () => {
- 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 `ngSrc` 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 an old `rawSrc` is present', () => {
- setupTestingModule();
-
- const template = '';
- expect(() => {
- const fixture = createTestComponent(template);
- fixture.detectChanges();
- })
- .toThrowError(
- 'NG02952: The NgOptimizedImage directive has detected that the `rawSrc` ' +
- 'attribute was used to activate the directive. Newer version of the directive uses ' +
- 'the `ngSrc` attribute instead. Please replace `rawSrc` with `ngSrc` and ' +
- '`rawSrcset` with `ngSrcset` attributes in the template to enable image optimizations.');
- });
-
- 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!);
+describe(
+ 'Image directive', () => {
+ 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();
- // 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();
- });
- });
+ const template = '';
+ TestBed.overrideComponent(TestComponent, {set: {template: template}});
- it('should throw if `width` and `height` are not set', () => {
- setupTestingModule();
+ const _document = TestBed.inject(DOCUMENT);
+ const _window = _document.defaultView!;
+ const setAttributeSpy =
+ spyOn(_window.HTMLImageElement.prototype, 'setAttribute').and.callThrough();
- const template = '';
- expect(() => {
- const fixture = createTestComponent(template);
+ const fixture = TestBed.createComponent(TestComponent);
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.');
- });
-
- 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.');
- });
-
- 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.');
- });
-
- 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 nativeElement = fixture.nativeElement as HTMLElement;
- 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.');
- });
+ 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 throw if `ngSrc` value is set to an empty string', () => {
- setupTestingModule();
+ it('should always reflect the width/height attributes if bound', () => {
+ setupTestingModule();
- const template = '';
- expect(() => {
+ const template = '';
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 nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('width')).toBe('100');
+ expect(img.getAttribute('height')).toBe('50');
+ });
- const template = `
+ 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 an old `rawSrc` is present', () => {
+ setupTestingModule();
+
+ const template =
+ '';
+ expect(() => {
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02952: The NgOptimizedImage directive has detected that the `rawSrc` ' +
+ 'attribute was used to activate the directive. Newer version of the directive uses ' +
+ 'the `ngSrc` attribute instead. Please replace `rawSrc` with `ngSrc` and ' +
+ '`rawSrcset` with `ngSrcset` attributes in the template to enable image optimizations.');
+ });
+
+ 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.');
+ });
+
+ 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.');
+ });
+
+ 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.');
+ });
+
+ 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 (``). ' +
+ '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(
+ '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, 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();
+ 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]
+ ];
+ inputs.forEach(([inputName, value]) => {
+ it(`should throw if an input changed after directive initialized the input`, () => {
+ setupTestingModule();
+
+ const template =
+ '';
+ // Initial render
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
+
+ expect(() => {
+ // Update input (expect to throw)
+ (fixture.componentInstance as unknown as
+ {[key: string]: unknown})[inputName as string] = value;
+ fixture.detectChanges();
+ })
+ .toThrowError(
+ 'NG02953: The NgOptimizedImage directive (activated on an element ' +
+ `with the \`ngSrc="img.png"\`) has detected that \`${
+ inputName}\` was updated ` +
+ 'after initialization. The NgOptimizedImage directive will not react ' +
+ `to this input change. To fix this, switch \`${
+ inputName}\` a static value or ` +
+ 'wrap the image element in an *ngIf that is gated on the necessary value.');
+ });
+ });
});
- it('should throw if density srcset is missing a comma', () => {
- expect(() => {
- assertValidNgSrcset(mockDirectiveInstance, '1x 2x');
- }).toThrowError();
- });
+ describe('lazy loading', () => {
+ it('should eagerly load priority images', () => {
+ setupTestingModule();
- it('should throw if density srcset has too many digits', () => {
- expect(() => {
- assertValidNgSrcset(mockDirectiveInstance, '100x, 2x');
- }).toThrowError();
- });
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
- it('should throw if width srcset includes a file name', () => {
- expect(() => {
- assertValidNgSrcset(mockDirectiveInstance, 'a.png 100w, b.png 200w');
- }).toThrowError();
- });
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('loading')).toBe('eager');
+ });
- it('should throw if density srcset includes a file name', () => {
- expect(() => {
- assertValidNgSrcset(mockDirectiveInstance, 'a.png 1x, b.png 2x');
- }).toThrowError();
- });
+ it('should lazily load non-priority images', () => {
+ setupTestingModule();
- it('should throw if srcset starts with a letter', () => {
- expect(() => {
- assertValidNgSrcset(mockDirectiveInstance, 'a100w, 200w');
- }).toThrowError();
- });
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
- it('should throw if srcset starts with another non-digit', () => {
- expect(() => {
- assertValidNgSrcset(mockDirectiveInstance, '--100w, 200w');
- }).toThrowError();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('loading')).toBe('lazy');
+ });
});
- it('should throw if first descriptor in srcset is junk', () => {
- expect(() => {
- assertValidNgSrcset(mockDirectiveInstance, 'foo, 1x');
- }).toThrowError();
- });
+ describe('loading attribute', () => {
+ it('should override the default loading behavior for non-priority images', () => {
+ setupTestingModule();
- it('should throw if later descriptors in srcset are junk', () => {
- expect(() => {
- assertValidNgSrcset(mockDirectiveInstance, '100w, foo');
- }).toThrowError();
- });
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
- it('should throw if srcset has a density descriptor after a width descriptor', () => {
- expect(() => {
- assertValidNgSrcset(mockDirectiveInstance, '100w, 1x');
- }).toThrowError();
- });
+ 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();
- it('should throw if srcset has a width descriptor after a density descriptor', () => {
- expect(() => {
- assertValidNgSrcset(mockDirectiveInstance, '1x, 200w');
- }).toThrowError();
+ 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").');
+ });
});
- });
- const inputs = [
- ['ngSrc', 'new-img.png'], //
- ['width', 10], //
- ['height', 20], //
- ['priority', true]
- ];
- inputs.forEach(([inputName, value]) => {
- it(`should throw if an input changed after directive initialized the input`, () => {
- setupTestingModule();
+ describe('fetch priority', () => {
+ it('should be "high" for priority images', () => {
+ setupTestingModule();
- const template =
- '';
- // Initial render
- const fixture = createTestComponent(template);
- fixture.detectChanges();
-
- expect(() => {
- // Update input (expect to throw)
- (fixture.componentInstance as unknown as {[key: string]: unknown})[inputName as string] =
- value;
+ const template = '';
+ const fixture = createTestComponent(template);
fixture.detectChanges();
- })
- .toThrowError(
- 'NG02953: The NgOptimizedImage directive (activated on an element ' +
- `with the \`ngSrc="img.png"\`) has detected that \`${inputName}\` was updated ` +
- 'after initialization. The NgOptimizedImage directive will not react ' +
- `to this input change. To fix this, switch \`${inputName}\` a static value or ` +
- 'wrap the image element in an *ngIf that is gated on the necessary value.');
- });
- });
- });
-
- 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 nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('fetchpriority')).toBe('high');
+ });
- const template = '';
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ it('should be "auto" for non-priority images', () => {
+ setupTestingModule();
- const nativeElement = fixture.nativeElement as HTMLElement;
- const img = nativeElement.querySelector('img')!;
- expect(img.getAttribute('loading')).toBe('auto');
- });
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
- it('should throw for invalid loading inputs', () => {
- setupTestingModule();
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.getAttribute('fetchpriority')).toBe('auto');
+ });
+ });
- 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('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';
+ };
- describe('fetch priority', () => {
- it('should be "high" for priority images', () => {
- setupTestingModule();
+ it('should log a warning if there is no preconnect link for a priority image',
+ withHead('', () => {
+ setupTestingModule({imageLoader});
- const template = '';
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ const consoleWarnSpy = spyOn(console, 'warn');
+ 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');
- });
+ 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 be "auto" for non-priority images', () => {
- setupTestingModule();
+ 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 template = '';
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ const consoleWarnSpy = spyOn(console, 'warn');
+ 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');
- });
- });
+ // Expect no warnings in the console.
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ }));
- 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(
- '',
- () => {
+ 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');
@@ -707,334 +689,506 @@ describe('Image directive', () => {
'\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);
- }));
- });
+ 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});
- 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);
- }));
- });
- });
+ const consoleWarnSpy = spyOn(console, 'warn');
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
- describe('loaders', () => {
- it('should set `src` to match `ngSrc` if image loader is not provided', () => {
- setupTestingModule();
+ // Expect no warnings in the console.
+ expect(consoleWarnSpy.calls.count()).toBe(0);
+ }));
- const template = ``;
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ ['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);
+ }));
+ });
+ });
- const nativeElement = fixture.nativeElement as HTMLElement;
- const img = nativeElement.querySelector('img')!;
- expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
- });
+ describe('loaders', () => {
+ it('should set `src` to match `ngSrc` if image loader is not provided', () => {
+ setupTestingModule();
+
+ const template = ``;
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
- 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 nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
+ expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
+ });
+
+ 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 template = `
`;
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ 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`);
- });
+ 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});
+ 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 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 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 = createTestComponent(template);
+ fixture.detectChanges();
- const fixture = TestBed.createComponent(TestComponent);
- 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`);
+ });
- const defaultLoader = TestBed.inject(IMAGE_LOADER);
- const nativeElement = fixture.nativeElement as HTMLElement;
- const img = nativeElement.querySelector('img')!;
+ 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});
- expect(defaultLoader({src: 'a.png'})).toBe('https://default.io/a.png');
- expect(img.src).toBe('https://component.io/a.png');
- }));
+ const template = '';
+ const fixture = createTestComponent(template);
+ fixture.detectChanges();
- describe('`ngSrcset` values', () => {
- let imageLoader!: ImageLoader;
+ 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();
- beforeEach(() => {
- imageLoader = (config: ImageLoaderConfig) => {
- const width = config.width ? `?w=${config.width}` : ``;
- return `${IMG_BASE_URL}/${config.src}${width}`;
- };
- });
+ const defaultLoader = TestBed.inject(IMAGE_LOADER);
+ const nativeElement = fixture.nativeElement as HTMLElement;
+ const img = nativeElement.querySelector('img')!;
- it('should NOT set `srcset` if no `ngSrcset` value', () => {
- setupTestingModule({imageLoader});
+ expect(defaultLoader({src: 'a.png'})).toBe('https://default.io/a.png');
+ expect(img.src).toBe('https://component.io/a.png');
+ }));
- const template = `
-
- `;
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ describe('`ngSrcset` values', () => {
+ let imageLoader!: ImageLoader;
- const nativeElement = fixture.nativeElement as HTMLElement;
- const img = nativeElement.querySelector('img')!;
- expect(img.src).toBe(`${IMG_BASE_URL}/img-100.png`);
- expect(img.srcset).toBe('');
- });
+ 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});
+ it('should set the `srcset` using the `ngSrcset` value with width descriptors', () => {
+ setupTestingModule({imageLoader});
- const template = `
+ const template = `
`;
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ 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`);
- });
+ 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});
+ it('should set the `srcset` using the `ngSrcset` value with density descriptors', () => {
+ setupTestingModule({imageLoader});
- const template = `
+ const template = `
`;
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ 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`);
- });
+ 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});
+ it('should set the `srcset` if `ngSrcset` has only one src defined', () => {
+ setupTestingModule({imageLoader});
- const template = `
+ const template = `
`;
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ 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`);
- });
+ 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});
+ it('should set the `srcset` if `ngSrcSet` has extra spaces', () => {
+ setupTestingModule({imageLoader});
- const template = `
+ const template = `
`;
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ 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`);
- });
+ 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});
+ it('should set the `srcset` if `ngSrcSet` has a trailing comma', () => {
+ setupTestingModule({imageLoader});
- const template = `
+ const template = `
`;
- const fixture = createTestComponent(template);
- fixture.detectChanges();
+ 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`);
- });
+ 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});
+ it('should set the `srcset` if `ngSrcSet` has 3+ srcs', () => {
+ setupTestingModule({imageLoader});
- const template = `
+ 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=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`);
+ 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 \`srcset\` value directly.');
+ });
+ it('should throw if a complex `sizes` is used with ngSrcset', () => {
+ 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 \`srcset\` value directly.');
+ });
+ it('should not throw if a complex `sizes` is used with a custom srcset', () => {
+ 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 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(
+ 'http://localhost/img?w=640 640w, http://localhost/img?w=750 750w, http://localhost/img?w=828 828w, http://localhost/img?w=1080 1080w, http://localhost/img?w=1200 1200w, http://localhost/img?w=1920 1920w, http://localhost/img?w=2048 2048w, http://localhost/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(
+ 'http://localhost/img?w=16 16w, http://localhost/img?w=32 32w, http://localhost/img?w=48 48w, http://localhost/img?w=64 64w, http://localhost/img?w=96 96w, http://localhost/img?w=128 128w, http://localhost/img?w=256 256w, http://localhost/img?w=384 384w, http://localhost/img?w=640 640w, http://localhost/img?w=750 750w, http://localhost/img?w=828 828w, http://localhost/img?w=1080 1080w, http://localhost/img?w=1200 1200w, http://localhost/img?w=1920 1920w, http://localhost/img?w=2048 2048w, http://localhost/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('http://localhost/img?w=100 1x, http://localhost/img?w=200 2x');
+ });
+
+ 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(
+ 'http://localhost/img?w=16 16w, http://localhost/img?w=32 32w, http://localhost/img?w=48 48w, http://localhost/img?w=64 64w, http://localhost/img?w=96 96w, http://localhost/img?w=128 128w, http://localhost/img?w=256 256w, http://localhost/img?w=384 384w, http://localhost/img?w=640 640w, http://localhost/img?w=1280 1280w, http://localhost/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(
+ 'http://localhost/img?w=16 16w, http://localhost/img?w=48 48w, http://localhost/img?w=640 640w, http://localhost/img?w=1280 1280w, http://localhost/img?w=3840 3840w');
+ });
+
+ it('should disable automatic srcset generation if "unoptimized" 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
@@ -1059,8 +1213,12 @@ class TestComponent {
priority = false;
}
-function setupTestingModule(
- config?: {imageLoader?: ImageLoader, extraProviders?: Provider[], component?: Type}) {
+function setupTestingModule(config?: {
+ imageConfig?: ImageConfig,
+ imageLoader?: ImageLoader,
+ extraProviders?: Provider[],
+ component?: Type
+}) {
const defaultLoader = (config: ImageLoaderConfig) => {
const isAbsolute = /^https?:\/\//.test(config.src);
return isAbsolute ? config.src : window.location.origin + '/' + config.src;
@@ -1072,6 +1230,9 @@ function setupTestingModule(
{provide: IMAGE_LOADER, useValue: loader},
...extraProviders,
];
+ if (config?.imageConfig) {
+ providers.push({provide: IMAGE_CONFIG, useValue: config.imageConfig});
+ }
TestBed.configureTestingModule({
declarations: [config?.component ?? TestComponent],