feat(change_detection): ensure that expression do not change after they have been checked

This commit is contained in:
vsavkin 2014-12-04 18:30:54 -08:00
parent d02e192951
commit 8acf9fb609
11 changed files with 123 additions and 27 deletions

View File

@ -6,14 +6,37 @@ import {ListWrapper, List} from 'facade/collection';
export * from './record'; export * from './record';
export * from './record_range' export * from './record_range'
class ExpressionChangedAfterItHasBeenChecked extends Error {
message:string;
constructor(record:Record) {
this.message = `Expression '${record.expressionAsString()}' has changed after it was checked. ` +
`Previous value: '${record.previousValue}'. Current value: '${record.currentValue}'`;
}
toString():string {
return this.message;
}
}
export class ChangeDetector { export class ChangeDetector {
_rootRecordRange:RecordRange; _rootRecordRange:RecordRange;
_enforceNoNewChanges:boolean;
constructor(recordRange:RecordRange) { constructor(recordRange:RecordRange, enforceNoNewChanges:boolean = false) {
this._rootRecordRange = recordRange; this._rootRecordRange = recordRange;
this._enforceNoNewChanges = enforceNoNewChanges;
} }
detectChanges():int { detectChanges():int {
var count = this._detectChanges(false);
if (this._enforceNoNewChanges) {
this._detectChanges(true)
}
return count;
}
_detectChanges(throwOnChange:boolean):int {
var count = 0; var count = 0;
var updatedRecords = null; var updatedRecords = null;
var record = this._rootRecordRange.findFirstEnabledRecord(); var record = this._rootRecordRange.findFirstEnabledRecord();
@ -23,6 +46,7 @@ export class ChangeDetector {
if (record.check()) { if (record.check()) {
count++; count++;
if (record.terminatesExpression()) { if (record.terminatesExpression()) {
if (throwOnChange) throw new ExpressionChangedAfterItHasBeenChecked(record);
currentRange = record.recordRange; currentRange = record.recordRange;
currentGroup = record.groupMemento(); currentGroup = record.groupMemento();
updatedRecords = this._addRecord(updatedRecords, record); updatedRecords = this._addRecord(updatedRecords, record);

View File

@ -17,6 +17,10 @@ export class AST {
visit(visitor, args) { visit(visitor, args) {
} }
toString():string {
return "AST";
}
} }
export class EmptyExpr extends AST { export class EmptyExpr extends AST {
@ -378,13 +382,33 @@ export class FunctionCall extends AST {
} }
} }
export class ASTWithSource { export class ASTWithSource extends AST {
ast:AST; ast:AST;
source:string; source:string;
constructor(ast:AST, source:string) { constructor(ast:AST, source:string) {
this.source = source; this.source = source;
this.ast = ast; this.ast = ast;
} }
eval(context) {
return this.ast.eval(context);
}
get isAssignable() {
return this.ast.isAssignable;
}
assign(context, value) {
return this.ast.assign(context, value);
}
visit(visitor, args) {
return this.ast.visit(visitor, args);
}
toString():string {
return this.source;
}
} }
export class TemplateBinding { export class TemplateBinding {

View File

@ -35,6 +35,7 @@ export class ProtoRecord {
name:string; name:string;
dest:any; dest:any;
groupMemento:any; groupMemento:any;
expressionAsString:string;
next:ProtoRecord; next:ProtoRecord;
@ -46,7 +47,8 @@ export class ProtoRecord {
arity:int, arity:int,
name:string, name:string,
dest, dest,
groupMemento) { groupMemento,
expressionAsString:string) {
this.recordRange = recordRange; this.recordRange = recordRange;
this._mode = mode; this._mode = mode;
@ -55,6 +57,7 @@ export class ProtoRecord {
this.name = name; this.name = name;
this.dest = dest; this.dest = dest;
this.groupMemento = groupMemento; this.groupMemento = groupMemento;
this.expressionAsString = expressionAsString;
this.next = null; this.next = null;
// The concrete Record instantiated from this ProtoRecord // The concrete Record instantiated from this ProtoRecord
@ -341,6 +344,10 @@ export class Record {
return this.protoRecord.dest; return this.protoRecord.dest;
} }
expressionAsString() {
return this.protoRecord.expressionAsString;
}
groupMemento() { groupMemento() {
return isPresent(this.protoRecord) ? this.protoRecord.groupMemento : null; return isPresent(this.protoRecord) ? this.protoRecord.groupMemento : null;
} }

View File

@ -44,7 +44,7 @@ export class ProtoRecordRange {
/** /**
* Parses [ast] into [ProtoRecord]s and adds them to [ProtoRecordRange]. * Parses [ast] into [ProtoRecord]s and adds them to [ProtoRecordRange].
* *
* @param ast The expression to watch * @param astWithSource The expression to watch
* @param expressionMemento an opaque object which will be passed to ChangeDispatcher on * @param expressionMemento an opaque object which will be passed to ChangeDispatcher on
* detecting a change. * detecting a change.
* @param groupMemento * @param groupMemento
@ -62,7 +62,6 @@ export class ProtoRecordRange {
if (content) { if (content) {
ast = new Collection(ast); ast = new Collection(ast);
} }
this.recordCreator.createRecordsFromAST(ast, expressionMemento, groupMemento); this.recordCreator.createRecordsFromAST(ast, expressionMemento, groupMemento);
} }
@ -318,11 +317,13 @@ class ProtoRecordCreator {
headRecord:ProtoRecord; headRecord:ProtoRecord;
tailRecord:ProtoRecord; tailRecord:ProtoRecord;
groupMemento:any; groupMemento:any;
expressionAsString:string;
constructor(protoRecordRange) { constructor(protoRecordRange) {
this.protoRecordRange = protoRecordRange; this.protoRecordRange = protoRecordRange;
this.headRecord = null; this.headRecord = null;
this.tailRecord = null; this.tailRecord = null;
this.expressionAsString = null;
} }
visitImplicitReceiver(ast:ImplicitReceiver, args) { visitImplicitReceiver(ast:ImplicitReceiver, args) {
@ -434,11 +435,13 @@ class ProtoRecordCreator {
createRecordsFromAST(ast:AST, expressionMemento:any, groupMemento:any){ createRecordsFromAST(ast:AST, expressionMemento:any, groupMemento:any){
this.groupMemento = groupMemento; this.groupMemento = groupMemento;
this.expressionAsString = ast.toString();
ast.visit(this, expressionMemento); ast.visit(this, expressionMemento);
} }
construct(recordType, funcOrValue, arity, name, dest) { construct(recordType, funcOrValue, arity, name, dest) {
return new ProtoRecord(this.protoRecordRange, recordType, funcOrValue, arity, name, dest, this.groupMemento); return new ProtoRecord(this.protoRecordRange, recordType, funcOrValue, arity,
name, dest, this.groupMemento, this.expressionAsString);
} }
add(protoRecord:ProtoRecord) { add(protoRecord:ProtoRecord) {

View File

@ -20,7 +20,7 @@ import {Record} from 'change_detection/record';
export function main() { export function main() {
function ast(exp:string) { function ast(exp:string) {
var parser = new Parser(new Lexer()); var parser = new Parser(new Lexer());
return parser.parseBinding(exp).ast; return parser.parseBinding(exp);
} }
function createChangeDetector(memo:string, exp:string, context = null, formatters = null, function createChangeDetector(memo:string, exp:string, context = null, formatters = null,
@ -433,6 +433,27 @@ export function main() {
expect(dispatcher.loggedValues).toEqual(['InvokeA', ['a'], 'InvokeB', 'InvokeC', ['b', 'c']]); expect(dispatcher.loggedValues).toEqual(['InvokeA', ['a'], 'InvokeB', 'InvokeC', ['b', 'c']]);
}); });
}); });
describe("enforce no new changes", () => {
it("should throw when a record gets changed after it has been checked", () => {
var prr = new ProtoRecordRange();
prr.addRecordsFromAST(ast("a"), "a", 1);
prr.addRecordsFromAST(ast("b()"), "b", 2);
var tr = new TestRecord();
tr.a = "a";
tr.b = () => {tr.a = "newA";};
var dispatcher = new TestDispatcher();
var rr = prr.instantiate(dispatcher, null);
rr.setContext(tr);
expect(() => {
var cd = new ChangeDetector(rr, true);
cd.detectChanges();
}).toThrowError(new RegExp("Expression 'a' has changed after it was checked"));
});
});
}); });
}); });
} }
@ -513,6 +534,6 @@ class TestDispatcher extends ChangeDispatcher {
} }
_asString(value) { _asString(value) {
return (value === null ? 'null' : value.toString()); return (isBlank(value) ? 'null' : value.toString());
} }
} }

View File

@ -45,7 +45,7 @@ export function main() {
} }
function createRecord(rr) { function createRecord(rr) {
return new Record(rr, new ProtoRecord(null, 0, null, null, null, null, null), null); return new Record(rr, new ProtoRecord(null, 0, null, null, null, null, null, null), null);
} }
describe('record range', () => { describe('record range', () => {

View File

@ -1,5 +1,5 @@
import {Injector, bind, OpaqueToken} from 'di/di'; import {Injector, bind, OpaqueToken} from 'di/di';
import {Type, FIELD, isBlank, isPresent, BaseException} from 'facade/lang'; import {Type, FIELD, isBlank, isPresent, BaseException, assertionsEnabled} from 'facade/lang';
import {DOM, Element} from 'facade/dom'; import {DOM, Element} from 'facade/dom';
import {Compiler, CompilerCache} from './compiler/compiler'; import {Compiler, CompilerCache} from './compiler/compiler';
import {ProtoView} from './compiler/view'; import {ProtoView} from './compiler/view';
@ -63,7 +63,7 @@ export function documentDependentBindings(appComponentType) {
bind(appRecordRangeToken).toFactory((rootView) => rootView.recordRange, bind(appRecordRangeToken).toFactory((rootView) => rootView.recordRange,
[appViewToken]), [appViewToken]),
bind(ChangeDetector).toFactory((appRecordRange) => bind(ChangeDetector).toFactory((appRecordRange) =>
new ChangeDetector(appRecordRange), [appRecordRangeToken]), new ChangeDetector(appRecordRange, assertionsEnabled()), [appRecordRangeToken]),
bind(appComponentType).toFactory((rootView) => rootView.elementInjectors[0].getComponent(), bind(appComponentType).toFactory((rootView) => rootView.elementInjectors[0].getComponent(),
[appViewToken]) [appViewToken])
]; ];

View File

@ -68,19 +68,19 @@ export class ElementBinderBuilder extends CompileStep {
_bindTextNodes(protoView, compileElement) { _bindTextNodes(protoView, compileElement) {
MapWrapper.forEach(compileElement.textNodeBindings, (expression, indexInParent) => { MapWrapper.forEach(compileElement.textNodeBindings, (expression, indexInParent) => {
protoView.bindTextNode(indexInParent, expression.ast); protoView.bindTextNode(indexInParent, expression);
}); });
} }
_bindElementProperties(protoView, compileElement) { _bindElementProperties(protoView, compileElement) {
MapWrapper.forEach(compileElement.propertyBindings, (expression, property) => { MapWrapper.forEach(compileElement.propertyBindings, (expression, property) => {
protoView.bindElementProperty(property, expression.ast); protoView.bindElementProperty(property, expression);
}); });
} }
_bindEvents(protoView, compileElement) { _bindEvents(protoView, compileElement) {
MapWrapper.forEach(compileElement.eventBindings, (expression, eventName) => { MapWrapper.forEach(compileElement.eventBindings, (expression, eventName) => {
protoView.bindEvent(eventName, expression.ast); protoView.bindEvent(eventName, expression);
}); });
} }
@ -118,7 +118,7 @@ export class ElementBinderBuilder extends CompileStep {
} }
protoView.bindDirectiveProperty( protoView.bindDirectiveProperty(
directiveIndex++, directiveIndex++,
expression.ast, expression,
dirProp, dirProp,
reflector.setter(dirProp) reflector.setter(dirProp)
); );

View File

@ -105,7 +105,7 @@ export function main() {
it('should collect property bindings on the root element if it has the ng-binding class', () => { it('should collect property bindings on the root element if it has the ng-binding class', () => {
var pv = new ProtoView(templateAwareCreateElement('<div [prop]="a" class="ng-binding"></div>'), new ProtoRecordRange()); var pv = new ProtoView(templateAwareCreateElement('<div [prop]="a" class="ng-binding"></div>'), new ProtoRecordRange());
pv.bindElement(null); pv.bindElement(null);
pv.bindElementProperty('prop', parser.parseBinding('a').ast); pv.bindElementProperty('prop', parser.parseBinding('a'));
var view = pv.instantiate(null); var view = pv.instantiate(null);
view.hydrate(null, null, null); view.hydrate(null, null, null);
@ -117,7 +117,7 @@ export function main() {
var pv = new ProtoView(templateAwareCreateElement('<div><span></span><span class="ng-binding"></span></div>'), var pv = new ProtoView(templateAwareCreateElement('<div><span></span><span class="ng-binding"></span></div>'),
new ProtoRecordRange()); new ProtoRecordRange());
pv.bindElement(null); pv.bindElement(null);
pv.bindElementProperty('a', parser.parseBinding('b').ast); pv.bindElementProperty('a', parser.parseBinding('b'));
var view = pv.instantiate(null); var view = pv.instantiate(null);
view.hydrate(null, null, null); view.hydrate(null, null, null);
@ -132,8 +132,8 @@ export function main() {
it('should collect text nodes under the root element', () => { it('should collect text nodes under the root element', () => {
var pv = new ProtoView(templateAwareCreateElement('<div class="ng-binding">{{}}<span></span>{{}}</div>'), new ProtoRecordRange()); var pv = new ProtoView(templateAwareCreateElement('<div class="ng-binding">{{}}<span></span>{{}}</div>'), new ProtoRecordRange());
pv.bindElement(null); pv.bindElement(null);
pv.bindTextNode(0, parser.parseBinding('a').ast); pv.bindTextNode(0, parser.parseBinding('a'));
pv.bindTextNode(2, parser.parseBinding('b').ast); pv.bindTextNode(2, parser.parseBinding('b'));
var view = pv.instantiate(null); var view = pv.instantiate(null);
view.hydrate(null, null, null); view.hydrate(null, null, null);
@ -146,7 +146,7 @@ export function main() {
var pv = new ProtoView(templateAwareCreateElement('<div><span> </span><span class="ng-binding">{{}}</span></div>'), var pv = new ProtoView(templateAwareCreateElement('<div><span> </span><span class="ng-binding">{{}}</span></div>'),
new ProtoRecordRange()); new ProtoRecordRange());
pv.bindElement(null); pv.bindElement(null);
pv.bindTextNode(0, parser.parseBinding('b').ast); pv.bindTextNode(0, parser.parseBinding('b'));
var view = pv.instantiate(null); var view = pv.instantiate(null);
view.hydrate(null, null, null); view.hydrate(null, null, null);
@ -357,7 +357,7 @@ export function main() {
var pv = new ProtoView(createElement('<div class="ng-binding">{{}}</div>'), var pv = new ProtoView(createElement('<div class="ng-binding">{{}}</div>'),
new ProtoRecordRange()); new ProtoRecordRange());
pv.bindElement(null); pv.bindElement(null);
pv.bindTextNode(0, parser.parseBinding('foo').ast); pv.bindTextNode(0, parser.parseBinding('foo'));
createViewAndChangeDetector(pv); createViewAndChangeDetector(pv);
ctx.foo = 'buz'; ctx.foo = 'buz';
@ -369,7 +369,7 @@ export function main() {
var pv = new ProtoView(createElement('<div class="ng-binding"></div>'), var pv = new ProtoView(createElement('<div class="ng-binding"></div>'),
new ProtoRecordRange()); new ProtoRecordRange());
pv.bindElement(null); pv.bindElement(null);
pv.bindElementProperty('id', parser.parseBinding('foo').ast); pv.bindElementProperty('id', parser.parseBinding('foo'));
createViewAndChangeDetector(pv); createViewAndChangeDetector(pv);
ctx.foo = 'buz'; ctx.foo = 'buz';
@ -381,7 +381,7 @@ export function main() {
var pv = new ProtoView(createElement('<div class="ng-binding"></div>'), var pv = new ProtoView(createElement('<div class="ng-binding"></div>'),
new ProtoRecordRange()); new ProtoRecordRange());
pv.bindElement(new ProtoElementInjector(null, 0, [SomeDirective])); pv.bindElement(new ProtoElementInjector(null, 0, [SomeDirective]));
pv.bindDirectiveProperty(0, parser.parseBinding('foo').ast, 'prop', reflector.setter('prop')); pv.bindDirectiveProperty(0, parser.parseBinding('foo'), 'prop', reflector.setter('prop'));
createViewAndChangeDetector(pv); createViewAndChangeDetector(pv);
ctx.foo = 'buz'; ctx.foo = 'buz';
@ -394,8 +394,8 @@ export function main() {
new ProtoRecordRange()); new ProtoRecordRange());
pv.bindElement(new ProtoElementInjector(null, 0, [DirectiveImplementingOnChange])); pv.bindElement(new ProtoElementInjector(null, 0, [DirectiveImplementingOnChange]));
pv.bindDirectiveProperty( 0, parser.parseBinding('a').ast, 'a', reflector.setter('a')); pv.bindDirectiveProperty( 0, parser.parseBinding('a'), 'a', reflector.setter('a'));
pv.bindDirectiveProperty( 0, parser.parseBinding('b').ast, 'b', reflector.setter('b')); pv.bindDirectiveProperty( 0, parser.parseBinding('b'), 'b', reflector.setter('b'));
createViewAndChangeDetector(pv); createViewAndChangeDetector(pv);
ctx.a = 100; ctx.a = 100;
@ -413,8 +413,7 @@ export function main() {
pv.bindElement(new ProtoElementInjector(null, 0, [DirectiveImplementingOnChange])); pv.bindElement(new ProtoElementInjector(null, 0, [DirectiveImplementingOnChange]));
pv.bindDirectiveProperty( 0, parser.parseBinding('a').ast, 'a', reflector.setter('a')); pv.bindDirectiveProperty( 0, parser.parseBinding('a').ast, 'a', reflector.setter('a'));
pv.bindDirectiveProperty( 0, parser.parseBinding('b').ast, 'b', reflector.setter('b')); pv.bindDirectiveProperty( 0, parser.parseBinding('b').ast, 'b', reflector.setter('b'));
createViewAndChangeDetector(pv); createView(pv);
ctx.a = 0; ctx.a = 0;
ctx.b = 0; ctx.b = 0;
cd.detectChanges(); cd.detectChanges();

View File

@ -169,3 +169,12 @@ bool isJsObject(o) {
return false; return false;
} }
assertionsEnabled() {
try {
assert(false);
return false;
} catch (e) {
return true;
}
}

View File

@ -206,3 +206,12 @@ export function normalizeBlank(obj) {
export function isJsObject(o):boolean { export function isJsObject(o):boolean {
return o !== null && (typeof o === "function" || typeof o === "object"); return o !== null && (typeof o === "function" || typeof o === "object");
} }
export function assertionsEnabled() {
try {
var x:int = "string";
return false;
} catch (e) {
return true;
}
}