fix(ivy): ensure errors are thrown during checkNoChanges for style/class bindings (#33103)
Prior to this fix, all style/class bindings (e.g. `[style]` and `[class.foo]`) would quietly update a binding value if and when the current binding value changes during checkNoChanges. With this patch, all styling instructions will properly check to see if the value has changed during the second pass of detectChanges() if checkNoChanges is active. PR Close #33103
This commit is contained in:
parent
9d54679e66
commit
f45c43188f
|
@ -1,5 +1,5 @@
|
|||
import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core';
|
||||
import { inject, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { inject, ComponentFixture, TestBed, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { APP_BASE_HREF } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
@ -529,7 +529,8 @@ describe('AppComponent', () => {
|
|||
it('should call `scrollAfterRender` (via `onDocInserted`) when navigate to a new Doc', fakeAsync(() => {
|
||||
locationService.go('guide/pipes');
|
||||
tick(1); // triggers the HTTP response for the document
|
||||
fixture.detectChanges(); // triggers the event that calls `onDocInserted`
|
||||
fixture.detectChanges(); // passes the new doc to the `DocViewer`
|
||||
flushMicrotasks(); // triggers the `DocViewer` event that calls `onDocInserted`
|
||||
|
||||
expect(scrollAfterRenderSpy).toHaveBeenCalledWith(scrollDelay);
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Meta, Title } from '@angular/platform-browser';
|
||||
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { Observable, asapScheduler, of } from 'rxjs';
|
||||
|
||||
import { FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
|
@ -21,6 +21,8 @@ describe('DocViewerComponent', () => {
|
|||
let docViewerEl: HTMLElement;
|
||||
let docViewer: TestDocViewerComponent;
|
||||
|
||||
const safeFlushAsapScheduler = () => asapScheduler.actions.length && asapScheduler.flush();
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CustomElementsModule, TestModule],
|
||||
|
@ -42,19 +44,20 @@ describe('DocViewerComponent', () => {
|
|||
describe('#doc', () => {
|
||||
let renderSpy: jasmine.Spy;
|
||||
|
||||
const setCurrentDoc = (contents: string|null, id = 'fizz/buzz') => {
|
||||
parentComponent.currentDoc = {contents, id};
|
||||
parentFixture.detectChanges();
|
||||
const setCurrentDoc = (newDoc: TestParentComponent['currentDoc']) => {
|
||||
parentComponent.currentDoc = newDoc && {id: 'fizz/buzz', ...newDoc};
|
||||
parentFixture.detectChanges(); // Run change detection to propagate the new doc to `DocViewer`.
|
||||
safeFlushAsapScheduler(); // Flush `asapScheduler` to trigger `DocViewer#render()`.
|
||||
};
|
||||
|
||||
beforeEach(() => renderSpy = spyOn(docViewer, 'render').and.callFake(() => of(undefined)));
|
||||
|
||||
it('should render the new document', () => {
|
||||
setCurrentDoc('foo', 'bar');
|
||||
setCurrentDoc({contents: 'foo', id: 'bar'});
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'bar', contents: 'foo'}]);
|
||||
|
||||
setCurrentDoc(null, 'baz');
|
||||
setCurrentDoc({contents: null, id: 'baz'});
|
||||
expect(renderSpy).toHaveBeenCalledTimes(2);
|
||||
expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]);
|
||||
});
|
||||
|
@ -63,24 +66,20 @@ describe('DocViewerComponent', () => {
|
|||
const obs = new ObservableWithSubscriptionSpies();
|
||||
renderSpy.and.returnValue(obs);
|
||||
|
||||
setCurrentDoc('foo', 'bar');
|
||||
setCurrentDoc({contents: 'foo', id: 'bar'});
|
||||
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled();
|
||||
|
||||
setCurrentDoc('baz', 'qux');
|
||||
setCurrentDoc({contents: 'baz', id: 'qux'});
|
||||
expect(obs.subscribeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should ignore falsy document values', () => {
|
||||
parentComponent.currentDoc = null;
|
||||
parentFixture.detectChanges();
|
||||
|
||||
setCurrentDoc(null);
|
||||
expect(renderSpy).not.toHaveBeenCalled();
|
||||
|
||||
parentComponent.currentDoc = undefined;
|
||||
parentFixture.detectChanges();
|
||||
|
||||
setCurrentDoc(undefined);
|
||||
expect(renderSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -92,14 +91,17 @@ describe('DocViewerComponent', () => {
|
|||
expect(renderSpy).not.toHaveBeenCalled();
|
||||
|
||||
docViewer.doc = {contents: 'Some content', id: 'some-id'};
|
||||
safeFlushAsapScheduler();
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
docViewer.ngOnDestroy();
|
||||
|
||||
docViewer.doc = {contents: 'Other content', id: 'other-id'};
|
||||
safeFlushAsapScheduler();
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
docViewer.doc = {contents: 'More content', id: 'more-id'};
|
||||
safeFlushAsapScheduler();
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Component, ElementRef, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
|
||||
import { Title, Meta } from '@angular/platform-browser';
|
||||
|
||||
import { Observable, of, timer } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil, tap } from 'rxjs/operators';
|
||||
import { asapScheduler, Observable, of, timer } from 'rxjs';
|
||||
import { catchError, observeOn, switchMap, takeUntil, tap } from 'rxjs/operators';
|
||||
|
||||
import { DocumentContents, FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
|
@ -78,6 +78,7 @@ export class DocViewerComponent implements OnDestroy {
|
|||
|
||||
this.docContents$
|
||||
.pipe(
|
||||
observeOn(asapScheduler),
|
||||
switchMap(newDoc => this.render(newDoc)),
|
||||
takeUntil(this.onDestroy$),
|
||||
)
|
||||
|
|
|
@ -7,19 +7,20 @@
|
|||
*/
|
||||
import {SafeValue} from '../../sanitization/bypass';
|
||||
import {StyleSanitizeFn} from '../../sanitization/style_sanitizer';
|
||||
import {throwErrorIfNoChangesMode} from '../errors';
|
||||
import {setInputsForProperty} from '../instructions/shared';
|
||||
import {AttributeMarker, TAttributes, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
|
||||
import {RElement} from '../interfaces/renderer';
|
||||
import {StylingMapArray, StylingMapArrayIndex, TStylingConfig, TStylingContext} from '../interfaces/styling';
|
||||
import {isDirectiveHost} from '../interfaces/type_checks';
|
||||
import {BINDING_INDEX, LView, RENDERER} from '../interfaces/view';
|
||||
import {getActiveDirectiveId, getCurrentStyleSanitizer, getLView, getSelectedIndex, resetCurrentStyleSanitizer, setCurrentStyleSanitizer, setElementExitFn} from '../state';
|
||||
import {getActiveDirectiveId, getCheckNoChangesMode, getCurrentStyleSanitizer, getLView, getSelectedIndex, resetCurrentStyleSanitizer, setCurrentStyleSanitizer, setElementExitFn} from '../state';
|
||||
import {applyStylingMapDirectly, applyStylingValueDirectly, flushStyling, setClass, setStyle, updateClassViaContext, updateStyleViaContext} from '../styling/bindings';
|
||||
import {activateStylingMapFeature} from '../styling/map_based_bindings';
|
||||
import {attachStylingDebugObject} from '../styling/styling_debug';
|
||||
import {NO_CHANGE} from '../tokens';
|
||||
import {renderStringify} from '../util/misc_utils';
|
||||
import {addItemToStylingMap, allocStylingMapArray, allocTStylingContext, allowDirectStyling, concatString, forceClassesAsString, forceStylesAsString, getInitialStylingValue, getStylingMapArray, hasClassInput, hasStyleInput, hasValueChanged, isContextLocked, isHostStylingActive, isStylingContext, normalizeIntoStylingMap, patchConfig, selectClassBasedInputName, setValue, stylingMapToString} from '../util/styling_utils';
|
||||
import {addItemToStylingMap, allocStylingMapArray, allocTStylingContext, allowDirectStyling, concatString, forceClassesAsString, forceStylesAsString, getInitialStylingValue, getStylingMapArray, getValue, hasClassInput, hasStyleInput, hasValueChanged, isContextLocked, isHostStylingActive, isStylingContext, normalizeIntoStylingMap, patchConfig, selectClassBasedInputName, setValue, stylingMapToString} from '../util/styling_utils';
|
||||
import {getNativeByTNode, getTNode} from '../util/view_utils';
|
||||
|
||||
|
||||
|
@ -171,6 +172,17 @@ function stylingProp(
|
|||
patchConfig(context, TStylingConfig.HasPropBindings);
|
||||
}
|
||||
|
||||
// [style.prop] and [class.name] bindings do not use `bind()` and will
|
||||
// therefore manage accessing and updating the new value in the lView directly.
|
||||
// For this reason, the checkNoChanges situation must also be handled here
|
||||
// as well.
|
||||
if (ngDevMode && getCheckNoChangesMode()) {
|
||||
const oldValue = getValue(lView, bindingIndex);
|
||||
if (hasValueChanged(oldValue, value)) {
|
||||
throwErrorIfNoChangesMode(false, oldValue, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct Apply Case: bypass context resolution and apply the
|
||||
// style/class value directly to the element
|
||||
if (allowDirectStyling(context, hostBindingsMode)) {
|
||||
|
@ -246,7 +258,7 @@ export function ɵɵstyleMap(styles: {[styleName: string]: any} | NO_CHANGE | nu
|
|||
styles = NO_CHANGE;
|
||||
}
|
||||
|
||||
const updated = _stylingMap(index, context, bindingIndex, styles, false);
|
||||
const updated = stylingMap(index, context, bindingIndex, styles, false);
|
||||
if (ngDevMode) {
|
||||
ngDevMode.styleMap++;
|
||||
if (updated) {
|
||||
|
@ -303,7 +315,7 @@ export function classMapInternal(
|
|||
classes = NO_CHANGE;
|
||||
}
|
||||
|
||||
const updated = _stylingMap(elementIndex, context, bindingIndex, classes, true);
|
||||
const updated = stylingMap(elementIndex, context, bindingIndex, classes, true);
|
||||
if (ngDevMode) {
|
||||
ngDevMode.classMap++;
|
||||
if (updated) {
|
||||
|
@ -318,7 +330,7 @@ export function classMapInternal(
|
|||
* When this function is called it will activate support for `[style]` and
|
||||
* `[class]` bindings in Angular.
|
||||
*/
|
||||
function _stylingMap(
|
||||
function stylingMap(
|
||||
elementIndex: number, context: TStylingContext, bindingIndex: number,
|
||||
value: {[key: string]: any} | string | null, isClassBased: boolean): boolean {
|
||||
let updated = false;
|
||||
|
@ -327,9 +339,18 @@ function _stylingMap(
|
|||
const directiveIndex = getActiveDirectiveId();
|
||||
const tNode = getTNode(elementIndex, lView);
|
||||
const native = getNativeByTNode(tNode, lView) as RElement;
|
||||
const oldValue = lView[bindingIndex] as StylingMapArray | null;
|
||||
const oldValue = getValue(lView, bindingIndex);
|
||||
const hostBindingsMode = isHostStyling();
|
||||
const sanitizer = getCurrentStyleSanitizer();
|
||||
const valueHasChanged = hasValueChanged(oldValue, value);
|
||||
|
||||
// [style] and [class] bindings do not use `bind()` and will therefore
|
||||
// manage accessing and updating the new value in the lView directly.
|
||||
// For this reason, the checkNoChanges situation must also be handled here
|
||||
// as well.
|
||||
if (ngDevMode && valueHasChanged && getCheckNoChangesMode()) {
|
||||
throwErrorIfNoChangesMode(false, oldValue, value);
|
||||
}
|
||||
|
||||
// we check for this in the instruction code so that the context can be notified
|
||||
// about prop or map bindings so that the direct apply check can decide earlier
|
||||
|
@ -338,7 +359,6 @@ function _stylingMap(
|
|||
patchConfig(context, TStylingConfig.HasMapBindings);
|
||||
}
|
||||
|
||||
const valueHasChanged = hasValueChanged(oldValue, value);
|
||||
const stylingMapArr =
|
||||
value === NO_CHANGE ? NO_CHANGE : normalizeIntoStylingMap(oldValue, value, !isClassBased);
|
||||
|
||||
|
@ -479,12 +499,12 @@ export function registerInitialStylingOnTNode(
|
|||
if (typeof attr == 'number') {
|
||||
mode = attr;
|
||||
} else if (mode == AttributeMarker.Classes) {
|
||||
classes = classes || allocStylingMapArray();
|
||||
classes = classes || allocStylingMapArray(null);
|
||||
addItemToStylingMap(classes, attr, true);
|
||||
hasAdditionalInitialStyling = true;
|
||||
} else if (mode == AttributeMarker.Styles) {
|
||||
const value = attrs[++i] as string | null;
|
||||
styles = styles || allocStylingMapArray();
|
||||
styles = styles || allocStylingMapArray(null);
|
||||
addItemToStylingMap(styles, attr, value);
|
||||
hasAdditionalInitialStyling = true;
|
||||
}
|
||||
|
|
|
@ -522,8 +522,11 @@ export type LStylingData = LView | (string | number | boolean | null)[];
|
|||
* of the key/value array that was used to populate the property/
|
||||
* value entries that take place in the remainder of the array.
|
||||
*/
|
||||
export interface StylingMapArray extends Array<{}|string|number|null> {
|
||||
[StylingMapArrayIndex.RawValuePosition]: {}|string|null;
|
||||
export interface StylingMapArray extends Array<{}|string|number|null|undefined> {
|
||||
/**
|
||||
* The last raw value used to generate the entries in the map.
|
||||
*/
|
||||
[StylingMapArrayIndex.RawValuePosition]: {}|string|number|null|undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -43,7 +43,7 @@ export const DEFAULT_GUARD_MASK_VALUE = 0b1;
|
|||
*/
|
||||
export function allocTStylingContext(
|
||||
initialStyling: StylingMapArray | null, hasDirectives: boolean): TStylingContext {
|
||||
initialStyling = initialStyling || allocStylingMapArray();
|
||||
initialStyling = initialStyling || allocStylingMapArray(null);
|
||||
let config = TStylingConfig.Initial;
|
||||
if (hasDirectives) {
|
||||
config |= TStylingConfig.HasDirectives;
|
||||
|
@ -58,8 +58,8 @@ export function allocTStylingContext(
|
|||
];
|
||||
}
|
||||
|
||||
export function allocStylingMapArray(): StylingMapArray {
|
||||
return [''];
|
||||
export function allocStylingMapArray(value: {} | string | null): StylingMapArray {
|
||||
return [value];
|
||||
}
|
||||
|
||||
export function getConfig(context: TStylingContext) {
|
||||
|
@ -169,7 +169,7 @@ export function setValue(data: LStylingData, bindingIndex: number, value: any) {
|
|||
}
|
||||
|
||||
export function getValue<T = any>(data: LStylingData, bindingIndex: number): T|null {
|
||||
return bindingIndex > 0 ? data[bindingIndex] as T : null;
|
||||
return bindingIndex !== 0 ? data[bindingIndex] as T : null;
|
||||
}
|
||||
|
||||
export function lockContext(context: TStylingContext, hostBindingsMode: boolean): void {
|
||||
|
@ -207,7 +207,7 @@ export function hasValueChanged(
|
|||
/**
|
||||
* Determines whether the provided styling value is truthy or falsy.
|
||||
*/
|
||||
export function isStylingValueDefined<T extends string|number|{}|null>(value: T):
|
||||
export function isStylingValueDefined<T extends string|number|{}|null|undefined>(value: T):
|
||||
value is NonNullable<T> {
|
||||
// the reason why null is compared against is because
|
||||
// a CSS class value that is set to `false` must be
|
||||
|
@ -396,8 +396,9 @@ export function normalizeIntoStylingMap(
|
|||
bindingValue: null | StylingMapArray,
|
||||
newValues: {[key: string]: any} | string | null | undefined,
|
||||
normalizeProps?: boolean): StylingMapArray {
|
||||
const stylingMapArr: StylingMapArray = Array.isArray(bindingValue) ? bindingValue : [null];
|
||||
stylingMapArr[StylingMapArrayIndex.RawValuePosition] = newValues || null;
|
||||
const stylingMapArr: StylingMapArray =
|
||||
Array.isArray(bindingValue) ? bindingValue : allocStylingMapArray(null);
|
||||
stylingMapArr[StylingMapArrayIndex.RawValuePosition] = newValues;
|
||||
|
||||
// because the new values may not include all the properties
|
||||
// that the old ones had, all values are set to `null` before
|
||||
|
|
|
@ -2241,6 +2241,57 @@ describe('styling', () => {
|
|||
fixture.detectChanges();
|
||||
expect(div.classList.contains('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw an error if a prop-based style/class binding value is changed during checkNoChanges',
|
||||
() => {
|
||||
@Component({
|
||||
template: `
|
||||
<div [style.color]="color" [class.foo]="fooClass"></div>
|
||||
`
|
||||
})
|
||||
class Cmp {
|
||||
color = 'red';
|
||||
fooClass = true;
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.color = 'blue';
|
||||
this.fooClass = false;
|
||||
}
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({declarations: [Cmp]});
|
||||
const fixture = TestBed.createComponent(Cmp);
|
||||
|
||||
expect(() => {
|
||||
fixture.detectChanges();
|
||||
}).toThrowError(/ExpressionChangedAfterItHasBeenCheckedError/);
|
||||
});
|
||||
|
||||
onlyInIvy('only ivy allows for map-based style AND class bindings')
|
||||
.it('should throw an error if a map-based style/class binding value is changed during checkNoChanges',
|
||||
() => {
|
||||
@Component({
|
||||
template: `
|
||||
<div [style]="style" [class]="klass"></div>
|
||||
`
|
||||
})
|
||||
class Cmp {
|
||||
style: any = {width: '100px'};
|
||||
klass: any = {foo: true, bar: false};
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.style = {height: '200px'};
|
||||
this.klass = {foo: false};
|
||||
}
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({declarations: [Cmp]});
|
||||
const fixture = TestBed.createComponent(Cmp);
|
||||
|
||||
expect(() => {
|
||||
fixture.detectChanges();
|
||||
}).toThrowError(/ExpressionChangedAfterItHasBeenCheckedError/);
|
||||
});
|
||||
});
|
||||
|
||||
function assertStyleCounters(countForSet: number, countForRemove: number) {
|
||||
|
|
|
@ -81,5 +81,5 @@ describe('map-based bindings', () => {
|
|||
|
||||
function createAndAssertValues(newValue: any, entries: any[]) {
|
||||
const result = createMap(null, newValue);
|
||||
expect(result).toEqual([newValue || null, ...entries]);
|
||||
expect(result).toEqual([newValue, ...entries]);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue