fix(common): ensure diffing in ngStyle/ngClass correctly emits value changes (#34307)
Prior to this change, in Ivy mode ngStyle/ngClass would accidentally emit value changes for static (string-based) values even if the value itself had not changed. This patch ensures that the style/class diffing code is more strict and when it signals ngClass/ngStyle that there has been a value change. Fixes #34336, #34444 PR Close #34307
This commit is contained in:
parent
27b9eb5e38
commit
abd4628587
|
@ -60,8 +60,20 @@
|
||||||
export class StylingDiffer<T> {
|
export class StylingDiffer<T> {
|
||||||
public readonly value: T|null = null;
|
public readonly value: T|null = null;
|
||||||
|
|
||||||
private _lastSetValue: {[key: string]: any}|string|string[]|null = null;
|
/**
|
||||||
|
* The last set value that was applied via `setValue()`
|
||||||
|
*/
|
||||||
|
private _lastSetValue: {[key: string]: any}|string|string[]|undefined|null = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of value that the `_lastSetValue` variable is
|
||||||
|
*/
|
||||||
private _lastSetValueType: StylingDifferValueTypes = StylingDifferValueTypes.Null;
|
private _lastSetValueType: StylingDifferValueTypes = StylingDifferValueTypes.Null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the last value change occurred because the variable itself changed reference
|
||||||
|
* (identity)
|
||||||
|
*/
|
||||||
private _lastSetValueIdentityChange = false;
|
private _lastSetValueIdentityChange = false;
|
||||||
|
|
||||||
constructor(private _name: string, private _options: StylingDifferOptions) {}
|
constructor(private _name: string, private _options: StylingDifferOptions) {}
|
||||||
|
@ -75,21 +87,29 @@ export class StylingDiffer<T> {
|
||||||
* @param value the new styling value provided from the ngClass/ngStyle binding
|
* @param value the new styling value provided from the ngClass/ngStyle binding
|
||||||
*/
|
*/
|
||||||
setValue(value: {[key: string]: any}|string[]|string|null) {
|
setValue(value: {[key: string]: any}|string[]|string|null) {
|
||||||
if (Array.isArray(value)) {
|
if (value !== this._lastSetValue) {
|
||||||
this._lastSetValueType = StylingDifferValueTypes.Array;
|
let type: StylingDifferValueTypes;
|
||||||
} else if (value instanceof Set) {
|
if (!value) { // matches empty strings, null, false and undefined
|
||||||
this._lastSetValueType = StylingDifferValueTypes.Set;
|
type = StylingDifferValueTypes.Null;
|
||||||
} else if (value && typeof value === 'string') {
|
value = null;
|
||||||
if (!(this._options & StylingDifferOptions.AllowStringValue)) {
|
} else if (Array.isArray(value)) {
|
||||||
throw new Error(this._name + ' string values are not allowed');
|
type = StylingDifferValueTypes.Array;
|
||||||
|
} else if (value instanceof Set) {
|
||||||
|
type = StylingDifferValueTypes.Set;
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
if (!(this._options & StylingDifferOptions.AllowStringValue)) {
|
||||||
|
throw new Error(this._name + ' string values are not allowed');
|
||||||
|
}
|
||||||
|
type = StylingDifferValueTypes.String;
|
||||||
|
} else {
|
||||||
|
type = StylingDifferValueTypes.StringMap;
|
||||||
}
|
}
|
||||||
this._lastSetValueType = StylingDifferValueTypes.String;
|
|
||||||
} else {
|
|
||||||
this._lastSetValueType = value ? StylingDifferValueTypes.Map : StylingDifferValueTypes.Null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._lastSetValueIdentityChange = true;
|
this._lastSetValueType = type;
|
||||||
this._lastSetValue = value || null;
|
this._lastSetValueIdentityChange = true;
|
||||||
|
this._lastSetValue = value;
|
||||||
|
this._processValueChange(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -104,8 +124,28 @@ export class StylingDiffer<T> {
|
||||||
*/
|
*/
|
||||||
hasValueChanged(): boolean {
|
hasValueChanged(): boolean {
|
||||||
let valueHasChanged = this._lastSetValueIdentityChange;
|
let valueHasChanged = this._lastSetValueIdentityChange;
|
||||||
if (!valueHasChanged && !(this._lastSetValueType & StylingDifferValueTypes.Collection))
|
if (!valueHasChanged && (this._lastSetValueType & StylingDifferValueTypes.Collection)) {
|
||||||
return false;
|
valueHasChanged = this._processValueChange(false);
|
||||||
|
} else {
|
||||||
|
// this is set to false in the event that the value is a collection.
|
||||||
|
// This way (if the identity hasn't changed), then the algorithm can
|
||||||
|
// diff the collection value to see if the contents have mutated
|
||||||
|
// (otherwise the value change was processed during the time when
|
||||||
|
// the variable changed).
|
||||||
|
this._lastSetValueIdentityChange = false;
|
||||||
|
}
|
||||||
|
return valueHasChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Examines the last set value to see if there was a change in data.
|
||||||
|
*
|
||||||
|
* @param hasIdentityChange whether or not the last set value changed in identity or not
|
||||||
|
*
|
||||||
|
* @returns true when the value has changed (either by identity or by shape if its a collection).
|
||||||
|
*/
|
||||||
|
private _processValueChange(hasIdentityChange: boolean) {
|
||||||
|
let valueHasChanged = hasIdentityChange;
|
||||||
|
|
||||||
let finalValue: {[key: string]: any}|string|null = null;
|
let finalValue: {[key: string]: any}|string|null = null;
|
||||||
const trimValues = (this._options & StylingDifferOptions.TrimProperties) ? true : false;
|
const trimValues = (this._options & StylingDifferOptions.TrimProperties) ? true : false;
|
||||||
|
@ -117,25 +157,27 @@ export class StylingDiffer<T> {
|
||||||
case StylingDifferValueTypes.String:
|
case StylingDifferValueTypes.String:
|
||||||
const tokens = (this._lastSetValue as string).split(/\s+/g);
|
const tokens = (this._lastSetValue as string).split(/\s+/g);
|
||||||
if (this._options & StylingDifferOptions.ForceAsMap) {
|
if (this._options & StylingDifferOptions.ForceAsMap) {
|
||||||
finalValue = {};
|
finalValue = {} as{[key: string]: any};
|
||||||
tokens.forEach((token, i) => (finalValue as{[key: string]: any})[token] = true);
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
finalValue[tokens[i]] = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
finalValue = tokens.reduce((str, token, i) => str + (i ? ' ' : '') + token);
|
finalValue = '';
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
finalValue += (i !== 0 ? ' ' : '') + tokens[i];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// case 2: [input]="{key:value}"
|
// case 2: [input]="{key:value}"
|
||||||
case StylingDifferValueTypes.Map:
|
case StylingDifferValueTypes.StringMap:
|
||||||
const map: {[key: string]: any} = this._lastSetValue as{[key: string]: any};
|
const map: {[key: string]: any} = this._lastSetValue as{[key: string]: any};
|
||||||
const keys = Object.keys(map);
|
const keys = Object.keys(map);
|
||||||
if (!valueHasChanged) {
|
if (!valueHasChanged) {
|
||||||
if (this.value) {
|
// we know that the classExp value exists and that it is
|
||||||
// we know that the classExp value exists and that it is
|
// a map (otherwise an identity change would have occurred)
|
||||||
// a map (otherwise an identity change would have occurred)
|
valueHasChanged =
|
||||||
valueHasChanged = mapHasChanged(keys, this.value as{[key: string]: any}, map);
|
this.value ? mapHasChanged(keys, this.value as{[key: string]: any}, map) : true;
|
||||||
} else {
|
|
||||||
valueHasChanged = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valueHasChanged) {
|
if (valueHasChanged) {
|
||||||
|
@ -174,27 +216,27 @@ export class StylingDiffer<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Various options that are consumed by the [StylingDiffer] class.
|
* Various options that are consumed by the [StylingDiffer] class
|
||||||
*/
|
*/
|
||||||
export const enum StylingDifferOptions {
|
export const enum StylingDifferOptions {
|
||||||
None = 0b00000,
|
None = 0b00000, //
|
||||||
TrimProperties = 0b00001,
|
TrimProperties = 0b00001, //
|
||||||
AllowSubKeys = 0b00010,
|
AllowSubKeys = 0b00010, //
|
||||||
AllowStringValue = 0b00100,
|
AllowStringValue = 0b00100, //
|
||||||
AllowUnits = 0b01000,
|
AllowUnits = 0b01000, //
|
||||||
ForceAsMap = 0b10000,
|
ForceAsMap = 0b10000, //
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The different types of inputs that the [StylingDiffer] can deal with
|
* The different types of inputs that the [StylingDiffer] can deal with
|
||||||
*/
|
*/
|
||||||
const enum StylingDifferValueTypes {
|
const enum StylingDifferValueTypes {
|
||||||
Null = 0b0000,
|
Null = 0b0000, //
|
||||||
String = 0b0001,
|
String = 0b0001, //
|
||||||
Map = 0b0010,
|
StringMap = 0b0010, //
|
||||||
Array = 0b0100,
|
Array = 0b0100, //
|
||||||
Set = 0b1000,
|
Set = 0b1000, //
|
||||||
Collection = 0b1110,
|
Collection = 0b1110, //
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -352,6 +352,21 @@ import {ComponentFixture, TestBed, async} from '@angular/core/testing';
|
||||||
detectChangesAndExpectClassName('init baz');
|
detectChangesAndExpectClassName('init baz');
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('non-regression', () => {
|
||||||
|
|
||||||
|
// https://github.com/angular/angular/issues/34336
|
||||||
|
it('should not write to native node when a bound expression doesnt change', () => {
|
||||||
|
fixture = createTestComponent(`<div [ngClass]="{'color-red': true}"></div>`);
|
||||||
|
detectChangesAndExpectClassName('color-red');
|
||||||
|
|
||||||
|
// Overwrite CSS classes to make sure that ngClass is not doing any DOM manipulation (as
|
||||||
|
// there was no change to the expression bound to [ngClass]).
|
||||||
|
fixture.debugElement.children[0].nativeElement.className = '';
|
||||||
|
detectChangesAndExpectClassName('');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -158,27 +158,22 @@ import {ComponentFixture, TestBed, async} from '@angular/core/testing';
|
||||||
expectNativeEl(fixture).toHaveCssStyle({'font-size': '12px'});
|
expectNativeEl(fixture).toHaveCssStyle({'font-size': '12px'});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should skip keys that are set to undefined values', async(() => {
|
it('should not write to native node when a bound expression doesnt change', () => {
|
||||||
const template = `<div [ngStyle]="expr"></div>`;
|
|
||||||
|
|
||||||
fixture = createTestComponent(template);
|
const template = `<div [ngStyle]="{'color': 'red'}"></div>`;
|
||||||
|
|
||||||
getComponent().expr = {
|
fixture = createTestComponent(template);
|
||||||
'border-top-color': undefined,
|
|
||||||
'border-top-style': undefined,
|
|
||||||
'border-color': 'red',
|
|
||||||
'border-style': 'solid',
|
|
||||||
'border-width': '1rem',
|
|
||||||
};
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
expectNativeEl(fixture).toHaveCssStyle({'color': 'red'});
|
||||||
|
|
||||||
expectNativeEl(fixture).toHaveCssStyle({
|
// Overwrite native styles to make sure that ngClass is not doing any DOM manipulation (as
|
||||||
'border-color': 'red',
|
// there was no change to the expression bound to [ngStyle]).
|
||||||
'border-style': 'solid',
|
fixture.debugElement.children[0].nativeElement.style.color = 'blue';
|
||||||
'border-width': '1rem',
|
fixture.detectChanges();
|
||||||
});
|
expectNativeEl(fixture).toHaveCssStyle({'color': 'blue'});
|
||||||
}));
|
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,113 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright Google Inc. All Rights Reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by an MIT-style license that can be
|
||||||
|
* found in the LICENSE file at https://angular.io/license
|
||||||
|
*/
|
||||||
|
import {StylingDiffer, StylingDifferOptions} from '@angular/common/src/directives/styling_differ';
|
||||||
|
|
||||||
|
describe('StylingDiffer', () => {
|
||||||
|
it('should create a key/value object of values from a string', () => {
|
||||||
|
const d = new StylingDiffer(
|
||||||
|
'ngClass', StylingDifferOptions.ForceAsMap | StylingDifferOptions.AllowStringValue);
|
||||||
|
expect(d.value).toEqual(null);
|
||||||
|
|
||||||
|
d.setValue('one two');
|
||||||
|
expect(d.value).toEqual({one: true, two: true});
|
||||||
|
|
||||||
|
d.setValue('three');
|
||||||
|
expect(d.value).toEqual({three: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not emit that a value has changed if a new non-collection value was not set', () => {
|
||||||
|
const d = new StylingDiffer(
|
||||||
|
'ngClass', StylingDifferOptions.ForceAsMap | StylingDifferOptions.AllowStringValue);
|
||||||
|
expect(d.value).toEqual(null);
|
||||||
|
|
||||||
|
d.setValue('one two');
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({one: true, two: true});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
expect(d.value).toEqual({one: true, two: true});
|
||||||
|
|
||||||
|
d.setValue('three');
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({three: true});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
expect(d.value).toEqual({three: true});
|
||||||
|
|
||||||
|
d.setValue(null);
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual(null);
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
expect(d.value).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should watch the contents of a StringMap value and emit new values if they change', () => {
|
||||||
|
const d = new StylingDiffer('ngClass', StylingDifferOptions.ForceAsMap);
|
||||||
|
|
||||||
|
const myMap: {[key: string]: any} = {};
|
||||||
|
myMap['abc'] = true;
|
||||||
|
|
||||||
|
d.setValue(myMap);
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({abc: true});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
|
||||||
|
myMap['def'] = true;
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({abc: true, def: true});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
|
||||||
|
delete myMap['abc'];
|
||||||
|
delete myMap['def'];
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should watch the contents of an Array value and emit new values if they change', () => {
|
||||||
|
const d = new StylingDiffer('ngClass', StylingDifferOptions.ForceAsMap);
|
||||||
|
|
||||||
|
const myArray: string[] = [];
|
||||||
|
myArray.push('abc');
|
||||||
|
|
||||||
|
d.setValue(myArray);
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({abc: true});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
|
||||||
|
myArray.push('def');
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({abc: true, def: true});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
|
||||||
|
myArray.length = 0;
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should watch the contents of a Set value and emit new values if they change', () => {
|
||||||
|
const d = new StylingDiffer('ngClass', StylingDifferOptions.ForceAsMap);
|
||||||
|
|
||||||
|
const mySet = new Set<string>();
|
||||||
|
mySet.add('abc');
|
||||||
|
|
||||||
|
d.setValue(mySet);
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({abc: true});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
|
||||||
|
mySet.add('def');
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({abc: true, def: true});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
|
||||||
|
mySet.clear();
|
||||||
|
expect(d.hasValueChanged()).toBeTruthy();
|
||||||
|
expect(d.value).toEqual({});
|
||||||
|
expect(d.hasValueChanged()).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue