refactor(common): throw an error if NgOptimizeImage inputs change (#47082)

This commit updates the `NgOptimizeImage` directive to add asserts to make sure no inputs are changed after directive initialization.

PR Close #47082
This commit is contained in:
Andrew Kushnir
2022-05-03 18:06:20 -07:00
committed by Pawel Kozlowski
parent c2854e8ef6
commit 2e0d09354f
4 changed files with 120 additions and 4 deletions
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Directive, ElementRef, Inject, InjectionToken, Input, NgModule, OnInit, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core';
import {Directive, ElementRef, Inject, InjectionToken, Input, NgModule, OnChanges, OnInit, Renderer2, SimpleChanges, ɵRuntimeError as RuntimeError} from '@angular/core';
import {RuntimeErrorCode} from '../errors';
@@ -63,7 +63,7 @@ export const IMAGE_LOADER = new InjectionToken<ImageLoader>('ImageLoader', {
standalone: true,
selector: 'img[rawSrc]',
})
export class NgOptimizedImage implements OnInit {
export class NgOptimizedImage implements OnInit, OnChanges {
constructor(
@Inject(IMAGE_LOADER) private imageLoader: ImageLoader, private renderer: Renderer2,
private imgElement: ElementRef) {}
@@ -104,6 +104,9 @@ export class NgOptimizedImage implements OnInit {
return this._height;
}
/**
* Indicates whether this image should have a high priority.
*/
@Input()
set priority(value: string|boolean|undefined) {
this._priority = inputToBoolean(value);
@@ -116,15 +119,23 @@ export class NgOptimizedImage implements OnInit {
* Get a value of the `src` if it's set on a host <img> element.
* This input is needed to verify that there are no `src` and `rawSrc` provided
* at the same time (thus causing an ambiguity on which src to use).
* @internal
*/
@Input() src?: string;
ngOnInit() {
if (ngDevMode) {
assertExistingSrc(this);
assertValidRawSrc(this.rawSrc);
assertNoConflictingSrc(this);
assertNotBase64Image(this);
assertNotBlobURL(this);
}
}
ngOnChanges(changes: SimpleChanges) {
if (ngDevMode) {
assertNoPostInitInputChange(this, changes, ['rawSrc', 'width', 'height', 'priority']);
}
this.setHostAttribute('loading', this.getLoadingBehavior());
this.setHostAttribute('fetchpriority', this.getFetchPriority());
// The `src` attribute should be set last since other attributes
@@ -175,7 +186,7 @@ function imgDirectiveDetails(dir: NgOptimizedImage) {
/***** Assert functions *****/
// Verifies that there is no `src` set on a host element.
function assertExistingSrc(dir: NgOptimizedImage) {
function assertNoConflictingSrc(dir: NgOptimizedImage) {
if (dir.src) {
throw new RuntimeError(
RuntimeErrorCode.UNEXPECTED_SRC_ATTR,
@@ -215,3 +226,43 @@ function assertNotBlobURL(dir: NgOptimizedImage) {
`for this element.`);
}
}
// Verifies that the `rawSrc` is set to a non-empty string.
function assertValidRawSrc(value: unknown) {
const isString = typeof value === 'string';
const isEmptyString = isString && value.trim() === '';
if (!isString || isEmptyString) {
const extraMessage = isEmptyString ? ' (empty string)' : '';
throw new RuntimeError(
RuntimeErrorCode.INVALID_INPUT,
`The NgOptimizedImage directive detected that the \`rawSrc\` has invalid value: ` +
`expecting a non-empty string, but got: \`${value}\`${extraMessage}.`);
}
}
// 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,
`${imgDirectiveDetails(dir)} detected that the \`${inputName}\` is updated after the ` +
`initialization. The NgOptimizedImage directive will not react to this input change.`);
}
// Verify that none of the listed inputs has changed.
function assertNoPostInitInputChange(
dir: NgOptimizedImage, changes: SimpleChanges, inputs: string[]) {
inputs.forEach(input => {
const isUpdated = changes.hasOwnProperty(input);
if (isUpdated && !changes[input].isFirstChange()) {
if (input === 'rawSrc') {
// When the `rawSrc` input changes, we detect that only in the
// `ngOnChanges` hook, thus the `rawSrc` is already set. We use
// `rawSrc` in the error message, so we use a previous value, but
// not the updated one in it.
dir = {rawSrc: changes[input].previousValue} as NgOptimizedImage;
}
throw postInitInputChangeError(dir, input);
}
});
}
+1
View File
@@ -23,4 +23,5 @@ export const enum RuntimeErrorCode {
// Image directive errors
UNEXPECTED_SRC_ATTR = 2950,
INVALID_INPUT = 2951,
UNEXPECTED_INPUT_CHANGE = 2952,
}
@@ -147,6 +147,64 @@ describe('Image directive', () => {
done();
});
it('should throw if `rawSrc` value is not provided', () => {
setupTestingModule();
const template = '<img rawSrc>';
expect(() => {
const fixture = createTestComponent(template);
fixture.detectChanges();
})
.toThrowError(
'NG02951: The NgOptimizedImage directive detected that the `rawSrc` ' +
'has invalid value: expecting a non-empty string, but got: `` (empty string).');
});
it('should throw if `rawSrc` value is set to an empty string', () => {
setupTestingModule();
const template = '<img rawSrc=" ">';
expect(() => {
const fixture = createTestComponent(template);
fixture.detectChanges();
})
.toThrowError(
'NG02951: The NgOptimizedImage directive detected that the `rawSrc` ' +
'has invalid value: expecting a non-empty string, but got: ` ` (empty string).');
});
const inputs = [
['rawSrc', 'new-img.png'], //
['width', 10], //
['height', 20], //
['priority', true]
];
inputs.forEach(([inputName, value]) => {
it(`should throw if inputs got changed after directive init (the \`${inputName}\` input)`,
() => {
setupTestingModule();
const template =
'<img [rawSrc]="rawSrc" [width]="width" [height]="height" [priority]="priority">';
expect(() => {
// Initial render
const fixture = createTestComponent(template);
fixture.detectChanges();
// Update input (expect to throw)
(fixture.componentInstance as unknown as
{[key: string]: unknown})[inputName as string] = value;
fixture.detectChanges();
})
.toThrowError(
`NG02952: The NgOptimizedImage directive (activated on an <img> element ` +
`with the \`rawSrc="img.png"\`) detected that the \`${
inputName}\` is updated ` +
`after the initialization. The NgOptimizedImage directive will not react ` +
`to this input change.`);
});
});
});
});
@@ -211,6 +269,10 @@ const ANGULAR_LOGO_BASE64 =
template: '',
})
class TestComponent {
width = 100;
height = 50;
rawSrc = 'img.png';
priority = false;
}
function setupTestingModule(config?: {imageLoader: ImageLoader}) {