From 31155db52349ce4c024ef43319deef90800348ea Mon Sep 17 00:00:00 2001 From: Alex Castle Date: Thu, 6 Oct 2022 16:40:00 -0700 Subject: [PATCH] feat(common): Add automatic srcset generation to ngOptimizedImage Add a feature to automatically generate the srcset attribute for images using the NgOptimizedImage directive. Uses the 'sizes' attribute to determine the appropriate srcset to generate. --- aio/content/guide/image-directive.md | 63 +- .../ng_optimized_image/ng_optimized_image.ts | 128 +- .../directives/ng_optimized_image_spec.ts | 1957 +++++++++-------- 3 files changed, 1244 insertions(+), 904 deletions(-) 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],