refactor(common): various NgOptimizedImage directive updates (#47082)

This commit contains a set of small changes based on the feedback received in https://github.com/angular/angular/pull/47082.

PR Close #47082
This commit is contained in:
Andrew Kushnir
2022-08-11 19:00:08 -07:00
committed by Pawel Kozlowski
parent aa254244f4
commit 213f57cb3d
12 changed files with 189 additions and 138 deletions
@@ -11,15 +11,15 @@ import {ɵRuntimeError as RuntimeError} from '@angular/core';
import {RuntimeErrorCode} from '../../errors';
/**
* Asserts whether an ngDevMode is enabled and throws an error if it's not the case.
* This assert can be used to make sure that there is no dev-mode code invoked in
* Asserts that the application is in development mode. Throws an error if the application is in
* production mode. This assert can be used to make sure that there is no dev-mode code invoked in
* the prod mode accidentally.
*/
export function assertDevMode(check: string) {
export function assertDevMode(checkName: string) {
if (!ngDevMode) {
throw new RuntimeError(
RuntimeErrorCode.UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE,
`Unexpected invocation of the ${check} in the prod mode. ` +
`Unexpected invocation of the ${checkName} in the prod mode. ` +
`Please make sure that the prod mode is enabled for production builds.`);
}
}
@@ -9,23 +9,26 @@
import {createImageLoader, ImageLoaderConfig} from './image_loader';
/**
* Function that generates a built-in ImageLoader for Cloudflare Image Resizing
* Function that generates an ImageLoader for Cloudflare Image Resizing
* and turns it into an Angular provider. Note: Cloudflare has multiple image
* products - this provider is specifically for Cloudflare Image Resizing;
* it will not work with Cloudflare Images or Cloudflare Polish.
*
* @param path Your domain name
* e.g. https://mysite.com
* @param path Your domain name, e.g. https://mysite.com
* @param options An object with extra configuration:
* - `ensurePreconnect`: boolean flag indicating whether the NgOptimizedImage directive
* should verify that there is a corresponding `<link rel="preconnect">`
* present in the document's `<head>`.
* @returns Provider that provides an ImageLoader function
*
* @publicApi
* @developerPreview
*/
export const provideCloudflareLoader = createImageLoader(
createCloudflareURL,
createCloudflareUrl,
ngDevMode ? ['https://<ZONE>/cdn-cgi/image/<OPTIONS>/<SOURCE-IMAGE>'] : undefined);
function createCloudflareURL(path: string, config: ImageLoaderConfig) {
function createCloudflareUrl(path: string, config: ImageLoaderConfig) {
let params = `format=auto`;
if (config.width) {
params += `,width=${config.width}`;
@@ -9,15 +9,14 @@
import {createImageLoader, ImageLoaderConfig} from './image_loader';
/**
* Function that generates a built-in ImageLoader for Cloudinary
* and turns it into an Angular provider.
* Function that generates an ImageLoader for Cloudinary and turns it into an Angular provider.
*
* @param path Base URL of your Cloudinary images
* This URL should match one of the following formats:
* https://res.cloudinary.com/mysite
* https://mysite.cloudinary.com
* https://subdomain.mysite.com
* @param options An object that allows to provide extra configuration:
* @param options An object with extra configuration:
* - `ensurePreconnect`: boolean flag indicating whether the NgOptimizedImage directive
* should verify that there is a corresponding `<link rel="preconnect">`
* present in the document's `<head>`.
@@ -27,7 +26,7 @@ import {createImageLoader, ImageLoaderConfig} from './image_loader';
* @developerPreview
*/
export const provideCloudinaryLoader = createImageLoader(
createCloudinaryURL,
createCloudinaryUrl,
ngDevMode ?
[
'https://res.cloudinary.com/mysite', 'https://mysite.cloudinary.com',
@@ -35,7 +34,7 @@ export const provideCloudinaryLoader = createImageLoader(
] :
undefined);
function createCloudinaryURL(path: string, config: ImageLoaderConfig) {
function createCloudinaryUrl(path: string, config: ImageLoaderConfig) {
// Example of a Cloudinary image URL:
// https://res.cloudinary.com/mysite/image/upload/c_scale,f_auto,q_auto,w_600/marketing/tile-topics-m.png
let params = `f_auto,q_auto`; // sets image format and quality to "auto"
@@ -19,9 +19,13 @@ import {isAbsoluteUrl, isValidPath, normalizePath, normalizeSrc} from '../url';
* @developerPreview
*/
export interface ImageLoaderConfig {
// Name of the image to be added to the image request URL
/**
* Image file name to be added to the image request URL.
*/
src: string;
// Width of the requested image (to be used when generating srcset)
/**
* Width of the requested image (to be used when generating srcset).
*/
width?: number;
}
@@ -40,8 +44,8 @@ export type ImageLoader = (config: ImageLoaderConfig) => string;
const noopImageLoader = (config: ImageLoaderConfig) => config.src;
/**
* Special token that allows to configure a function that will be used to produce an image URL based
* on the specified input.
* Injection token that configures the function that produces image URLs based on the specified
* input.
*
* @publicApi
* @developerPreview
@@ -62,6 +66,11 @@ export function createImageLoader(
const loaderFn = (config: ImageLoaderConfig) => {
if (ngDevMode && isAbsoluteUrl(config.src)) {
// Image loader functions expect an image file name (e.g. `my-image.png`)
// or a relative path + a file name (e.g. `/a/b/c/my-image.png`) as an input,
// so the final absolute URL can be constructed.
// When an absolute URL is provided instead - the loader can not
// build a final URL, thus the error is thrown to indicate that.
throwUnexpectedAbsoluteUrlError(path, config.src);
}
@@ -9,14 +9,13 @@
import {createImageLoader, ImageLoaderConfig} from './image_loader';
/**
* Function that generates a built-in ImageLoader for ImageKit
* and turns it into an Angular provider.
* Function that generates an ImageLoader for ImageKit and turns it into an Angular provider.
*
* @param path Base URL of your ImageKit images
* This URL should match one of the following formats:
* https://ik.imagekit.io/myaccount
* https://subdomain.mysite.com
* @param options An object that allows to provide extra configuration:
* @param options An object with extra configuration:
* - `ensurePreconnect`: boolean flag indicating whether the NgOptimizedImage directive
* should verify that there is a corresponding `<link rel="preconnect">`
* present in the document's `<head>`.
@@ -26,10 +25,10 @@ import {createImageLoader, ImageLoaderConfig} from './image_loader';
* @developerPreview
*/
export const provideImageKitLoader = createImageLoader(
createImagekitURL,
createImagekitUrl,
ngDevMode ? ['https://ik.imagekit.io/mysite', 'https://subdomain.mysite.com'] : undefined);
export function createImagekitURL(path: string, config: ImageLoaderConfig) {
export function createImagekitUrl(path: string, config: ImageLoaderConfig) {
// Example of an ImageKit image URL:
// https://ik.imagekit.io/demo/tr:w-300,h-300/medium_cafe_B1iTdD0C.jpg
let params = `tr:q-auto`; // applies the "auto quality" transformation
@@ -9,12 +9,11 @@
import {createImageLoader, ImageLoaderConfig} from './image_loader';
/**
* Function that generates a built-in ImageLoader for Imgix and turns it
* into an Angular provider.
* Function that generates an ImageLoader for Imgix and turns it into an Angular provider.
*
* @param path path to the desired Imgix origin,
* e.g. https://somepath.imgix.net or https://images.mysite.com
* @param options An object that allows to provide extra configuration:
* @param options An object with extra configuration:
* - `ensurePreconnect`: boolean flag indicating whether the NgOptimizedImage directive
* should verify that there is a corresponding `<link rel="preconnect">`
* present in the document's `<head>`.
@@ -24,9 +23,9 @@ import {createImageLoader, ImageLoaderConfig} from './image_loader';
* @developerPreview
*/
export const provideImgixLoader =
createImageLoader(createImgixURL, ngDevMode ? ['https://somepath.imgix.net/'] : undefined);
createImageLoader(createImgixUrl, ngDevMode ? ['https://somepath.imgix.net/'] : undefined);
function createImgixURL(path: string, config: ImageLoaderConfig) {
function createImgixUrl(path: string, config: ImageLoaderConfig) {
const url = new URL(`${path}/${config.src}`);
// This setting ensures the smallest allowable format is set.
url.searchParams.set('auto', 'format');
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Inject, Injectable, OnDestroy, ɵformatRuntimeError as formatRuntimeError} from '@angular/core';
import {inject, Injectable, OnDestroy, ɵformatRuntimeError as formatRuntimeError} from '@angular/core';
import {DOCUMENT} from '../../dom_tokens';
import {RuntimeErrorCode} from '../../errors';
@@ -16,11 +16,11 @@ import {imgDirectiveDetails} from './error_helper';
import {getUrl} from './url';
/**
* Contains the logic to detect whether an image with the `NgOptimizedImage` directive
* is treated as an LCP element. If so, verifies that the image is marked as a priority,
* using the `priority` attribute.
* Observer that detects whether an image with `NgOptimizedImage`
* is treated as a Largest Contentful Paint (LCP) element. If so,
* asserts that the image has the `priority` attribute.
*
* Note: this is a dev-mode only class, which should not appear in prod bundles,
* Note: this is a dev-mode only class and it does not appear in prod bundles,
* thus there is no `ngDevMode` use in the code.
*
* Based on https://web.dev/lcp/#measure-lcp-in-javascript.
@@ -35,22 +35,24 @@ export class LCPImageObserver implements OnDestroy {
private window: Window|null = null;
private observer: PerformanceObserver|null = null;
constructor(@Inject(DOCUMENT) doc: Document) {
constructor() {
assertDevMode('LCP checker');
const win = doc.defaultView;
const win = inject(DOCUMENT).defaultView;
if (typeof win !== 'undefined' && typeof PerformanceObserver !== 'undefined') {
this.window = win;
this.observer = this.initPerformanceObserver();
}
}
// Inits PerformanceObserver and subscribes to LCP events.
// Based on https://web.dev/lcp/#measure-lcp-in-javascript
/**
* Inits PerformanceObserver and subscribes to LCP events.
* Based on https://web.dev/lcp/#measure-lcp-in-javascript
*/
private initPerformanceObserver(): PerformanceObserver {
const observer = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
if (entries.length === 0) return;
// Note: we use the latest entry produced by the `PerformanceObserver` as the best
// We use the latest entry produced by the `PerformanceObserver` as the best
// signal on which element is actually an LCP one. As an example, the first image to load on
// a page, by virtue of being the only thing on the page so far, is often a LCP candidate
// and gets reported by PerformanceObserver, but isn't necessarily the LCP element.
@@ -64,13 +66,7 @@ export class LCPImageObserver implements OnDestroy {
const imgRawSrc = this.images.get(imgSrc);
if (imgRawSrc && !this.alreadyWarned.has(imgSrc)) {
this.alreadyWarned.add(imgSrc);
const directiveDetails = imgDirectiveDetails(imgRawSrc);
console.warn(formatRuntimeError(
RuntimeErrorCode.LCP_IMG_MISSING_PRIORITY,
`${directiveDetails} this image is the Largest Contentful Paint (LCP) ` +
`element but was not marked "priority". This image should be marked ` +
`"priority" in order to prioritize its loading. ` +
`To fix this, add the "priority" attribute.`));
logMissingPriorityWarning(imgSrc);
}
});
observer.observe({type: 'largest-contentful-paint', buffered: true});
@@ -94,3 +90,13 @@ export class LCPImageObserver implements OnDestroy {
this.alreadyWarned.clear();
}
}
function logMissingPriorityWarning(rawSrc: string) {
const directiveDetails = imgDirectiveDetails(rawSrc);
console.warn(formatRuntimeError(
RuntimeErrorCode.LCP_IMG_MISSING_PRIORITY,
`${directiveDetails} this image is the Largest Contentful Paint (LCP) ` +
`element but was not marked "priority". This image should be marked ` +
`"priority" in order to prioritize its loading. ` +
`To fix this, add the "priority" attribute.`));
}
@@ -6,12 +6,12 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Directive, ElementRef, Inject, Injector, Input, NgModule, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
import {Directive, ElementRef, inject, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
import {RuntimeErrorCode} from '../../errors';
import {imgDirectiveDetails} from './error_helper';
import {IMAGE_LOADER, ImageLoader} from './image_loaders/image_loader';
import {IMAGE_LOADER} from './image_loaders/image_loader';
import {LCPImageObserver} from './lcp_image_observer';
import {PreconnectLinkChecker} from './preconnect_link_checker';
@@ -55,15 +55,16 @@ export const RECOMMENDED_SRCSET_DENSITY_CAP = 2;
const ASPECT_RATIO_TOLERANCE = .1;
/**
* The directive that helps to improve image loading performance by following best practices.
* Directive that improves image loading performance by enforcing best practices.
*
* The `NgOptimizedImage` ensures that the loading of the LCP image is prioritized by:
* `NgOptimizedImage` ensures that the loading of the Largest Contentful Paint (LCP) image is
* prioritized by:
* - Automatically setting the `fetchpriority` attribute on the `<img>` tag
* - Lazy loading non-priority images by default
* - Asserting that there is a corresponding preconnect link tag in the document head
*
* In addition, the directive:
* - Generates appropriate asset URLs (if a corresponding loader function is provided)
* - Generates appropriate asset URLs if a corresponding `ImageLoader` function is provided
* - 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
@@ -74,8 +75,10 @@ const ASPECT_RATIO_TOLERANCE = .1;
*
* Follow the steps below to enable and use the directive:
* 1. Import it into the necessary NgModule or a standalone Component.
* 2. Configure a loader that you want to use.
* 2. Optionally provide an `ImageLoader` if you use an image hosting service.
* 3. Update the necessary `<img>` tags in templates and replace `src` attributes with `rawSrc`.
* Using a `rawSrc` allows the directive to control when the `src` gets set, which triggers an image
* download.
*
* Step 1: import the `NgOptimizedImage` directive.
*
@@ -140,7 +143,7 @@ const ASPECT_RATIO_TOLERANCE = .1;
* ],
* ```
*
* Step 3: update `<img>` tags in templates to use `rawSrc` instead of `rawSrc`.
* Step 3: update `<img>` tags in templates to use `rawSrc` instead of `src`.
*
* ```
* <img rawSrc="logo.png" width="200" height="100">
@@ -154,20 +157,18 @@ const ASPECT_RATIO_TOLERANCE = .1;
selector: 'img[rawSrc]',
})
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
constructor(
@Inject(IMAGE_LOADER) private imageLoader: ImageLoader, private renderer: Renderer2,
private imgElement: ElementRef, private injector: Injector) {}
private imageLoader = inject(IMAGE_LOADER);
private renderer = inject(Renderer2);
private imgElement: HTMLImageElement = inject(ElementRef).nativeElement;
private injector = inject(Injector);
// Private fields to keep normalized input values.
private _width?: number;
private _height?: number;
private _priority = false;
// Calculate the rewritten `src` once and store it.
// This is needed to avoid repetitive calculations and make sure the directive cleanup in the
// `ngOnDestroy` does not rely on the `IMAGE_LOADER` logic (which in turn can rely on some other
// instance that might be already destroyed).
private _rewrittenSrc: string|null = null;
/**
* Calculate the rewritten `src` once and store it.
* This is needed to avoid repetitive calculations and make sure the directive cleanup in the
* `ngOnDestroy` does not rely on the `IMAGE_LOADER` logic (which in turn can rely on some other
* instance that might be already destroyed).
*/
private _renderedSrc: string|null = null;
/**
* Name of the source image.
@@ -190,34 +191,36 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
@Input() rawSrcset!: string;
/**
* The intrinsic width of the image in px.
* The intrinsic width of the image in pixels.
*/
@Input()
set width(value: string|number|undefined) {
ngDevMode && assertGreaterThanZeroNumber(this, value, 'width');
ngDevMode && assertGreaterThanZero(this, value, 'width');
this._width = inputToInteger(value);
}
get width(): number|undefined {
return this._width;
}
private _width?: number;
/**
* The intrinsic height of the image in px.
* The intrinsic height of the image in pixels.
*/
@Input()
set height(value: string|number|undefined) {
ngDevMode && assertGreaterThanZeroNumber(this, value, 'height');
ngDevMode && assertGreaterThanZero(this, value, 'height');
this._height = inputToInteger(value);
}
get height(): number|undefined {
return this._height;
}
private _height?: number;
/**
* The desired loading behavior (lazy, eager, or auto).
* The primary use case for this input is opting-out non-priority images
* from lazy loading by marking them loading='eager' or loading='auto'.
* This input should not be used with priority images.
*
* Setting images as loading='eager' or loading='auto' marks them
* as non-priority images. Avoid changing this input for priority images.
*/
@Input() loading?: string;
@@ -231,16 +234,22 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
get priority(): boolean {
return this._priority;
}
private _priority = false;
/**
* Get a value of the `src` and `srcset` if they're set on a host `<img>` element.
* These inputs are needed to verify that there are no conflicting sources provided
* at the same time (e.g. `src` and `rawSrc` together or `srcset` and `rawSrcset`,
* thus causing an ambiguity on which src to use) and that images
* don't start to load until a lazy loading strategy is set.
* Value of the `src` attribute if set on the host `<img>` element.
* This input is exclusively read to assert that `src` is not set in conflict
* with `rawSrc` and that images don't start to load until a lazy loading strategy is set.
* @internal
*/
@Input() src?: string;
/**
* Value of the `srcset` attribute if set on the host `<img>` element.
* This input is exclusively read to assert that `srcset` is not set in conflict
* with `rawSrcset` and that images don't start to load until a lazy loading strategy is set.
* @internal
*/
@Input() srcset?: string;
ngOnInit() {
@@ -250,7 +259,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
assertNoConflictingSrc(this);
assertNoConflictingSrcset(this);
assertNotBase64Image(this);
assertNotBlobURL(this);
assertNotBlobUrl(this);
assertNonEmptyWidthAndHeight(this);
assertValidLoadingInput(this);
assertNoImageDistortion(this, this.imgElement, this.renderer);
@@ -304,12 +313,12 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
// ImageLoaderConfig supports setting a width property. However, we're not setting width here
// because if the developer uses rendered width instead of intrinsic width in the HTML width
// attribute, the image requested may be too small for 2x+ screens.
if (!this._rewrittenSrc) {
if (!this._renderedSrc) {
const imgConfig = {src: this.rawSrc};
// Cache calculated image src to reuse it later in the code.
this._rewrittenSrc = this.imageLoader(imgConfig);
this._renderedSrc = this.imageLoader(imgConfig);
}
return this._rewrittenSrc;
return this._renderedSrc;
}
private getRewrittenSrcset(): string {
@@ -324,27 +333,31 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
ngOnDestroy() {
if (ngDevMode) {
if (!this.priority && this._rewrittenSrc !== null) {
if (!this.priority && this._renderedSrc !== null) {
withLCPImageObserver(
this.injector,
(observer: LCPImageObserver) => observer.unregisterImage(this._rewrittenSrc!));
(observer: LCPImageObserver) => observer.unregisterImage(this._renderedSrc!));
}
}
}
private setHostAttribute(name: string, value: string): void {
this.renderer.setAttribute(this.imgElement.nativeElement, name, value);
this.renderer.setAttribute(this.imgElement, name, value);
}
}
/***** Helpers *****/
// Convert input value to integer.
/**
* Convert input value to integer.
*/
function inputToInteger(value: string|number|undefined): number|undefined {
return typeof value === 'string' ? parseInt(value, 10) : value;
}
// Convert input value to boolean.
/**
* Convert input value to boolean.
*/
function inputToBoolean(value: unknown): boolean {
return value != null && `${value}` !== 'false';
}
@@ -376,31 +389,37 @@ function withLCPImageObserver(
/***** Assert functions *****/
// Verifies that there is no `src` set on a host element.
/**
* Verifies that there is no `src` set on a host element.
*/
function assertNoConflictingSrc(dir: NgOptimizedImage) {
if (dir.src) {
throw new RuntimeError(
RuntimeErrorCode.UNEXPECTED_SRC_ATTR,
`${imgDirectiveDetails(dir.rawSrc)} both \`src\` and \`rawSrc\` have been set. ` +
`Supplying both of these attributes is not necessary and will break lazy loading. ` +
`The NgOptimizedImage directive will set \`src\` itself based on the value of \`rawSrc\`. ` +
`Supplying both of these attributes breaks lazy loading. ` +
`The NgOptimizedImage directive sets \`src\` itself based on the value of \`rawSrc\`. ` +
`To fix this, please remove the \`src\` attribute.`);
}
}
// Verifies that there is no `srcset` set on a host element.
/**
* Verifies that there is no `srcset` set on a host element.
*/
function assertNoConflictingSrcset(dir: NgOptimizedImage) {
if (dir.srcset) {
throw new RuntimeError(
RuntimeErrorCode.UNEXPECTED_SRCSET_ATTR,
`${imgDirectiveDetails(dir.rawSrc)} both \`srcset\` and \`rawSrcset\` have been set. ` +
`Supplying both of these attributes is not necessary and will break lazy loading. ` +
`The NgOptimizedImage directive will set \`srcset\` itself based on the value of ` +
`Supplying both of these attributes breaks lazy loading. ` +
`The NgOptimizedImage directive sets \`srcset\` itself based on the value of ` +
`\`rawSrcset\`. To fix this, please remove the \`srcset\` attribute.`);
}
}
// Verifies that the `rawSrc` is not a Base64-encoded image.
/**
* Verifies that the `rawSrc` is not a Base64-encoded image.
*/
function assertNotBase64Image(dir: NgOptimizedImage) {
let rawSrc = dir.rawSrc.trim();
if (rawSrc.startsWith('data:')) {
@@ -416,8 +435,10 @@ function assertNotBase64Image(dir: NgOptimizedImage) {
}
}
// Verifies that the `rawSrc` is not a Blob URL.
function assertNotBlobURL(dir: NgOptimizedImage) {
/**
* Verifies that the `rawSrc` is not a Blob URL.
*/
function assertNotBlobUrl(dir: NgOptimizedImage) {
const rawSrc = dir.rawSrc.trim();
if (rawSrc.startsWith('blob:')) {
throw new RuntimeError(
@@ -429,7 +450,9 @@ function assertNotBlobURL(dir: NgOptimizedImage) {
}
}
// Verifies that the input is set to a non-empty string.
/**
* Verifies that the input is set to a non-empty string.
*/
function assertNonEmptyInput(dir: NgOptimizedImage, name: string, value: unknown) {
const isString = typeof value === 'string';
const isEmptyString = isString && value.trim() === '';
@@ -441,7 +464,9 @@ function assertNonEmptyInput(dir: NgOptimizedImage, name: string, value: unknown
}
}
// Verifies that the `rawSrcset` is in a valid format, e.g. "100w, 200w" or "1x, 2x"
/**
* Verifies that the `rawSrcset` is in a valid format, e.g. "100w, 200w" or "1x, 2x".
*/
export function assertValidRawSrcset(dir: NgOptimizedImage, value: unknown) {
if (value == null) return;
assertNonEmptyInput(dir, 'rawSrcset', value);
@@ -481,8 +506,10 @@ function assertUnderDensityCap(dir: NgOptimizedImage, value: string) {
}
}
// Creates a `RuntimeError` instance to represent a situation when an input is set after
// the directive has initialized.
/**
* Creates a `RuntimeError` instance to represent a situation when an input is set after
* the directive has initialized.
*/
function postInitInputChangeError(dir: NgOptimizedImage, inputName: string): {} {
return new RuntimeError(
RuntimeErrorCode.UNEXPECTED_INPUT_CHANGE,
@@ -492,7 +519,9 @@ function postInitInputChangeError(dir: NgOptimizedImage, inputName: string): {}
`in an *ngIf that is gated on the necessary value.`);
}
// Verify that none of the listed inputs has changed.
/**
* Verify that none of the listed inputs has changed.
*/
function assertNoPostInitInputChange(
dir: NgOptimizedImage, changes: SimpleChanges, inputs: string[]) {
inputs.forEach(input => {
@@ -510,9 +539,10 @@ function assertNoPostInitInputChange(
});
}
// Verifies that a specified input is a number greater than 0.
function assertGreaterThanZeroNumber(
dir: NgOptimizedImage, inputValue: unknown, inputName: string) {
/**
* Verifies that a specified input is a number greater than 0.
*/
function assertGreaterThanZero(dir: NgOptimizedImage, inputValue: unknown, inputName: string) {
const validNumber = typeof inputValue === 'number' && inputValue > 0;
const validString =
typeof inputValue === 'string' && /^\d+$/.test(inputValue.trim()) && parseInt(inputValue) > 0;
@@ -525,21 +555,25 @@ function assertGreaterThanZeroNumber(
}
}
// Verifies that the rendered image is not visually distorted. Effectively this is checking:
// - Whether the "width" and "height" attributes reflect the actual dimensions of the image.
// - Whether image styling is "correct" (see below for a longer explanation).
/**
* Verifies that the rendered image is not visually distorted. Effectively this is checking:
* - Whether the "width" and "height" attributes reflect the actual dimensions of the image.
* - Whether image styling is "correct" (see below for a longer explanation).
*/
function assertNoImageDistortion(
dir: NgOptimizedImage, element: ElementRef<any>, renderer: Renderer2) {
const img = element.nativeElement;
dir: NgOptimizedImage, img: HTMLImageElement, renderer: Renderer2) {
const removeListenerFn = renderer.listen(img, 'load', () => {
removeListenerFn();
const renderedWidth = parseFloat(img.clientWidth);
const renderedHeight = parseFloat(img.clientHeight);
// TODO: `clientWidth`, `clientHeight`, `naturalWidth` and `naturalHeight`
// are typed as number, but we run `parseFloat` (which accepts strings only).
// Verify whether `parseFloat` is needed in the cases below.
const renderedWidth = parseFloat(img.clientWidth as any);
const renderedHeight = parseFloat(img.clientHeight as any);
const renderedAspectRatio = renderedWidth / renderedHeight;
const nonZeroRenderedDimensions = renderedWidth !== 0 && renderedHeight !== 0;
const intrinsicWidth = parseFloat(img.naturalWidth);
const intrinsicHeight = parseFloat(img.naturalHeight);
const intrinsicWidth = parseFloat(img.naturalWidth as any);
const intrinsicHeight = parseFloat(img.naturalHeight as any);
const intrinsicAspectRatio = intrinsicWidth / intrinsicHeight;
const suppliedWidth = dir.width!;
@@ -583,7 +617,9 @@ function assertNoImageDistortion(
});
}
// Verifies that a specified input is set.
/**
* Verifies that a specified input is set.
*/
function assertNonEmptyWidthAndHeight(dir: NgOptimizedImage) {
let missingAttributes = [];
if (dir.width === undefined) missingAttributes.push('width');
@@ -598,8 +634,10 @@ function assertNonEmptyWidthAndHeight(dir: NgOptimizedImage) {
}
}
// Verifies that the `loading` attribute is set to a valid input &
// is not used on priority images.
/**
* Verifies that the `loading` attribute is set to a valid input &
* is not used on priority images.
*/
function assertValidLoadingInput(dir: NgOptimizedImage) {
if (dir.loading && dir.priority) {
throw new RuntimeError(
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Inject, Injectable, InjectionToken, Optional, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
import {inject, Injectable, InjectionToken, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
import {DOCUMENT} from '../../dom_tokens';
import {RuntimeErrorCode} from '../../errors';
@@ -49,25 +49,28 @@ export const PRECONNECT_CHECK_BLOCKLIST =
*/
@Injectable({providedIn: 'root'})
export class PreconnectLinkChecker {
// Set of <link rel="preconnect"> tags found on this page.
// The `null` value indicates that there was no DOM query operation performed.
/**
* Set of <link rel="preconnect"> tags found on this page.
* The `null` value indicates that there was no DOM query operation performed.
*/
private preconnectLinks: Set<string>|null = null;
// Keep track of all already seen origin URLs to avoid repeating the same check.
/*
* Keep track of all already seen origin URLs to avoid repeating the same check.
*/
private alreadySeen = new Set<string>();
private window: Window|null = null;
private blocklist = new Set<string>(INTERNAL_PRECONNECT_CHECK_BLOCKLIST);
constructor(
@Inject(DOCUMENT) private doc: Document,
@Optional() @Inject(PRECONNECT_CHECK_BLOCKLIST) blocklist: Array<string|string[]>|null) {
constructor() {
assertDevMode('preconnect link checker');
const win = doc.defaultView;
const win = inject(DOCUMENT).defaultView;
if (typeof win !== 'undefined') {
this.window = win;
}
const blocklist = inject(PRECONNECT_CHECK_BLOCKLIST, {optional: true});
if (blocklist) {
this.populateBlocklist(blocklist);
}
@@ -124,8 +127,7 @@ export class PreconnectLinkChecker {
private queryPreconnectLinks(): Set<string> {
const preconnectUrls = new Set<string>();
const selector = 'link[rel=preconnect]';
const links =
Array.from(document.querySelectorAll('link[rel=preconnect]')) as HTMLLinkElement[];
const links: HTMLLinkElement[] = Array.from(document.querySelectorAll(selector));
for (let link of links) {
const url = getUrl(link.href, this.window!);
preconnectUrls.add(url.origin);
@@ -139,8 +141,10 @@ export class PreconnectLinkChecker {
}
}
// Invokes a callback for each element in the array. Also invokes a callback
// recursively for each nested array.
/**
* Invokes a callback for each element in the array. Also invokes a callback
* recursively for each nested array.
*/
function deepForEach<T>(input: (T|any[])[], fn: (value: T) => void): void {
for (let value of input) {
Array.isArray(value) ? deepForEach(value, fn) : fn(value);