diff --git a/lib/change_detection/ast.dart b/lib/change_detection/ast.dart new file mode 100644 index 000000000..36a6324b1 --- /dev/null +++ b/lib/change_detection/ast.dart @@ -0,0 +1,140 @@ +part of angular.watch_group; + + +/** + * RULES: + * - ASTs are reusable. Don't store scope/instance refs there + * - Parent knows about children, not the other way around. + */ +abstract class AST { + static final String _CONTEXT = '#'; + final String expression; + AST(this.expression) { assert(expression!=null); } + WatchRecord<_Handler> setupWatch(WatchGroup watchGroup); + toString() => expression; +} + +/** + * SYNTAX: _context_ + * + * This represent the initial _context_ for evaluation. + */ +class ContextReferenceAST extends AST { + ContextReferenceAST(): super(AST._CONTEXT); + WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) + => new _ConstantWatchRecord(watchGroup, expression, watchGroup.context); +} + +/** + * SYNTAX: _context_ + * + * This represent the initial _context_ for evaluation. + */ +class ConstantAST extends AST { + final constant; + + ConstantAST(dynamic constant): + super('$constant'), + constant = constant; + + WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) + => new _ConstantWatchRecord(watchGroup, expression, constant); +} + +/** + * SYNTAX: lhs.name. + * + * This is the '.' dot operator. + */ +class FieldReadAST extends AST { + AST lhs; + final String name; + + FieldReadAST(lhs, name) + : super(lhs.expression == AST._CONTEXT ? name : '$lhs.$name'), + lhs = lhs, + name = name; + + WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) => + watchGroup.addFieldWatch(lhs, name, expression); + +} + +/** + * SYNTAX: fn(arg0, arg1, ...) + * + * Invoke a pure function. Pure means that the function has no state, and + * therefore it needs to be re-computed only if its args change. + */ +class PureFunctionAST extends AST { + final String name; + final Function fn; + final List argsAST; + + PureFunctionAST(name, this.fn, argsAST) + : super('$name(${_argList(argsAST)})'), + argsAST = argsAST, + name = name; + + WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) => + watchGroup.addFunctionWatch(fn, argsAST, expression); +} + +/** + * SYNTAX: lhs.method(arg0, arg1, ...) + * + * Invoke a method on [lhs] object. + */ +class MethodAST extends AST { + final AST lhsAST; + final String name; + final List argsAST; + + MethodAST(lhsAST, name, argsAST) + : super('$lhsAST.$name(${_argList(argsAST)})'), + lhsAST = lhsAST, + name = name, + argsAST = argsAST; + + WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) => + watchGroup.addMethodWatch(lhsAST, name, argsAST, expression); +} + + +class CollectionAST extends AST { + final AST valueAST; + CollectionAST(valueAST): + super('#collection($valueAST)'), + valueAST = valueAST; + + WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) { + return watchGroup.addCollectionWatch(valueAST); + } +} + +_argList(List items) => items.join(', '); + +/** + * The name is a bit oxymoron, but it is essentially the NullObject pattern. + * + * This allows children to set a handler on this ChangeRecord and then let it write the initial + * constant value to the forwarding ChangeRecord. + */ +class _ConstantWatchRecord extends WatchRecord<_Handler> { + final currentValue; + final _Handler handler; + + _ConstantWatchRecord(WatchGroup watchGroup, String expression, dynamic currentValue): + currentValue = currentValue, + handler = new _ConstantHandler(watchGroup, expression, currentValue); + + ChangeRecord<_Handler> check() => null; + void remove() => null; + + get field => null; + get previousValue => null; + get object => null; + set object(_) => null; + get nextChange => null; +} + diff --git a/lib/change_detection/change_detection.dart b/lib/change_detection/change_detection.dart new file mode 100644 index 000000000..e7237b5b3 --- /dev/null +++ b/lib/change_detection/change_detection.dart @@ -0,0 +1,178 @@ +library change_detection; + +/** + * An interface for [ChangeDetectorGroup] groups related watches together. It + * guarentees that within the group all watches will be reported in the order in + * which they were registered. It also provides an efficient way of removing the + * watch group. + */ +abstract class ChangeDetectorGroup { + /** + * Watch a specific [field] on an [object]. + * + * If the [field] is: + * - _name_ - Name of the field to watch. (If the [object] is a Map then + * treat it as a key.) + * - _[]_ - Watch all items in an array. + * - _{}_ - Watch all items in a Map. + * - _._ - Watch the actual object identity. + * + * + * Parameters: + * - [object] to watch. + * - [field] to watch on the [object]. + * - [handler] an opaque object passed on to [ChangeRecord]. + */ + WatchRecord watch(Object object, String field, H handler); + + + /** Use to remove all watches in the group in an efficient manner. */ + void remove(); + + /** Create a child [ChangeDetectorGroup] */ + ChangeDetectorGroup newGroup(); +} + +/** + * An interface for [ChangeDetector]. An application can have multiple instances + * of the [ChangeDetector] to be used for checking different application domains. + * + * [ChangeDetector] works by comparing the identity of the objects not by + * calling the `.equals()` method. This is because ChangeDetector needs to have + * predictable performance, and the developer can implement `.equals()` on top + * of identity checks. + * + * - [H] A [ChangeRecord] has associated handler object. The handler object is + * opaque to the [ChangeDetector] but it is meaningful to the code which + * registered the watcher. It can be a data structure, an object, or a function. + * It is up to the developer to attach meaning to it. + */ +abstract class ChangeDetector extends ChangeDetectorGroup { + /** + * This method does the work of collecting the changes and returns them as a + * linked list of [ChangeRecord]s. The [ChangeRecord]s are to be returned in + * the same order as they were registered. + */ + ChangeRecord collectChanges(); +} + +abstract class Record { + /** The observed object. */ + Object get object; + + /** + * The field which is being watched: + * - _name_ - Name of the field to watch. + * - _[]_ - Watch all items in an array. + * - _{}_ - Watch all items in a Map. + * - _._ - Watch the actual object identity. + */ + String get field; + + /** + * An application provided object which contains the specific logic which + * needs to be applied when the change is detected. The handler is opaque to + * the ChangeDetector and as such can be anything the application desires. + */ + H get handler; + + /** Current value of the [field] on the [object] */ + get currentValue; + /** Previous value of the [field] on the [object] */ + get previousValue; +} + +/** + * [WatchRecord] API which allows changing what object is being watched and + * manually triggering the checking. + */ +abstract class WatchRecord extends Record { + /** Set a new object for checking */ + set object(value); + + /** + * Check to see if the field on the object has changed. Returns [null] if no + * change, or a [ChangeRecord] if the change has been detected. + */ + ChangeRecord check(); + + void remove(); +} + +/** + * Provides information about the changes which were detected in objects. + * + * It exposes a `nextChange` method for traversing all of the changes. + */ +abstract class ChangeRecord extends Record { + /** Next [ChangeRecord] */ + ChangeRecord get nextChange; +} + +/** + * If [ChangeDetector] is watching a collection (an [Iterable]) then the + * [currentValue] of [Record] will contain this object. The object contains a + * summary of changes to the collection since the last execution. The changes + * are reported as a list of [CollectionChangeItem]s which contain the current + * and previous position in the list as well as the item. + */ +abstract class CollectionChangeRecord { + /** The underlying iterable object */ + Iterable get iterable; + + /** A list of [CollectionItem]s which are in the iteration order. */ + CollectionItem get collectionHead; + /** A list of new [AddedItem]s. */ + AddedItem get additionsHead; + /** A list of [MovedItem]s. */ + MovedItem get movesHead; + /** A list of [RemovedItem]s. */ + RemovedItem get removalsHead; +} + +/** + * Each item in collection is wrapped in [CollectionChangeItem], which can track + * the [item]s [currentKey] and [previousKey] location. + */ +abstract class CollectionChangeItem { + /** Previous item location in the list or [null] if addition. */ + K get previousKey; + + /** Current item location in the list or [null] if removal. */ + K get currentKey; + + /** The item. */ + V get item; +} + +/** + * Used to create a linked list of collection items. + * These items are always in the iteration order of the collection. + */ +abstract class CollectionItem extends CollectionChangeItem { + CollectionItem get nextCollectionItem; +} + +/** + * A linked list of new items added to the collection. + * These items are always in the iteration order of the collection. + */ +abstract class AddedItem extends CollectionChangeItem { + AddedItem get nextAddedItem; +} + +/** + * A linked list of moved items in to the collection. + * These items are always in the iteration order of the collection. + */ +abstract class MovedItem extends CollectionChangeItem { + MovedItem get nextMovedItem; +} + +/** + * A linked list of removed items in to the collection. + * These items are always in the iteration order of the collection. + */ +abstract class RemovedItem extends CollectionChangeItem { + RemovedItem get nextRemovedItem; +} diff --git a/lib/change_detection/dirty_checking_change_detector.dart b/lib/change_detection/dirty_checking_change_detector.dart new file mode 100644 index 000000000..6df3307b8 --- /dev/null +++ b/lib/change_detection/dirty_checking_change_detector.dart @@ -0,0 +1,888 @@ +library dirty_checking_change_detector; + +import 'dart:mirrors'; +import 'dart:collection'; +import 'package:angular/change_detection/change_detection.dart'; + +typedef FieldGetter(object); + +class GetterCache { + Map _map; + + GetterCache(this._map); + + FieldGetter call(String name) => _map[name]; +} + +/** + * [DirtyCheckingChangeDetector] determines which object properties have changed + * by comparing them to the their previous value. + * + * GOALS: + * - Plugable implementation, replaceable with other technologies, such as + * Object.observe(). + * - SPEED this needs to be as fast as possible. + * - No GC pressure. Since change detection runs often it should perform no + * memory allocations. + * - The changes need to be delivered in a single data-structure at once. + * There are two reasons for this: + * 1. It should be easy to measure the cost of change detection vs + * processing. + * 2. The feature may move to VM for performance reason. The VM should be + * free to implement it in any way. The only requirement is that the list of + * changes need to be delivered. + * + * [DirtyCheckingRecord] + * + * Each property to be watched is recorded as a [DirtyCheckingRecord] and kept + * in a linked list. Linked list are faster than Arrays for iteration. They also + * allow removal of large blocks of watches in an efficient manner. + * + * [ChangeRecord] + * + * When the results are delivered they are a linked list of [ChangeRecord]s. For + * efficiency reasons the [DirtyCheckingRecord] and [ChangeRecord] are two + * different interfaces for the same underlying object this makes reporting + * efficient since no additional memory allocation is performed. + */ +class DirtyCheckingChangeDetectorGroup implements ChangeDetectorGroup { + /** + * A group must have at least one record so that it can act as a placeholder. + * This record has minimal cost and never detects change. Once actual records + * get added the marker record gets removed, but it gets reinserted if all + * other records are removed. + */ + final DirtyCheckingRecord _marker = new DirtyCheckingRecord.marker(); + + final GetterCache _getterCache; + + /** + * All records for group are kept together and are denoted by head/tail. + */ + DirtyCheckingRecord _head, _tail; + + /** + * ChangeDetectorGroup is organized hierarchically, a root group can have + * child groups and so on. We keep track of parent, children and next, + * previous here. + */ + DirtyCheckingChangeDetectorGroup _parent, _childHead, _childTail, _prev, _next; + + DirtyCheckingChangeDetectorGroup(this._parent, this._getterCache) { + // we need to insert the marker record at the beginning. + if (_parent == null) { + _head = _marker; + _tail = _marker; + } else { + // we need to find the tail of previous record + // If we are first then it is the tail of the parent group + // otherwise it is the tail of the previous group + DirtyCheckingChangeDetectorGroup tail = _parent._childTail; + _tail = (tail == null ? _parent : tail)._tail; + _head = _tail = _recordAdd(_marker); + } + } + + /** + * Returns the number of watches in this group (including child groups). + */ + get count { + int count = 0; + DirtyCheckingRecord cursor = _head == _marker ? + _head._nextWatch : + _head; + while (cursor != null) { + count++; + cursor = cursor._nextWatch; + } + return count; + } + + WatchRecord watch(Object object, String field, H handler) { + var getter = field == null ? null : _getterCache(field); + return _recordAdd(new DirtyCheckingRecord(this, object, field, getter, + handler)); + } + + /** + * Create a child [ChangeDetector] group. + */ + DirtyCheckingChangeDetectorGroup newGroup() { + var child = new DirtyCheckingChangeDetectorGroup(this, _getterCache); + if (_childHead == null) { + _childHead = _childTail = child; + } else { + child._prev = _childTail; + _childTail._next = child; + _childTail = child; + } + return child; + } + + /** + * Bulk remove all records. + */ + void remove() { + DirtyCheckingRecord previousRecord = _head._prevWatch; + var childTail = _childTail == null ? this : _childTail; + DirtyCheckingRecord nextRecord = childTail._tail._nextWatch; + + if (previousRecord != null) previousRecord._nextWatch = nextRecord; + if (nextRecord != null) nextRecord._prevWatch = previousRecord; + + var prevGroup = _prev; + var nextGroup = _next; + + if (prevGroup == null) { + _parent._childHead = nextGroup; + } else { + prevGroup._next = nextGroup; + } + if (nextGroup == null) { + _parent._childTail = prevGroup; + } else { + nextGroup._prev = prevGroup; + } + } + + _recordAdd(DirtyCheckingRecord record) { + DirtyCheckingRecord previous = _tail; + DirtyCheckingRecord next = previous == null ? null : previous._nextWatch; + + record._nextWatch = next; + record._prevWatch = previous; + + if (previous != null) previous._nextWatch = record; + if (next != null) next._prevWatch = record; + + _tail = record; + + if (previous == _marker) _recordRemove(_marker); + + return record; + } + + _recordRemove(DirtyCheckingRecord record) { + DirtyCheckingRecord previous = record._prevWatch; + DirtyCheckingRecord next = record._nextWatch; + + if (record == _head && record == _tail) { + // we are the last one, must leave marker behind. + _head = _tail = _marker; + _marker._nextWatch = next; + _marker._prevWatch = previous; + if (previous != null) previous._nextWatch = _marker; + if (next != null) next._prevWatch = _marker; + } else { + if (record == _tail) _tail = previous; + if (record == _head) _head = next; + if (previous != null) previous._nextWatch = next; + if (next != null) next._prevWatch = previous; + } + } + + toString() { + var lines = []; + if (_parent == null) { + var allRecords = []; + DirtyCheckingRecord record = _head; + while (record != null) { + allRecords.add(record.toString()); + record = record._nextWatch; + } + lines.add('FIELDS: ${allRecords.join(', ')}'); + } + + var records = []; + DirtyCheckingRecord record = _head; + while (record != _tail) { + records.add(record.toString()); + record = record._nextWatch; + } + records.add(record.toString()); + + lines.add('DirtyCheckingChangeDetectorGroup(fields: ${records.join(', ')})'); + var childGroup = _childHead; + while (childGroup != null) { + lines.add(' ' + childGroup.toString().split('\n').join('\n ')); + childGroup = childGroup._next; + } + return lines.join('\n'); + } +} + +class DirtyCheckingChangeDetector extends DirtyCheckingChangeDetectorGroup + implements ChangeDetector { + DirtyCheckingChangeDetector(GetterCache getterCache): super(null, getterCache); + + DirtyCheckingRecord collectChanges() { + DirtyCheckingRecord changeHead = null; + DirtyCheckingRecord changeTail = null; + DirtyCheckingRecord current = _head; // current index + + while (current != null) { + if (current.check() != null) { + if (changeHead == null) { + changeHead = changeTail = current; + } else { + changeTail = changeTail.nextChange = current; + } + } + current = current._nextWatch; + } + if (changeTail != null) changeTail.nextChange = null; + return changeHead; + } + + remove() { + throw new StateError('Root ChangeDetector can not be removed'); + } +} + +/** + * [DirtyCheckingRecord] represents as single item to check. The heart of the + * [DirtyCheckingRecord] is a the [check] method which can read the + * [currentValue] and compare it to the [previousValue]. + * + * [DirtyCheckingRecord]s form linked list. This makes traversal, adding, and + * removing efficient. [DirtyCheckingRecord] also has a [nextChange] field which + * creates a single linked list of all of the changes for efficient traversal. + */ +class DirtyCheckingRecord implements ChangeRecord, WatchRecord { + static const List _MODE_NAMES = + const ['MARKER', 'IDENT', 'REFLECT', 'GETTER', 'MAP[]', 'ITERABLE', 'MAP']; + static const int _MODE_MARKER_ = 0; + static const int _MODE_IDENTITY_ = 1; + static const int _MODE_REFLECT_ = 2; + static const int _MODE_GETTER_ = 3; + static const int _MODE_MAP_FIELD_ = 4; + static const int _MODE_ITERABLE_ = 5; + static const int _MODE_MAP_ = 6; + + final DirtyCheckingChangeDetectorGroup _group; + final String field; + final Symbol _symbol; + final FieldGetter _getter; + final H handler; + + int _mode; + + var previousValue; + var currentValue; + DirtyCheckingRecord _nextWatch; + DirtyCheckingRecord _prevWatch; + ChangeRecord nextChange; + var _object; + InstanceMirror _instanceMirror; + + DirtyCheckingRecord(this._group, object, fieldName, this._getter, this.handler) + : field = fieldName, + _symbol = fieldName == null ? null : new Symbol(fieldName) + { + this.object = object; + } + + DirtyCheckingRecord.marker() + : handler = null, + field = null, + _group = null, + _symbol = null, + _getter = null, + _mode = _MODE_MARKER_; + + get object => _object; + + /** + * Setting an [object] will cause the setter to introspect it and place + * [DirtyCheckingRecord] into different access modes. If Object it sets up + * reflection. If [Map] then it sets up map accessor. + */ + set object(obj) { + this._object = obj; + if (obj == null) { + _mode = _MODE_IDENTITY_; + } else if (field == null) { + _instanceMirror = null; + if (obj is Map) { + _mode = _MODE_MAP_; + currentValue = new _MapChangeRecord(); + } else if (obj is Iterable) { + if (_mode == _MODE_ITERABLE_) return; // Last one was collection as well, don't reset state. + _mode = _MODE_ITERABLE_; + currentValue = new _CollectionChangeRecord(); + } else { + _mode = _MODE_IDENTITY_; + } + } else { + if (obj is Map) { + _mode = _MODE_MAP_FIELD_; + _instanceMirror = null; + } else if (_getter != null) { + _mode = _MODE_GETTER_; + _instanceMirror = null; + } else { + _mode = _MODE_REFLECT_; + _instanceMirror = reflect(obj); + } + } + } + + ChangeRecord check() { + assert(_mode != null); + var current; + switch (_mode) { + case _MODE_MARKER_: + return null; + case _MODE_REFLECT_: + current = _instanceMirror.getField(_symbol).reflectee; + break; + case _MODE_GETTER_: + current = _getter(object); + break; + case _MODE_MAP_FIELD_: + current = object[field]; + break; + case _MODE_IDENTITY_: + current = object; + break; + case _MODE_MAP_: + return mapCheck(object) ? this : null; + case _MODE_ITERABLE_: + return iterableCheck(object) ? this : null; + default: + assert(false); + } + + var last = currentValue; + if (!identical(last, current)) { + if (last is String && current is String && + last == current) { + // This is false change in strings we need to recover, and pretend it + // is the same. We save the value so that next time identity will pass + currentValue = current; + } else { + previousValue = last; + currentValue = current; + return this; + } + } + return null; + } + + mapCheck(Map map) { + assert('TODO: implement!' == true); + _MapChangeRecord mapChangeRecord = currentValue as _MapChangeRecord; + ItemRecord record = mapChangeRecord._collectionHead; + mapChangeRecord.truncate(record); + map.forEach((key, value) { + if (record == null || !identical(value, record.item)) { } + }); + return mapChangeRecord.isDirty; + } + + + /** + * Check the [Iterable] [collection] for changes. + */ + iterableCheck(Iterable collection) { + _CollectionChangeRecord collectionChangeRecord = + currentValue as _CollectionChangeRecord; + collectionChangeRecord._reset(); + ItemRecord record = collectionChangeRecord._collectionHead; + bool maybeDirty = false; + if ((collection is UnmodifiableListView) && + identical(collectionChangeRecord._iterable, collection)) { + // Short circuit and assume that the list has not been modified. + return false; + } else if (collection is List) { + var list = collection as List; + for(int index = 0, length = list.length; index < length; index++) { + var item = list[index]; + if (record == null || !identical(item, record.item)) { + record = collectionChangeRecord.mismatch(record, item, index); + maybeDirty = true; + } else if (maybeDirty) { + // TODO(misko): can we limit this to duplicates only? + record = collectionChangeRecord.verifyReinsertion(record, item, index); + } + record = record._nextRec; + } + } else { + int index = 0; + for(var item in collection) { + if (record == null || !identical(item, record.item)) { + record = collectionChangeRecord.mismatch(record, item, index); + maybeDirty = true; + } else if (maybeDirty) { + // TODO(misko): can we limit this to duplicates only? + record = collectionChangeRecord.verifyReinsertion(record, item, index); + } + record = record._nextRec; + index++; + } + } + collectionChangeRecord.truncate(record); + collectionChangeRecord._iterable = collection; + return collectionChangeRecord.isDirty; + } + + remove() { + _group._recordRemove(this); + } + + toString() => '${_MODE_NAMES[_mode]}[$field]'; +} + +final Object _INITIAL_ = new Object(); + +class _MapChangeRecord implements CollectionChangeRecord { +} + +class _CollectionChangeRecord implements CollectionChangeRecord { + Iterable _iterable; + /** Used to keep track of items during moves. */ + DuplicateMap _items = new DuplicateMap(); + + /** Used to keep track of removed items. */ + DuplicateMap _removedItems = new DuplicateMap(); + + ItemRecord _collectionHead, _collectionTail; + ItemRecord _additionsHead, _additionsTail; + ItemRecord _movesHead, _movesTail; + ItemRecord _removalsHead, _removalsTail; + + CollectionChangeItem get collectionHead => _collectionHead; + CollectionChangeItem get additionsHead => _additionsHead; + CollectionChangeItem get movesHead => _movesHead; + CollectionChangeItem get removalsHead => _removalsHead; + + Iterable get iterable => _iterable; + + /** + * Reset the state of the change objects to show no changes. This means set + * previousKey to currentKey, and clear all of the queues (additions, moves, + * removals). + */ + _reset() { + ItemRecord record; + + record = _additionsHead; + while(record != null) { + record.previousKey = record.currentKey; + record = record._nextAddedRec; + } + _additionsHead = _additionsTail = null; + + record = _movesHead; + while(record != null) { + record.previousKey = record.currentKey; + record = record._nextMovedRec; + } + _movesHead = _movesTail = null; + + record = _removalsHead; + while(record != null) { + record.previousKey = record.currentKey; + record = record._nextRemovedRec; + } + _removalsHead = _removalsTail = null; + } + + /** + * A [_CollectionChangeRecord] is considered dirty if it has additions, moves + * or removals. + */ + get isDirty => _additionsHead != null || _movesHead != null || + _removalsHead != null; + + /** + * This is the core function which handles differences between collections. + * + * - [record] is the record which we saw at this position last time. If `null` + * then it is a new item. + * - [item] is the current item in the collection + * - [index] is the position of the item in the collection + */ + mismatch(ItemRecord record, dynamic item, int index) { + // Guard against bogus String changes + if (record != null && item is String && record.item is String && + record == item) { + // this is false change in strings we need to recover, and pretend it is + // the same. We save the value so that next time identity will pass + return record..item = item; + } + + // find the previous record os that we know where to insert after. + ItemRecord prev = record == null ? _collectionTail : record._prevRec; + + // Remove the record from the collection since we know it does not match the item. + if (record != null) _collection_remove(record); + // Attempt to see if we have seen the item before. + record = _items.get(item, index); + if (record != null) { + // We have seen this before, we need to move it forward in the collection. + _collection_moveAfter(record, prev, index); + } else { + // Never seen it, check evicted list. + record = _removedItems.get(item); + if (record != null) { + // It is an item which we have earlier evict it, reinsert it back into the list. + _collection_reinsertAfter(record, prev, index); + } else { + // It is a new item add it. + record = _collection_addAfter(new ItemRecord(item), prev, index); + } + } + return record; + } + + /** + * This check is only needed if an array contains duplicates. (Short circuit + * of nothing dirty) + * + * Use case: `[a, a]` => `[b, a, a]` + * + * If we did not have this check then the insertion of `b` would: + * 1) evict first `a` + * 2) insert `b` at `0` index. + * 3) leave `a` at index `1` as is. <-- this is wrong! + * 3) reinsert `a` at index 2. <-- this is wrong! + * + * The correct behavior is: + * 1) evict first `a` + * 2) insert `b` at `0` index. + * 3) reinsert `a` at index 1. + * 3) move `a` at from `1` to `2`. + * + * + * Double check that we have not evicted a duplicate item. We need to check if + * the item type may have already been removed: + * The insertion of b will evict the first 'a'. If we don't reinsert it now it + * will be reinserted at the end. Which will show up as the two 'a's switching + * position. This is incorrect, since a better way to think of it is as insert + * of 'b' rather then switch 'a' with 'b' and then add 'a' at the end. + */ + verifyReinsertion(ItemRecord record, dynamic item, int index) { + ItemRecord reinsertRecord = _removedItems.get(item); + if (reinsertRecord != null) { + record = _collection_reinsertAfter(reinsertRecord, record._prevRec, index); + } else if (record.currentKey != index) { + record.currentKey = index; + _moves_add(record); + } + return record; + } + + /** + * Get rid of any excess [ItemRecord]s from the previous collection + * + * - [record] The first excess [ItemRecord]. + */ + void truncate(ItemRecord record) { + // Anything after that needs to be removed; + while(record != null) { + ItemRecord nextRecord = record._nextRec; + _removals_add(_collection_unlink(record)); + record = nextRecord; + } + _removedItems.clear(); + } + + ItemRecord _collection_reinsertAfter(ItemRecord record, ItemRecord insertPrev, int index) { + _removedItems.remove(record); + var prev = record._prevRemovedRec; + var next = record._nextRemovedRec; + + assert((record._prevRemovedRec = null) == null); + assert((record._nextRemovedRec = null) == null); + + if (prev == null) { + _removalsHead = next; + } else { + prev._nextRemovedRec = next; + } + if (next == null) { + _removalsTail = prev; + } else { + next._prevRemovedRec = prev; + } + + _collection_insertAfter(record, insertPrev, index); + _moves_add(record); + return record; + } + + ItemRecord _collection_moveAfter(ItemRecord record, ItemRecord prev, int index) { + _collection_unlink(record); + _collection_insertAfter(record, prev, index); + _moves_add(record); + return record; + } + + ItemRecord _collection_addAfter(ItemRecord record, ItemRecord prev, int index) { + _collection_insertAfter(record, prev, index); + + if (_additionsTail == null) { + assert(_additionsHead == null); + _additionsTail = _additionsHead = record; + } else { + assert(_additionsTail._nextAddedRec == null); + assert(record._nextAddedRec == null); + _additionsTail = _additionsTail._nextAddedRec = record; + } + return record; + } + + ItemRecord _collection_insertAfter(ItemRecord record, ItemRecord prev, int index) { + assert(record != prev); + assert(record._nextRec == null); + assert(record._prevRec == null); + + ItemRecord next = prev == null ? _collectionHead : prev._nextRec; + assert(next != record); + assert(prev != record); + record._nextRec = next; + record._prevRec = prev; + if (next == null) { + _collectionTail = record; + } else { + next._prevRec = record; + } + if (prev == null) { + _collectionHead = record; + } else { + prev._nextRec = record; + } + + _items.put(record); + record.currentKey = index; + return record; + } + + ItemRecord _collection_remove(ItemRecord record) => + _removals_add(_collection_unlink(record)); + + ItemRecord _collection_unlink(ItemRecord record) { + _items.remove(record); + + var prev = record._prevRec; + var next = record._nextRec; + + assert((record._prevRec = null) == null); + assert((record._nextRec = null) == null); + + if (prev == null) { + _collectionHead = next; + } else { + prev._nextRec = next; + } + if (next == null) { + _collectionTail = prev; + } else { + next._prevRec = prev; + } + + return record; + } + + ItemRecord _moves_add(ItemRecord record) { + if (_movesTail == null) { + assert(_movesHead == null); + _movesTail = _movesHead = record; + } else { + assert(_movesTail._nextMovedRec == null); + assert(record._nextMovedRec == null); + _movesTail = _movesTail._nextMovedRec = record; + } + + return record; + } + + ItemRecord _removals_add(ItemRecord record) { + record.currentKey = null; + _removedItems.put(record); + + if (_removalsTail == null) { + assert(_removalsHead == null); + _removalsTail = _removalsHead = record; + } else { + assert(_removalsTail._nextRemovedRec == null); + assert(record._nextRemovedRec == null); + record._prevRemovedRec = _removalsTail; + _removalsTail = _removalsTail._nextRemovedRec = record; + } + return record; + } + + toString() { + ItemRecord record; + + var list = []; + record = _collectionHead; + while(record != null) { + list.add(record); + record = record._nextRec; + } + + var additions = []; + record = _additionsHead; + while(record != null) { + additions.add(record); + record = record._nextAddedRec; + } + + var moves = []; + record = _movesHead; + while(record != null) { + moves.add(record); + record = record._nextMovedRec; + } + + var removals = []; + record = _removalsHead; + while(record != null) { + removals.add(record); + record = record._nextRemovedRec; + } + + return """ +collection: ${list.join(", ")} +additions: ${additions.join(", ")} +moves: ${moves.join(", ")} +removals: ${removals.join(", ")}' + """; + } +} + +class ItemRecord implements CollectionItem, AddedItem, MovedItem, RemovedItem { + K previousKey = null; + K currentKey = null; + V item = _INITIAL_; + + ItemRecord _prevRec, _nextRec; + ItemRecord _prevDupRec, _nextDupRec; + ItemRecord _prevRemovedRec, _nextRemovedRec; + ItemRecord _nextAddedRec, _nextMovedRec; + + CollectionItem get nextCollectionItem => _nextRec; + RemovedItem get nextRemovedItem => _nextRemovedRec; + AddedItem get nextAddedItem => _nextAddedRec; + MovedItem get nextMovedItem => _nextMovedRec; + + ItemRecord(this.item); + + toString() => previousKey == currentKey ? + '$item' : + '$item[$previousKey -> $currentKey]'; +} + +class _DuplicateItemRecordList { + ItemRecord head, tail; + + add(ItemRecord record, ItemRecord beforeRecord) { + assert(record._prevDupRec == null); + assert(record._nextDupRec == null); + assert(beforeRecord == null ? true : beforeRecord.item == record.item); + if (head == null) { + assert(beforeRecord == null); + head = tail = record; + } else { + assert(record.item == head.item); + if (beforeRecord == null) { + tail._nextDupRec = record; + record._prevDupRec = tail; + tail = record; + } else { + var prev = beforeRecord._prevDupRec; + var next = beforeRecord; + record._prevDupRec = prev; + record._nextDupRec = next; + if (prev == null) head = record; else prev._nextDupRec = record; + next._prevDupRec = record; + } + } + } + + ItemRecord get(dynamic key, int hideIndex) { + ItemRecord record = head; + while(record != null) { + if (hideIndex == null ? true : hideIndex < record.currentKey && + identical(record.item, key)) { + return record; + } + record = record._nextDupRec; + } + return record; + } + + bool remove(ItemRecord record) { + assert(() { + // verify that the record being removed is someplace in the list. + ItemRecord cursor = head; + while(cursor != null) { + if (identical(cursor, record)) return true; + cursor = cursor._nextDupRec; + } + return false; + }); + + var prev = record._prevDupRec; + var next = record._nextDupRec; + if (prev == null) { + head = next; + } else { + prev._nextDupRec = next; + } + if (next == null) { + tail = prev; + } else { + next._prevDupRec = prev; + } + + assert((record._prevDupRec = null) == null); + assert((record._nextDupRec = null) == null); + + return head == null; + } +} + +/** + * This is a custom map which supports duplicate [ItemRecord] values for each key. + */ +class DuplicateMap { + final Map map = + new Map(); + + void put(ItemRecord record, [ItemRecord beforeRecord = null]) { + assert(record._nextDupRec == null); + assert(record._prevDupRec == null); + map.putIfAbsent(record.item, () => + new _DuplicateItemRecordList()).add(record, beforeRecord); + } + + /** + * Retrieve the `value` using [key]. Because the [ItemRecord] value maybe one + * which we have already iterated over, we use the [hideIndex] to pretend it + * is not there. + * + * Use case: `[a, b, c, a, a]` if we are at index `3` which is the second `a` + * then asking if we have any more `a`s needs to return the last `a` not the + * first or second. + */ + ItemRecord get(dynamic key, [int hideIndex]) { + _DuplicateItemRecordList recordList = map[key]; + ItemRecord item = recordList == null ? null : recordList.get(key, hideIndex); + return item; + } + + ItemRecord remove(ItemRecord record) { + _DuplicateItemRecordList recordList = map[record.item]; + assert(recordList != null); + if (recordList.remove(record)) { + map.remove(record.item); + } + return record; + } + + clear() => map.clear(); +} diff --git a/lib/change_detection/linked_list.dart b/lib/change_detection/linked_list.dart new file mode 100644 index 000000000..a2d10aa88 --- /dev/null +++ b/lib/change_detection/linked_list.dart @@ -0,0 +1,160 @@ +part of angular.watch_group; + + +class _LinkedListItem { + I _previous, _next; +} + +class _LinkedList { + L _head, _tail; + + static _Handler _add(_Handler list, _LinkedListItem item) { + assert(item._next == null); + assert(item._previous == null); + if (list._tail == null) { + list._head = list._tail = item; + } else { + item._previous = list._tail; + list._tail._next = item; + list._tail = item; + } + return item; + } + + static _isEmpty(_Handler list) => list._head == null; + + static _remove(_Handler list, _Handler item) { + var previous = item._previous; + var next = item._next; + + if (previous == null) list._head = next; else previous._next = next; + if (next == null) list._tail = previous; else next._previous = previous; + } +} + +class _ArgHandlerList { + _ArgHandler _argHandlerHead, _argHandlerTail; + + static _Handler _add(_ArgHandlerList list, _ArgHandler item) { + assert(item._nextArgHandler == null); + assert(item._previousArgHandler == null); + if (list._argHandlerTail == null) { + list._argHandlerHead = list._argHandlerTail = item; + } else { + item._previousArgHandler = list._argHandlerTail; + list._argHandlerTail._nextArgHandler = item; + list._argHandlerTail = item; + } + return item; + } + + static _isEmpty(_InvokeHandler list) => list._argHandlerHead == null; + + static _remove(_InvokeHandler list, _ArgHandler item) { + var previous = item._previousArgHandler; + var next = item._nextArgHandler; + + if (previous == null) list._argHandlerHead = next; else previous._nextArgHandler = next; + if (next == null) list._argHandlerTail = previous; else next._previousArgHandler = previous; + } +} + +class _WatchList { + Watch _watchHead, _watchTail; + + static Watch _add(_WatchList list, Watch item) { + assert(item._nextWatch == null); + assert(item._previousWatch == null); + if (list._watchTail == null) { + list._watchHead = list._watchTail = item; + } else { + item._previousWatch = list._watchTail; + list._watchTail._nextWatch = item; + list._watchTail = item; + } + return item; + } + + static _isEmpty(_Handler list) => list._watchHead == null; + + static _remove(_Handler list, Watch item) { + var previous = item._previousWatch; + var next = item._nextWatch; + + if (previous == null) list._watchHead = next; else previous._nextWatch = next; + if (next == null) list._watchTail = previous; else next._previousWatch = previous; + } +} + +class _EvalWatchList { + _EvalWatchRecord _evalWatchHead, _evalWatchTail, _marker; + + static _EvalWatchRecord _add(_EvalWatchList list, _EvalWatchRecord item) { + assert(item._nextEvalWatch == null); + assert(item._previousEvalWatch == null); + var prev = list._evalWatchTail; + var next = prev._nextEvalWatch; + + if (prev == list._marker) { + list._evalWatchHead = list._evalWatchTail = item; + prev = prev._previousEvalWatch; + } + item._nextEvalWatch = next; + item._previousEvalWatch = prev; + + if (prev != null) prev._nextEvalWatch = item; + if (next != null) next._previousEvalWatch = item; + + list._evalWatchTail = item; + return item; + } + + static _isEmpty(_EvalWatchList list) => list._evalWatchHead == null; + + static _remove(_EvalWatchList list, _EvalWatchRecord item) { + assert(item.watchGrp == list); + var prev = item._previousEvalWatch; + var next = item._nextEvalWatch; + + if (list._evalWatchHead == list._evalWatchTail) { + list._evalWatchHead = list._evalWatchTail = list._marker; + list._marker + .._nextEvalWatch = next + .._previousEvalWatch = prev; + if (prev != null) prev._nextEvalWatch = list._marker; + if (next != null) next._previousEvalWatch = list._marker; + } else { + if (item == list._evalWatchHead) list._evalWatchHead = next; + if (item == list._evalWatchTail) list._evalWatchTail = prev; + if (prev != null) prev._nextEvalWatch = next; + if (next != null) next._previousEvalWatch = prev; + } + } +} + +class _WatchGroupList { + WatchGroup _watchGroupHead, _watchGroupTail; + + static WatchGroup _add(_WatchGroupList list, WatchGroup item) { + assert(item._nextWatchGroup == null); + assert(item._previousWatchGroup == null); + if (list._watchGroupTail == null) { + list._watchGroupHead = list._watchGroupTail = item; + } else { + item._previousWatchGroup = list._watchGroupTail; + list._watchGroupTail._nextWatchGroup = item; + list._watchGroupTail = item; + } + return item; + } + + static _isEmpty(_WatchGroupList list) => list._watchGroupHead == null; + + static _remove(_WatchGroupList list, WatchGroup item) { + var previous = item._previousWatchGroup; + var next = item._nextWatchGroup; + + if (previous == null) list._watchGroupHead = next; else previous._nextWatchGroup = next; + if (next == null) list._watchGroupTail = previous; else next._previousWatchGroup = previous; + } +} diff --git a/lib/change_detection/prototype_map.dart b/lib/change_detection/prototype_map.dart new file mode 100644 index 000000000..a043eae63 --- /dev/null +++ b/lib/change_detection/prototype_map.dart @@ -0,0 +1,10 @@ +part of angular.watch_group; + +class PrototypeMap implements Map { + final Map prototype; + final Map self = new Map(); + PrototypeMap(this.prototype); + + operator []=(name, value) => self[name] = value; + operator [](name) => self.containsKey(name) ? self[name] : prototype[name]; +} diff --git a/lib/change_detection/watch_group.dart b/lib/change_detection/watch_group.dart new file mode 100644 index 000000000..bece0f0bc --- /dev/null +++ b/lib/change_detection/watch_group.dart @@ -0,0 +1,712 @@ +library angular.watch_group; + +import 'dart:mirrors'; +import 'dart:collection'; +import 'package:angular/change_detection/change_detection.dart'; + +part 'linked_list.dart'; +part 'ast.dart'; +part 'prototype_map.dart'; + +typedef ReactionFn(value, previousValue, object); + +/** + * Extend this class if you wish to pretend to be a function, but you don't know + * number of arguments with which the function will get called. + */ +abstract class FunctionApply { + dynamic call() { throw new StateError('Use apply()'); } + dynamic apply(List arguments); +} + +/** + * [WatchGroup] is a logical grouping of a set of watches. [WatchGroup]s are + * organized into a hierarchical tree parent-children configuration. + * [WatchGroup] builds upon [ChangeDetector] and adds expression (field chains + * as in `a.b.c`) support as well as support function/closure/method (function + * invocation as in `a.b()`) watching. + */ +class WatchGroup implements _EvalWatchList, _WatchGroupList { + /** A unique ID for the WatchGroup */ + final String id; + /** + * A marker to be inserted when a group has no watches. We need the marker to + * hold our position information in the linked list of all [Watch]es. + */ + final _EvalWatchRecord _marker = new _EvalWatchRecord.marker(); + + /** All Expressions are evaluated against a context object. */ + final Object context; + + /** [ChangeDetector] used for field watching */ + final ChangeDetectorGroup<_Handler> _changeDetector; + /** A cache for sharing sub expression watching. Watching `a` and `a.b` will + * watch `a` only once. */ + final Map> _cache; + final RootWatchGroup _rootGroup; + + /// STATS: Number of field watchers which are in use. + int _fieldCost = 0; + int _collectionCost = 0; + + /// STATS: Number of field watchers which are in use including child [WatchGroup]s. + int get fieldCost => _fieldCost; + int get totalFieldCost { + var cost = _fieldCost; + WatchGroup group = _watchGroupHead; + while(group != null) { + cost += group.totalFieldCost; + group = group._nextWatchGroup; + } + return cost; + } + + /// STATS: Number of collection watchers which are in use including child [WatchGroup]s. + int get collectionCost => _collectionCost; + int get totalCollectionCost { + var cost = _collectionCost; + WatchGroup group = _watchGroupHead; + while(group != null) { + cost += group.totalCollectionCost; + group = group._nextWatchGroup; + } + return cost; + } + + /// STATS: Number of invocation watchers (closures/methods) which are in use. + int get evalCost => _evalCost; + int _evalCost = 0; + + /// STATS: Number of invocation watchers which are in use including child [WatchGroup]s. + int get totalEvalCost { + var cost = _evalCost; + WatchGroup group = _watchGroupHead; + while(group != null) { + cost += group.evalCost; + group = group._nextWatchGroup; + } + return cost; + } + + int _nextChildId = 0; + _EvalWatchRecord _evalWatchHead, _evalWatchTail; + /// Pointer for creating tree of [WatchGroup]s. + WatchGroup _watchGroupHead, _watchGroupTail, _previousWatchGroup, + _nextWatchGroup; + final WatchGroup _parentWatchGroup; + + WatchGroup._child(_parentWatchGroup, this._changeDetector, + this.context, this._cache, this._rootGroup) + : _parentWatchGroup = _parentWatchGroup, + id = '${_parentWatchGroup.id}.${_parentWatchGroup._nextChildId++}' + { + _marker.watchGrp = this; + _evalWatchTail = _evalWatchHead = _marker; + } + + WatchGroup._root(this._changeDetector, this.context) + : id = '', + _rootGroup = null, + _cache = new Map>() + { + _marker.watchGrp = this; + _evalWatchTail = _evalWatchHead = _marker; + } + + Watch watch(AST expression, ReactionFn reactionFn) { + WatchRecord<_Handler> watchRecord = + _cache.putIfAbsent(expression.expression, + () => expression.setupWatch(this)); + return watchRecord.handler.addReactionFn(reactionFn); + } + + /** + * Watch a [name] field on [lhs] represented by [expression]. + * + * - [name] the field to watch. + * - [lhs] left-hand-side of the field. + */ + WatchRecord<_Handler> addFieldWatch(AST lhs, String name, String expression) { + var fieldHandler = new _FieldHandler(this, expression); + + // Create a ChangeRecord for the current field and assign the change record + // to the handler. + var watchRecord = _changeDetector.watch(null, name, fieldHandler); + _fieldCost++; + fieldHandler.watchRecord = watchRecord; + + WatchRecord<_Handler> lhsWR = _cache.putIfAbsent(lhs.expression, + () => lhs.setupWatch(this)); + + // We set a field forwarding handler on LHS. This will allow the change + // objects to propagate to the current WatchRecord. + lhsWR.handler.addForwardHandler(fieldHandler); + + // propagate the value from the LHS to here + fieldHandler.acceptValue(lhsWR.currentValue); + return watchRecord; + } + + WatchRecord<_Handler> addCollectionWatch(AST ast) { + var collectionHandler = new _CollectionHandler(this, ast.expression); + var watchRecord = _changeDetector.watch(null, null, collectionHandler); + _collectionCost++; + collectionHandler.watchRecord = watchRecord; + WatchRecord<_Handler> astWR = _cache.putIfAbsent(ast.expression, () => ast.setupWatch(this)); + + // We set a field forwarding handler on LHS. This will allow the change objects to propagate + // to the current WatchRecord. + astWR.handler.addForwardHandler(collectionHandler); + + // propagate the value from the LHS to here + collectionHandler.acceptValue(astWR.currentValue); + return watchRecord; + } + + /** + * Watch a [fn] function represented by an [expression]. + * + * - [fn] function to evaluate. + * - [argsAST] list of [AST]es which represent arguments passed to function. + * - [expression] normalized expression used for caching. + */ + _EvalWatchRecord addFunctionWatch(Function fn, List argsAST, + String expression) => + _addEvalWatch(null, fn, null, argsAST, expression); + + /** + * Watch a method [name]ed represented by an [expression]. + * + * - [lhs] left-hand-side of the method. + * - [name] name of the method. + * - [argsAST] list of [AST]es which represent arguments passed to method. + * - [expression] normalized expression used for caching. + */ + _EvalWatchRecord addMethodWatch(AST lhs, String name, List argsAST, + String expression) => + _addEvalWatch(lhs, null, name, argsAST, expression); + + + + _EvalWatchRecord _addEvalWatch(AST lhsAST, Function fn, String name, + List argsAST, String expression) { + _InvokeHandler invokeHandler = new _InvokeHandler(this, expression); + var evalWatchRecord = new _EvalWatchRecord(this, invokeHandler, fn, name, + argsAST.length); + invokeHandler.watchRecord = evalWatchRecord; + + if (lhsAST != null) { + var lhsWR = _cache.putIfAbsent(lhsAST.expression, + () => lhsAST.setupWatch(this)); + lhsWR.handler.addForwardHandler(invokeHandler); + invokeHandler.acceptValue(lhsWR.currentValue); + } + + // Convert the args from AST to WatchRecords + var i = 0; + argsAST. + map((ast) => _cache.putIfAbsent(ast.expression, + () => ast.setupWatch(this))).forEach((WatchRecord<_Handler> record) { + var argHandler = new _ArgHandler(this, evalWatchRecord, i++); + _ArgHandlerList._add(invokeHandler, argHandler); + record.handler.addForwardHandler(argHandler); + argHandler.acceptValue(record.currentValue); + }); + + // Must be done last + _EvalWatchList._add(this, evalWatchRecord); + _evalCost++; + + return evalWatchRecord; + } + + WatchGroup get _childWatchGroupTail { + WatchGroup tail = this; + WatchGroup nextTail; + while ((nextTail = tail._watchGroupTail) != null) { + tail = nextTail; + } + return tail; + } + + /** + * Create a new child [WatchGroup]. + * + * - [context] if present the the child [WatchGroup] expressions will evaluate + * against the new [context]. If not present than child expressions will + * evaluate on same context allowing the reuse of the expression cache. + */ + WatchGroup newGroup([Object context]) { + _EvalWatchRecord prev = _childWatchGroupTail._evalWatchTail; + _EvalWatchRecord next = prev._nextEvalWatch; + var childGroup = new WatchGroup._child( + this, + _changeDetector.newGroup(), + context == null ? this.context : context, + context == null ? this._cache: new Map>(), + _rootGroup == null ? this : _rootGroup); + _WatchGroupList._add(this, childGroup); + var marker = childGroup._marker; + + marker._previousEvalWatch = prev; + marker._nextEvalWatch = next; + if (prev != null) prev._nextEvalWatch = marker; + if (next != null) next._previousEvalWatch = marker; + + return childGroup; + } + + /** + * Remove/destroy [WatchGroup] and all of its [Watches]. + */ + void remove() { + // TODO:(misko) This code is not right. + // 1) It fails to release [ChangeDetector] [WatchRecord]s. + // 2) it needs to cleanup caches if the cache is being shared. + + _WatchGroupList._remove(_parentWatchGroup, this); + _changeDetector.remove(); + + // Unlink the _watchRecord + _EvalWatchRecord firstEvalWatch = _evalWatchHead; + _EvalWatchRecord lastEvalWatch = + (_watchGroupTail == null ? this : _watchGroupTail)._evalWatchTail; + _EvalWatchRecord previous = firstEvalWatch._previousEvalWatch; + _EvalWatchRecord next = lastEvalWatch._nextEvalWatch; + if (previous != null) previous._nextEvalWatch = next; + if (next != null) next._previousEvalWatch = previous; + } + + toString() { + var lines = []; + if (this == _rootGroup) { + var allWatches = []; + var watch = _evalWatchHead; + var prev = null; + while (watch != null) { + allWatches.add(watch.toString()); + assert(watch._previousEvalWatch == prev); + prev = watch; + watch = watch._nextEvalWatch; + } + lines.add('WATCHES: ${allWatches.join(', ')}'); + } + + var watches = []; + var watch = _evalWatchHead; + while (watch != _evalWatchTail) { + watches.add(watch.toString()); + watch = watch._nextEvalWatch; + } + watches.add(watch.toString()); + + lines.add('WatchGroup[$id](watches: ${watches.join(', ')})'); + var childGroup = _watchGroupHead; + while (childGroup != null) { + lines.add(' ' + childGroup.toString().split('\n').join('\n ')); + childGroup = childGroup._nextWatchGroup; + } + return lines.join('\n'); + } +} + +/** + * [RootWatchGroup] + */ +class RootWatchGroup extends WatchGroup { + Watch _dirtyWatchHead, _dirtyWatchTail; + + RootWatchGroup(ChangeDetector changeDetector, Object context): + super._root(changeDetector, context); + + get _rootGroup => this; + + /** + * Detect changes and process the [ReactionFn]s. + * + * Algorithm: + * 1) process the [ChangeDetector#collectChanges]. + * 2) process function/closure/method changes + * 3) call an [ReactionFn]s + * + * Each step is called in sequence. ([ReactionFn]s are not called until all previous steps are + * completed). + */ + int detectChanges() { + // Process the ChangeRecords from the change detector + ChangeRecord<_Handler> changeRecord = _changeDetector.collectChanges(); + while (changeRecord != null) { + changeRecord.handler.onChange(changeRecord); + changeRecord = changeRecord.nextChange; + } + + int count = 0; + // Process our own function evaluations + _EvalWatchRecord evalRecord = _evalWatchHead; + while (evalRecord != null) { + evalRecord.check(); + evalRecord = evalRecord._nextEvalWatch; + } + + // Because the handler can forward changes between each other synchronously + // We need to call reaction functions asynchronously. This processes the + // asynchronous reaction function queue. + Watch dirtyWatch = _dirtyWatchHead; + while(dirtyWatch != null) { + count++; + dirtyWatch.invoke(); + dirtyWatch = dirtyWatch._nextDirtyWatch; + } + _dirtyWatchHead = _dirtyWatchTail = null; + return count; + } + + /** + * Add Watch into the asynchronous queue for later processing. + */ + Watch _addDirtyWatch(Watch watch) { + if (!watch._dirty) { + watch._dirty = true; + if (_dirtyWatchTail == null) { + _dirtyWatchHead = _dirtyWatchTail = watch; + } else { + _dirtyWatchTail._nextDirtyWatch = watch; + _dirtyWatchTail = watch; + } + watch._nextDirtyWatch = null; + } + return watch; + } +} + +/** + * [Watch] corresponds to an individual [watch] registration on the watchGrp. + */ +class Watch { + Watch _previousWatch, _nextWatch; + + final Record<_Handler> _record; + final ReactionFn reactionFn; + + bool _dirty = false; + bool _deleted = false; + Watch _nextDirtyWatch; + + Watch(this._record, this.reactionFn); + + get expression => _record.handler.expression; + + invoke() { + _dirty = false; + reactionFn(_record.currentValue, _record.previousValue, _record.object); + } + + remove() { + if (_deleted) throw new StateError('Already deleted!'); + _deleted = true; + var handler = _record.handler; + _WatchList._remove(handler, this); + handler.release(); + } +} + +/** + * This class processes changes from the change detector. The changes are + * forwarded onto the next [_Handler] or queued up in case of reaction function. + * + * Given these two expression: 'a.b.c' => rfn1 and 'a.b' => rfn2 + * The resulting data structure is: + * + * _Handler +--> _Handler +--> _Handler + * - delegateHandler -+ - delegateHandler -+ - delegateHandler = null + * - expression: 'a' - expression: 'a.b' - expression: 'a.b.c' + * - watchObject: context - watchObject: context.a - watchObject: context.a.b + * - watchRecord: 'a' - watchRecord 'b' - watchRecord 'c' + * - reactionFn: null - reactionFn: rfn1 - reactionFn: rfn2 + * + * Notice how the [_Handler]s coalesce their watching. Also notice that any + * changes detected at one handler are propagated to the next handler. + */ +abstract class _Handler implements _LinkedList, _LinkedListItem, _WatchList { + _Handler _head, _tail; + _Handler _next, _previous; + Watch _watchHead, _watchTail; + + final String expression; + final WatchGroup watchGrp; + + WatchRecord<_Handler> watchRecord; + _Handler forwardingHandler; + + _Handler(this.watchGrp, this.expression) { + assert(watchGrp != null); + assert(expression != null); + } + + Watch addReactionFn(ReactionFn reactionFn) { + assert(_next != this); // verify we are not detached + return watchGrp._rootGroup._addDirtyWatch(_WatchList._add(this, + new Watch(watchRecord, reactionFn))); + } + + void addForwardHandler(_Handler forwardToHandler) { + assert(forwardToHandler.forwardingHandler == null); + _LinkedList._add(this, forwardToHandler); + forwardToHandler.forwardingHandler = this; + } + + void release() { + if (_WatchList._isEmpty(this) && _LinkedList._isEmpty(this)) { + _releaseWatch(); + // Remove ourselves from cache, or else new registrations will go to us, + // but we are dead + watchGrp._cache.remove(expression); + + if (forwardingHandler != null) { + // TODO(misko): why do we need this check? + _LinkedList._remove(forwardingHandler, this); + forwardingHandler.release(); + } + + // We can remove ourselves + assert((_next = _previous = this) == this); // mark ourselves as detached + } + } + + _releaseWatch() { + watchRecord.remove(); + watchGrp._fieldCost--; + } + acceptValue(dynamic object) => null; + + void onChange(ChangeRecord<_Handler> record) { + assert(_next != this); // verify we are not detached + // If we have reaction functions than queue them up for asynchronous + // processing. + Watch watch = _watchHead; + while(watch != null) { + watchGrp._rootGroup._addDirtyWatch(watch); + watch = watch._nextWatch; + } + // If we have a delegateHandler then forward the new value to it. + _Handler delegateHandler = _head; + while (delegateHandler != null) { + delegateHandler.acceptValue(record.currentValue); + delegateHandler = delegateHandler._next; + } + } +} + +class _ConstantHandler extends _Handler { + _ConstantHandler(WatchGroup watchGroup, String expression, dynamic constantValue) + : super(watchGroup, expression) + { + watchRecord = new _EvalWatchRecord.constant(this, constantValue); + } + release() => null; +} + +class _FieldHandler extends _Handler { + _FieldHandler(watchGrp, expression): super(watchGrp, expression); + + /** + * This function forwards the watched object to the next [_Handler] + * synchronously. + */ + acceptValue(dynamic object) { + watchRecord.object = object; + var changeRecord = watchRecord.check(); + if (changeRecord != null) onChange(changeRecord); + } +} + +class _CollectionHandler extends _Handler { + _CollectionHandler(watchGrp, expression): super(watchGrp, expression); + /** + * This function forwards the watched object to the next [_Handler] synchronously. + */ + acceptValue(dynamic object) { + watchRecord.object = object; + var changeRecord = watchRecord.check(); + if (changeRecord != null) onChange(changeRecord); + } + _releaseWatch() { + watchRecord.remove(); + watchGrp._collectionCost--; + } +} + +class _ArgHandler extends _Handler { + _ArgHandler _previousArgHandler, _nextArgHandler; + + // TODO(misko): Why do we override parent? + final _EvalWatchRecord watchRecord; + final int index; + + _releaseWatch() => null; + + _ArgHandler(WatchGroup watchGrp, this.watchRecord, int index) + : super(watchGrp, 'arg[$index]'), index = index; + + acceptValue(dynamic object) { + watchRecord.dirtyArgs = true; + watchRecord.args[index] = object; + } +} + +class _InvokeHandler extends _Handler implements _ArgHandlerList { + _ArgHandler _argHandlerHead, _argHandlerTail; + + _InvokeHandler(watchGrp, expression): super(watchGrp, expression); + + acceptValue(dynamic object) => watchRecord.object = object; + + _releaseWatch() => (watchRecord as _EvalWatchRecord).remove(); + + release() { + super.release(); + _ArgHandler current = _argHandlerHead; + while(current != null) { + current.release(); + current = current._nextArgHandler; + } + } +} + + +class _EvalWatchRecord implements WatchRecord<_Handler>, ChangeRecord<_Handler> { + static const int _MODE_DELETED_ = -1; + static const int _MODE_MARKER_ = 0; + static const int _MODE_FUNCTION_ = 1; + static const int _MODE_FUNCTION_APPLY_ = 2; + static const int _MODE_NULL_ = 3; + static const int _MODE_FIELD_CLOSURE_ = 4; + static const int _MODE_MAP_CLOSURE_ = 5; + static const int _MODE_METHOD_ = 6; + WatchGroup watchGrp; + final _Handler handler; + final List args; + final Symbol symbol; + final String name; + int mode; + Function fn; + InstanceMirror _instanceMirror; + bool dirtyArgs = true; + + dynamic currentValue, previousValue, _object; + _EvalWatchRecord _previousEvalWatch, _nextEvalWatch; + + _EvalWatchRecord(this.watchGrp, this.handler, this.fn, name, int arity) + : args = new List(arity), + name = name, + symbol = new Symbol(name) + { + if (fn is FunctionApply) mode = _MODE_FUNCTION_APPLY_; + else if (fn is Function) mode = _MODE_FUNCTION_; + else mode = _MODE_NULL_; + } + + _EvalWatchRecord.marker() + : mode = _MODE_MARKER_, + watchGrp = null, + handler = null, + args = null, + fn = null, + symbol = null, + name = null; + + _EvalWatchRecord.constant(_Handler handler, dynamic constantValue) + : mode = _MODE_MARKER_, + handler = handler, + currentValue = constantValue, + watchGrp = null, + args = null, + fn = null, + symbol = null, + name = null; + + get field => '()'; + + get object => _object; + + set object(value) { + assert(mode != _MODE_DELETED_); + assert(mode != _MODE_MARKER_); + assert(mode != _MODE_FUNCTION_); + assert(mode != _MODE_FUNCTION_APPLY_); + assert(symbol != null); + _object = value; + + if (value == null) { + mode = _MODE_NULL_; + } else { + _instanceMirror = reflect(value); + ClassMirror classMirror = _instanceMirror.type; + if(classMirror.instanceMembers[symbol] != null) { + mode = _MODE_METHOD_; + } else { + mode = value is Map ? _MODE_MAP_CLOSURE_: _MODE_FIELD_CLOSURE_; + } + } + } + + ChangeRecord<_Handler> check() { + var value; + switch (mode) { + case _MODE_MARKER_: + case _MODE_NULL_: + return null; + case _MODE_FUNCTION_: + if (!dirtyArgs) return null; + value = Function.apply(fn, args); + dirtyArgs = false; + break; + case _MODE_FUNCTION_APPLY_: + if (!dirtyArgs) return null; + value = fn.apply(args); + dirtyArgs = false; + break; + case _MODE_FIELD_CLOSURE_: + var closure = _instanceMirror.getField(symbol).reflectee; + value = closure == null ? null : Function.apply(closure, args); + break; + case _MODE_MAP_CLOSURE_: + var closure = object[name]; + value = closure == null ? null : Function.apply(closure, args); + break; + case _MODE_METHOD_: + value = _instanceMirror.invoke(symbol, args).reflectee; + break; + default: + assert(false); + } + + var current = currentValue; + if (!identical(current, value)) { + if (value is String && current is String && value == current) { + // it is really the same, recover and save so next time identity is same + current = value; + } else { + previousValue = current; + currentValue = value; + handler.onChange(this); + return this; + } + } + return null; + } + + get nextChange => null; + + remove() { + assert(mode != _MODE_DELETED_); + assert((mode = _MODE_DELETED_) == _MODE_DELETED_); // Mark as deleted. + watchGrp._evalCost--; + _EvalWatchList._remove(watchGrp, this); + } + + toString() { + if (mode == _MODE_MARKER_) return 'MARKER[$currentValue]'; + return '${watchGrp.id}:${handler.expression}'; + } +} diff --git a/lib/core/scope2.dart b/lib/core/scope2.dart new file mode 100644 index 000000000..47da936da --- /dev/null +++ b/lib/core/scope2.dart @@ -0,0 +1,187 @@ +library angular.scope2; + +import 'package:angular/change_detection/watch_group.dart'; +import 'package:angular/change_detection/change_detection.dart'; +import 'package:angular/core/parser/syntax.dart'; +import 'package:angular/core/parser/utils.dart'; +import 'package:angular/angular.dart'; +import 'dart:collection'; + +class Scope2 { + final Parser _parser; + final WatchGroup _watchGroup; + final ExpressionVisitor visitor = new ExpressionVisitor(); + Object context; + + Scope2(Object this.context, this._parser, this._watchGroup); + + watch(String expression, ReactionFn reactionFn) { + AST ast = visitor.visit(_parser.call(expression)); + assert(ast != null); + return _watchGroup.watch(ast, reactionFn); + } + + digest() => _watchGroup.detectChanges(); +} + +class ExpressionVisitor implements Visitor { + static final ContextReferenceAST contextRef = new ContextReferenceAST(); + AST ast; + + visit(Expression exp) { + exp.accept(this); + assert(ast != null); + return ast; + } + + AST _mapToAst(Expression expression) => visit(expression); + List _toAst(List expressions) => expressions.map(_mapToAst).toList(); + + visitCallScope(CallScope exp) => ast = new MethodAST(contextRef, exp.name, _toAst(exp.arguments)); + visitCallMember(CallMember exp) => ast = new MethodAST(visit(exp.object), exp.name, _toAst(exp.arguments)); + + visitAccessScope(AccessScope exp) => ast = new FieldReadAST(contextRef, exp.name); + visitAccessMember(AccessMember exp) => ast = new FieldReadAST(visit(exp.object), exp.name); + visitBinary(Binary exp) => ast = new PureFunctionAST(exp.operation, + _operationToFunction(exp.operation), + [visit(exp.left), visit(exp.right)]); + visitPrefix(Prefix exp) => ast = new PureFunctionAST(exp.operation, + _operationToFunction(exp.operation), + [visit(exp.expression)]); + visitConditional(Conditional exp) => ast = new PureFunctionAST('?:', _operation_ternary, + [visit(exp.condition), visit(exp.yes), visit(exp.no)]); + visitAccessKeyed(AccessKeyed exp) => ast = new PureFunctionAST('[]', _operation_bracket, + [visit(exp.object), visit(exp.key)]); + + visitLiteralPrimitive(LiteralPrimitive exp) => ast = new ConstantAST(exp.value); + visitLiteralString(LiteralString exp) => ast = new ConstantAST(exp.value); + + visitLiteralArray(LiteralArray exp) { + List items = _toAst(exp.elements); + ast = new PureFunctionAST('[${items.join(', ')}]', new _ArrayFn(), items); + } + + visitLiteralObject(LiteralObject exp) { + List keys = exp.keys; + List values = _toAst(exp.values); + assert(keys.length == values.length); + List kv = []; + for(var i = 0; i < keys.length; i++) { + kv.add('${keys[i]}: ${values[i]}'); + } + ast = new PureFunctionAST('{${kv.join(', ')}}', new _MapFn(keys), values); + } + + visitFilter(Filter exp) { + Function filterFunction = exp.function; + List args = [new CollectionAST(visit(exp.expression))]; + args.addAll(_toAst(exp.arguments).map((ast) => new CollectionAST(ast))); + ast = new PureFunctionAST('|${exp.name}', new _FilterWrapper(exp.function, args.length), args); + } + + // TODO(misko): this is a corner case. Choosing not to implement for now. + visitCallFunction(CallFunction exp) => _notSupported("function's returing functions"); + visitAssign(Assign exp) => _notSupported('assignement'); + visitLiteral(Literal exp) => _notSupported('literal'); + visitExpression(Expression exp) => _notSupported('?'); + visitChain(Chain exp) => _notSupported(';'); + + _notSupported(String name) { + throw new StateError("Can not watch expression containing '$name'."); + } +} + +_operationToFunction(String operation) { + switch(operation) { + case '!' : return _operation_negate; + case '+' : return _operation_add; + case '-' : return _operation_subtract; + case '*' : return _operation_multiply; + case '/' : return _operation_divide; + case '~/' : return _operation_divide_int; + case '%' : return _operation_remainder; + case '==' : return _operation_equals; + case '!=' : return _operation_not_equals; + case '<' : return _operation_less_then; + case '>' : return _operation_greater_then; + case '<=' : return _operation_less_or_equals_then; + case '>=' : return _operation_greater_or_equals_then; + case '^' : return _operation_power; + case '&' : return _operation_bitwise_and; + case '&&' : return _operation_logical_and; + case '||' : return _operation_logical_or; + default: throw new StateError(operation); + } +} + +_operation_negate(value) => !toBool(value); +_operation_add(left, right) => autoConvertAdd(left, right); +_operation_subtract(left, right) => left - right; +_operation_multiply(left, right) => left * right; +_operation_divide(left, right) => left / right; +_operation_divide_int(left, right) => left ~/ right; +_operation_remainder(left, right) => left % right; +_operation_equals(left, right) => left == right; +_operation_not_equals(left, right) => left != right; +_operation_less_then(left, right) => left < right; +_operation_greater_then(left, right) => left > right; +_operation_less_or_equals_then(left, right) => left <= right; +_operation_greater_or_equals_then(left, right) => left >= right; +_operation_power(left, right) => left ^ right; +_operation_bitwise_and(left, right) => left & right; +// TODO(misko): these should short circuit the evaluation. +_operation_logical_and(left, right) => toBool(left) && toBool(left); +_operation_logical_or(left, right) => toBool(left) || toBool(right); + +_operation_ternary(condition, yes, no) => toBool(condition) ? yes : no; +_operation_bracket(obj, key) => obj == null ? null : obj[key]; + +class _ArrayFn extends FunctionApply { + apply(List args) => args; +} + +class _MapFn extends FunctionApply { + final Map map = {}; + final List keys; + + _MapFn(this.keys); + + apply(List values) { + assert(values.length == keys.length); + for(var i = 0; i < keys.length; i++) { + map[keys[i]] = values[i]; + } + return map; + } +} + +class _FilterWrapper extends FunctionApply { + final Function filterFn; + final List args; + final List argsWatches; + _FilterWrapper(this.filterFn, length): + args = new List(length), + argsWatches = new List(length); + + apply(List values) { + for(var i=0; i < values.length; i++) { + var value = values[i]; + var lastValue = args[i]; + if (!identical(value, lastValue)) { + if (value is CollectionChangeRecord) { + args[i] = (value as CollectionChangeRecord).iterable; + } else { + args[i] = value; + } + } + } + var value = Function.apply(filterFn, args); + if (value is Iterable) { + // Since filters are pure we can guarantee that this well never change. + // By wrapping in UnmodifiableListView we can hint to the dirty checker and + // short circuit the iterator. + value = new UnmodifiableListView(value); + } + return value; + } +} diff --git a/perf.sh b/perf.sh new file mode 100755 index 000000000..2a4ce3e13 --- /dev/null +++ b/perf.sh @@ -0,0 +1,6 @@ +if dart2js perf/mirror_perf.dart -o perf/mirror_perf.dart.js > /dev/null ; then + echo DART: + dart perf/mirror_perf.dart + echo JavaScript: + node perf/mirror_perf.dart.js +fi \ No newline at end of file diff --git a/perf/loop_perf.dart b/perf/loop_perf.dart new file mode 100644 index 000000000..2ad7862bd --- /dev/null +++ b/perf/loop_perf.dart @@ -0,0 +1,69 @@ +import 'package:benchmark_harness/benchmark_harness.dart'; + +class IterationBenchmark extends BenchmarkBase { + List list = new List.generate(1000, (i) => i); + List map; + var r = 0; + IterationBenchmark(name) : super(name) { + map = new Map.fromIterable(list, key: (i) => i, value: (i) => i); + } +} + +class ForEach extends IterationBenchmark { + ForEach() : super('forEach'); + run() { + var count = 0; + list.forEach((int i) => count = count + i); + return count; + } +} + +class ForEachMap extends IterationBenchmark { + ForEachMap() : super('forEachMap'); + run() { + var count = 0; + map.forEach((int k, int v) => count = count + k + v); + return count; + } +} + +class ForIn extends IterationBenchmark { + ForIn() : super('for in'); + run() { + var count = 0; + for(int item in list) { + count = count + item; + } + return count; + } +} + +class ForInMap extends IterationBenchmark { + ForInMap() : super('for in Map'); + run() { + var count = 0; + for(int key in map.keys) { + count = count + key + map[key]; + } + return count; + } +} + +class ForLoop extends IterationBenchmark { + ForLoop() : super('for loop'); + run() { + int count = 0; + for(int i = 0; i < list.length; i++) { + count += list[i]; + } + return count; + } +} + +void main() { + new ForEach().report(); + new ForIn().report(); + new ForLoop().report(); + new ForEachMap().report(); + new ForInMap().report(); +} diff --git a/perf/mirror_perf.dart b/perf/mirror_perf.dart index 70b22a808..23d42b19a 100644 --- a/perf/mirror_perf.dart +++ b/perf/mirror_perf.dart @@ -2,6 +2,7 @@ library angular.perf.mirror; import '_perf.dart'; import 'dart:mirrors'; +import 'package:angular/change_detection/dirty_checking_change_detector.dart'; main() { var c = new Obj(1); @@ -9,9 +10,11 @@ main() { Symbol symbol = new Symbol('a'); Watch head = new Watch(); Watch current = head; + var detector = new DirtyCheckingChangeDetector(); for(var i=1; i < 10000; i++) { Watch next = new Watch(); current = (current.next = new Watch()); + detector.watch(c, 'a', ''); } var dirtyCheck = () { @@ -34,9 +37,10 @@ main() { } }; - time('fieldRead', () => im.getField(symbol).reflectee ); - time('Object.observe', dirtyCheck); - time('Object.observe fn()', dirtyCheckFn); + xtime('fieldRead', () => im.getField(symbol).reflectee ); + xtime('Object.observe', dirtyCheck); + xtime('Object.observe fn()', dirtyCheckFn); + time('ChangeDetection', detector.collectChanges); } class Watch { diff --git a/perf/pubspec.lock b/perf/pubspec.lock new file mode 100644 index 000000000..66a8c56b8 --- /dev/null +++ b/perf/pubspec.lock @@ -0,0 +1,81 @@ +# Generated by pub +# See http://pub.dartlang.org/doc/glossary.html#lockfile +packages: + analyzer: + description: analyzer + source: hosted + version: "0.10.5" + angular: + description: + path: "/Users/misko/work/angular.dart/perf/.." + relative: true + source: path + version: "0.9.3" + args: + description: args + source: hosted + version: "0.9.0" + benchmark_harness: + description: benchmark_harness + source: hosted + version: "1.0.2" + browser: + description: browser + source: hosted + version: "0.9.1" + collection: + description: collection + source: hosted + version: "0.9.0" + di: + description: di + source: hosted + version: "0.0.29" + html5lib: + description: html5lib + source: hosted + version: "0.9.1" + intl: + description: intl + source: hosted + version: "0.9.1" + js: + description: js + source: hosted + version: "0.2.1" + logging: + description: logging + source: hosted + version: "0.9.1+1" + path: + description: path + source: hosted + version: "1.0.0" + perf_api: + description: perf_api + source: hosted + version: "0.0.8" + route_hierarchical: + description: route_hierarchical + source: hosted + version: "0.4.10" + source_maps: + description: source_maps + source: hosted + version: "0.9.0" + stack_trace: + description: stack_trace + source: hosted + version: "0.9.1" + unittest: + description: unittest + source: hosted + version: "0.9.2+1" + unmodifiable_collection: + description: unmodifiable_collection + source: hosted + version: "0.9.2" + utf: + description: utf + source: hosted + version: "0.9.0" diff --git a/perf/pubspec.yaml b/perf/pubspec.yaml new file mode 100644 index 000000000..557d04ce2 --- /dev/null +++ b/perf/pubspec.yaml @@ -0,0 +1,21 @@ +name: angular-perf +version: 0.9.3 +authors: +- Misko Hevery +description: Angular performance tests for AngularDart. +homepage: https://github.com/angular/angular.dart +environment: + sdk: '>=1.0.0' +dependencies: + angular: + path: .. + analyzer: ">=0.10.0 <0.11.0" + browser: ">=0.8.7 <0.10.0" + di: ">=0.0.24 <0.1.0" + html5lib: ">=0.8.7 <0.10.0" + intl: ">=0.8.7 <0.10.0" + js: ">=0.2.0 <0.3.0" + perf_api: ">=0.0.8 <0.1.0" + route_hierarchical: ">=0.4.7 <0.5.0" + benchmark_harness: ">=1.0.0" + unittest: ">=0.8.7 <0.10.0" diff --git a/perf/scope_perf.dart b/perf/scope_perf.dart index 4d592cb8c..c0b1b61b0 100644 --- a/perf/scope_perf.dart +++ b/perf/scope_perf.dart @@ -6,62 +6,249 @@ import 'package:angular/core/parser/parser.dart'; import 'package:di/di.dart'; import 'package:di/dynamic_injector.dart'; -main() { - var scope = new DynamicInjector( +createInjector() { + return new DynamicInjector( modules: [new Module() ..type(Parser, implementedBy: DynamicParser) ..type(ParserBackend, implementedBy: DynamicParserBackend)], - allowImplicitInjection:true).get(Scope); - var scope2, scope3, scope4, scope5; - var fill = (scope) { - for(var i = 0; i < 10000; i++) { - scope['key_$i'] = i; - } - return scope; + allowImplicitInjection:true); +} + +var reactionFn = (_, __, ___) => null; +main() { + fieldRead(); + mapRead(); + methodInvoke0(); + methodInvoke1(); + function2(); +} + +fieldRead() { + var injector = createInjector(); + var obj = new Obj(); + var scope = injector.get(Scope); + var parser = injector.get(Parser); + var parse = (exp) { + var fn = parser(exp).eval; + var o = obj; + return (s) => fn(o); }; + scope.$watch(parse('a'), reactionFn); + scope.$watch(parse('b'), reactionFn); + scope.$watch(parse('c'), reactionFn); + scope.$watch(parse('d'), reactionFn); + scope.$watch(parse('e'), reactionFn); + scope.$watch(parse('f'), reactionFn); + scope.$watch(parse('g'), reactionFn); + scope.$watch(parse('h'), reactionFn); + scope.$watch(parse('i'), reactionFn); + scope.$watch(parse('j'), reactionFn); + scope.$watch(parse('k'), reactionFn); + scope.$watch(parse('l'), reactionFn); + scope.$watch(parse('m'), reactionFn); + scope.$watch(parse('n'), reactionFn); + scope.$watch(parse('o'), reactionFn); + scope.$watch(parse('p'), reactionFn); + scope.$watch(parse('q'), reactionFn); + scope.$watch(parse('r'), reactionFn); + scope.$watch(parse('s'), reactionFn); + scope.$watch(parse('t'), reactionFn); + scope.$digest(); + time('fieldRead', () => scope.$digest()); +} - scope = fill(scope); - scope2 = fill(scope.$new()); - scope3 = fill(scope2.$new()); - scope4 = fill(scope3.$new()); - scope5 = fill(scope4.$new()); +mapRead() { + var map = { + 'a': 0, 'b': 1, 'c': 2, 'd': 3, 'e': 4, + 'f': 0, 'g': 1, 'h': 2, 'i': 3, 'j': 4, + 'k': 0, 'l': 1, 'm': 2, 'n': 3, 'o': 4, + 'p': 0, 'q': 1, 'r': 2, 's': 3, 't': 4}; + var injector = createInjector(); + var obj = new Obj(); + var scope = injector.get(Scope); + map.forEach((k, v) => scope[k] = v); - time('noop', () {}); + scope.$watch('a', reactionFn); + scope.$watch('b', reactionFn); + scope.$watch('c', reactionFn); + scope.$watch('d', reactionFn); + scope.$watch('e', reactionFn); + scope.$watch('f', reactionFn); + scope.$watch('g', reactionFn); + scope.$watch('h', reactionFn); + scope.$watch('i', reactionFn); + scope.$watch('j', reactionFn); + scope.$watch('k', reactionFn); + scope.$watch('l', reactionFn); + scope.$watch('m', reactionFn); + scope.$watch('n', reactionFn); + scope.$watch('o', reactionFn); + scope.$watch('p', reactionFn); + scope.$watch('q', reactionFn); + scope.$watch('r', reactionFn); + scope.$watch('s', reactionFn); + scope.$watch('t', reactionFn); + scope.$digest(); + time('mapRead', () => scope.$digest()); +} - time('empty scope \$digest()', () { - scope.$digest(); - }); +methodInvoke0() { + var context = new Obj(); + var injector = createInjector(); + var obj = new Obj(); + var scope = injector.get(Scope); + scope.a = context; + scope.$watch('a.methodA()', reactionFn); + scope.$watch('a.methodB()', reactionFn); + scope.$watch('a.methodC()', reactionFn); + scope.$watch('a.methodD()', reactionFn); + scope.$watch('a.methodE()', reactionFn); + scope.$watch('a.methodF()', reactionFn); + scope.$watch('a.methodG()', reactionFn); + scope.$watch('a.methodH()', reactionFn); + scope.$watch('a.methodI()', reactionFn); + scope.$watch('a.methodJ()', reactionFn); + scope.$watch('a.methodK()', reactionFn); + scope.$watch('a.methodL()', reactionFn); + scope.$watch('a.methodM()', reactionFn); + scope.$watch('a.methodN()', reactionFn); + scope.$watch('a.methodO()', reactionFn); + scope.$watch('a.methodP()', reactionFn); + scope.$watch('a.methodQ()', reactionFn); + scope.$watch('a.methodR()', reactionFn); + scope.$watch('a.methodS()', reactionFn); + scope.$watch('a.methodT()', reactionFn); + scope.$digest(); + time('obj.method?()', () => scope.$digest()); +} - scope.a = new A(); +methodInvoke1() { + var context = new Obj(); + var injector = createInjector(); + var obj = new Obj(); + var scope = injector.get(Scope); + scope.a = context; + scope.$watch('a.methodA(a)', reactionFn); + scope.$watch('a.methodB(a)', reactionFn); + scope.$watch('a.methodC(a)', reactionFn); + scope.$watch('a.methodD(a)', reactionFn); + scope.$watch('a.methodE(a)', reactionFn); + scope.$watch('a.methodF(a)', reactionFn); + scope.$watch('a.methodG(a)', reactionFn); + scope.$watch('a.methodH(a)', reactionFn); + scope.$watch('a.methodI(a)', reactionFn); + scope.$watch('a.methodJ(a)', reactionFn); + scope.$watch('a.methodK(a)', reactionFn); + scope.$watch('a.methodL(a)', reactionFn); + scope.$watch('a.methodM(a)', reactionFn); + scope.$watch('a.methodN(a)', reactionFn); + scope.$watch('a.methodO(a)', reactionFn); + scope.$watch('a.methodP(a)', reactionFn); + scope.$watch('a.methodQ(a)', reactionFn); + scope.$watch('a.methodR(a)', reactionFn); + scope.$watch('a.methodS(a)', reactionFn); + scope.$watch('a.methodT(a)', reactionFn); + scope.$digest(); + time('obj.method?(obj)', () => scope.$digest()); +} - List watchFns = new List.generate(4000, (i) => () => i); - time('adding/removing 4000 watchers', () { - List watchers = watchFns.map(scope.$watch).toList(); - watchers.forEach((e) => e()); +function2() { + var injector = createInjector(); + var obj = new Obj(); + obj.a = 1; + var scope = injector.get(Scope); + var parser = injector.get(Parser); + var aFn = parser('a').eval; + var add = () { + var fn = aFn; + var o = obj; + return (s) => fn(o) + fn(o); + }; + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$watch(add(), reactionFn); + scope.$digest(); + time('add?(a, a)', () => scope.$digest()); +} + +AST add(id, lhs, rhs) { + return new PureFunctionAST('add$id', (a, b) => a + b, [lhs, rhs]); +} + +AST method(lhs, methodName, [args]) { + if (args == null) args = []; + return new MethodAST(parse(lhs), methodName, args); +} + +AST parse(String expression) { + var currentAST = new ContextReferenceAST(); + expression.split('.').forEach((name) { + currentAST = new FieldReadAST(currentAST, name); }); + return currentAST; +} - List watchers = watchFns.map(scope.$watch).toList(); - time('4000 dummy watchers on scope', () => scope.$digest()); - watchers.forEach((e) => e()); - for(var i = 0; i < 1000; i++ ) { - scope.$watch('a.number', () => null); - scope.$watch('a.str', () => null); - scope.$watch('a.obj', () => null); - } +class Obj { + var a = 1; + var b = 2; + var c = 3; + var d = 4; + var e = 5; - time('3000 watchers on scope', () => scope.$digest()); + var f = 6; + var g = 7; + var h = 8; + var i = 9; + var j = 10; - //TODO(misko): build matrics of these - time('scope[] 1 deep', () => scope['nenexistant']); - time('scope[] 2 deep', () => scope2['nenexistant']); - time('scope[] 3 deep', () => scope3['nenexistant']); - time('scope[] 4 deep', () => scope4['nenexistant']); - time('scope[] 5 deep', () => scope5['nenexistant']); -} + var k = 11; + var l = 12; + var m = 13; + var n = 14; + var o = 15; + + var p = 16; + var q = 17; + var r = 18; + var s = 19; + var t = 20; -class A { - var number = 1; - var str = 'abc'; - var obj = {}; + methodA([arg0]) => a; + methodB([arg0]) => b; + methodC([arg0]) => c; + methodD([arg0]) => d; + methodE([arg0]) => e; + methodF([arg0]) => f; + methodG([arg0]) => g; + methodH([arg0]) => h; + methodI([arg0]) => i; + methodJ([arg0]) => j; + methodK([arg0]) => k; + methodL([arg0]) => l; + methodM([arg0]) => m; + methodN([arg0]) => n; + methodO([arg0]) => o; + methodP([arg0]) => p; + methodQ([arg0]) => q; + methodR([arg0]) => r; + methodS([arg0]) => s; + methodT([arg0]) => t; } diff --git a/perf/watch_group_perf.dart b/perf/watch_group_perf.dart new file mode 100644 index 000000000..ef211bc22 --- /dev/null +++ b/perf/watch_group_perf.dart @@ -0,0 +1,284 @@ +library angular.perf.watch_group; + +import '_perf.dart'; +import 'dart:mirrors'; +import 'package:angular/change_detection/dirty_checking_change_detector.dart'; +import 'package:angular/change_detection/watch_group.dart'; +import 'package:benchmark_harness/benchmark_harness.dart'; + +var reactionFn = (_, __, ___) => null; +var getterCache = new GetterCache({}); +main() { + fieldRead(); + fieldReadGetter(); + mapRead(); + methodInvoke0(); + methodInvoke1(); + function2(); + new CollectionCheck().report(); +} + +class CollectionCheck extends BenchmarkBase { + List list = new List.generate(1000, (i) => i); + var detector = new DirtyCheckingChangeDetector<_Handler>(getterCache); + + CollectionCheck(): super('change-detect List[1000]') { + detector + ..watch(list, null, 'handler') + ..collectChanges(); // intialize + } + + run() { + detector.collectChanges(); + } +} + +fieldRead() { + var watchGrp = new RootWatchGroup( + new DirtyCheckingChangeDetector<_Handler>(getterCache), new Obj()) + ..watch(parse('a'), reactionFn) + ..watch(parse('b'), reactionFn) + ..watch(parse('c'), reactionFn) + ..watch(parse('d'), reactionFn) + ..watch(parse('e'), reactionFn) + ..watch(parse('f'), reactionFn) + ..watch(parse('g'), reactionFn) + ..watch(parse('h'), reactionFn) + ..watch(parse('i'), reactionFn) + ..watch(parse('j'), reactionFn) + ..watch(parse('k'), reactionFn) + ..watch(parse('l'), reactionFn) + ..watch(parse('m'), reactionFn) + ..watch(parse('n'), reactionFn) + ..watch(parse('o'), reactionFn) + ..watch(parse('p'), reactionFn) + ..watch(parse('q'), reactionFn) + ..watch(parse('r'), reactionFn) + ..watch(parse('s'), reactionFn) + ..watch(parse('t'), reactionFn); + + print('Watch: ${watchGrp.fieldCost}; eval: ${watchGrp.evalCost}'); + + time('fieldRead', () => watchGrp.detectChanges()); +} + +fieldReadGetter() { + var getterCache = new GetterCache({ + "a": (o) => o.a, "b": (o) => o.b, "c": (o) => o.c, "d": (o) => o.d, "e": (o) => o.e, + "f": (o) => o.f, "g": (o) => o.g, "h": (o) => o.h, "i": (o) => o.i, "j": (o) => o.j, + "k": (o) => o.k, "l": (o) => o.l, "m": (o) => o.m, "n": (o) => o.n, "o": (o) => o.o, + "p": (o) => o.p, "q": (o) => o.q, "r": (o) => o.r, "n": (o) => o.s, "t": (o) => o.t, + }); + var watchGrp= new RootWatchGroup( + new DirtyCheckingChangeDetector<_Handler>(getterCache), new Obj()) + ..watch(parse('a'), reactionFn) + ..watch(parse('b'), reactionFn) + ..watch(parse('c'), reactionFn) + ..watch(parse('d'), reactionFn) + ..watch(parse('e'), reactionFn) + ..watch(parse('f'), reactionFn) + ..watch(parse('g'), reactionFn) + ..watch(parse('h'), reactionFn) + ..watch(parse('i'), reactionFn) + ..watch(parse('j'), reactionFn) + ..watch(parse('k'), reactionFn) + ..watch(parse('l'), reactionFn) + ..watch(parse('m'), reactionFn) + ..watch(parse('n'), reactionFn) + ..watch(parse('o'), reactionFn) + ..watch(parse('p'), reactionFn) + ..watch(parse('q'), reactionFn) + ..watch(parse('r'), reactionFn) + ..watch(parse('s'), reactionFn) + ..watch(parse('t'), reactionFn); + + print('Watch: ${watchGrp.fieldCost}; eval: ${watchGrp.evalCost}'); + + time('fieldReadGetter', () => watchGrp.detectChanges()); +} + +mapRead() { + var map = { + 'a': 0, 'b': 1, 'c': 2, 'd': 3, 'e': 4, + 'f': 0, 'g': 1, 'h': 2, 'i': 3, 'j': 4, + 'k': 0, 'l': 1, 'm': 2, 'n': 3, 'o': 4, + 'p': 0, 'q': 1, 'r': 2, 's': 3, 't': 4}; + var watchGrp = new RootWatchGroup( + new DirtyCheckingChangeDetector<_Handler>(getterCache), map) + ..watch(parse('a'), reactionFn) + ..watch(parse('b'), reactionFn) + ..watch(parse('c'), reactionFn) + ..watch(parse('d'), reactionFn) + ..watch(parse('e'), reactionFn) + ..watch(parse('f'), reactionFn) + ..watch(parse('g'), reactionFn) + ..watch(parse('h'), reactionFn) + ..watch(parse('i'), reactionFn) + ..watch(parse('j'), reactionFn) + ..watch(parse('k'), reactionFn) + ..watch(parse('l'), reactionFn) + ..watch(parse('m'), reactionFn) + ..watch(parse('n'), reactionFn) + ..watch(parse('o'), reactionFn) + ..watch(parse('p'), reactionFn) + ..watch(parse('q'), reactionFn) + ..watch(parse('r'), reactionFn) + ..watch(parse('s'), reactionFn) + ..watch(parse('t'), reactionFn); + + print('Watch: ${watchGrp.fieldCost}; eval: ${watchGrp.evalCost}'); + time('mapRead', () => watchGrp.detectChanges()); +} + +methodInvoke0() { + var context = new Obj(); + context.a = new Obj(); + var watchGrp = new RootWatchGroup( + new DirtyCheckingChangeDetector<_Handler>(getterCache), context) + ..watch(method('a', 'methodA'), reactionFn) + ..watch(method('a', 'methodB'), reactionFn) + ..watch(method('a', 'methodC'), reactionFn) + ..watch(method('a', 'methodD'), reactionFn) + ..watch(method('a', 'methodE'), reactionFn) + ..watch(method('a', 'methodF'), reactionFn) + ..watch(method('a', 'methodG'), reactionFn) + ..watch(method('a', 'methodH'), reactionFn) + ..watch(method('a', 'methodI'), reactionFn) + ..watch(method('a', 'methodJ'), reactionFn) + ..watch(method('a', 'methodK'), reactionFn) + ..watch(method('a', 'methodL'), reactionFn) + ..watch(method('a', 'methodM'), reactionFn) + ..watch(method('a', 'methodN'), reactionFn) + ..watch(method('a', 'methodO'), reactionFn) + ..watch(method('a', 'methodP'), reactionFn) + ..watch(method('a', 'methodQ'), reactionFn) + ..watch(method('a', 'methodR'), reactionFn) + ..watch(method('a', 'methodS'), reactionFn) + ..watch(method('a', 'methodT'), reactionFn); + + print('Watch: ${watchGrp.fieldCost}; eval: ${watchGrp.evalCost}'); + time('obj.method?()', () => watchGrp.detectChanges()); +} + +methodInvoke1() { + var context = new Obj(); + context.a = new Obj(); + var watchGrp = new RootWatchGroup( + new DirtyCheckingChangeDetector<_Handler>(getterCache), context) + ..watch(method('a', 'methodA', [parse('a')]), reactionFn) + ..watch(method('a', 'methodB', [parse('a')]), reactionFn) + ..watch(method('a', 'methodC', [parse('a')]), reactionFn) + ..watch(method('a', 'methodD', [parse('a')]), reactionFn) + ..watch(method('a', 'methodE', [parse('a')]), reactionFn) + ..watch(method('a', 'methodF', [parse('a')]), reactionFn) + ..watch(method('a', 'methodG', [parse('a')]), reactionFn) + ..watch(method('a', 'methodH', [parse('a')]), reactionFn) + ..watch(method('a', 'methodI', [parse('a')]), reactionFn) + ..watch(method('a', 'methodJ', [parse('a')]), reactionFn) + ..watch(method('a', 'methodK', [parse('a')]), reactionFn) + ..watch(method('a', 'methodL', [parse('a')]), reactionFn) + ..watch(method('a', 'methodM', [parse('a')]), reactionFn) + ..watch(method('a', 'methodN', [parse('a')]), reactionFn) + ..watch(method('a', 'methodO', [parse('a')]), reactionFn) + ..watch(method('a', 'methodP', [parse('a')]), reactionFn) + ..watch(method('a', 'methodQ', [parse('a')]), reactionFn) + ..watch(method('a', 'methodR', [parse('a')]), reactionFn) + ..watch(method('a', 'methodS', [parse('a')]), reactionFn) + ..watch(method('a', 'methodT', [parse('a')]), reactionFn); + + print('Watch: ${watchGrp.fieldCost}; eval: ${watchGrp.evalCost}'); + time('obj.method?(obj)', () => watchGrp.detectChanges()); +} + +function2() { + var context = new Obj(); + var watchGrp = new RootWatchGroup( + new DirtyCheckingChangeDetector<_Handler>(getterCache), context) + ..watch(add(0, parse('a'), parse('a')), reactionFn) + ..watch(add(1, parse('a'), parse('a')), reactionFn) + ..watch(add(2, parse('a'), parse('a')), reactionFn) + ..watch(add(3, parse('a'), parse('a')), reactionFn) + ..watch(add(4, parse('a'), parse('a')), reactionFn) + ..watch(add(5, parse('a'), parse('a')), reactionFn) + ..watch(add(6, parse('a'), parse('a')), reactionFn) + ..watch(add(7, parse('a'), parse('a')), reactionFn) + ..watch(add(8, parse('a'), parse('a')), reactionFn) + ..watch(add(9, parse('a'), parse('a')), reactionFn) + ..watch(add(10, parse('a'), parse('a')), reactionFn) + ..watch(add(11, parse('a'), parse('a')), reactionFn) + ..watch(add(12, parse('a'), parse('a')), reactionFn) + ..watch(add(13, parse('a'), parse('a')), reactionFn) + ..watch(add(14, parse('a'), parse('a')), reactionFn) + ..watch(add(15, parse('a'), parse('a')), reactionFn) + ..watch(add(16, parse('a'), parse('a')), reactionFn) + ..watch(add(17, parse('a'), parse('a')), reactionFn) + ..watch(add(18, parse('a'), parse('a')), reactionFn) + ..watch(add(19, parse('a'), parse('a')), reactionFn); + + print('Watch: ${watchGrp.fieldCost}; eval: ${watchGrp.evalCost}'); + time('add?(a, a)', () => watchGrp.detectChanges()); +} + +AST add(id, lhs, rhs) => + new PureFunctionAST('add$id', (a, b) => a + b, [lhs, rhs]); + +AST method(lhs, methodName, [args]) { + if (args == null) args = []; + return new MethodAST(parse(lhs), methodName, args); +} + +AST parse(String expression) { + var currentAST = new ContextReferenceAST(); + expression.split('.').forEach((name) { + currentAST = new FieldReadAST(currentAST, name); + }); + return currentAST; +} + + +class Obj { + var a = 1; + var b = 2; + var c = 3; + var d = 4; + var e = 5; + + var f = 6; + var g = 7; + var h = 8; + var i = 9; + var j = 10; + + var k = 11; + var l = 12; + var m = 13; + var n = 14; + var o = 15; + + var p = 16; + var q = 17; + var r = 18; + var s = 19; + var t = 20; + + methodA([arg0]) => a; + methodB([arg0]) => b; + methodC([arg0]) => c; + methodD([arg0]) => d; + methodE([arg0]) => e; + methodF([arg0]) => f; + methodG([arg0]) => g; + methodH([arg0]) => h; + methodI([arg0]) => i; + methodJ([arg0]) => j; + methodK([arg0]) => k; + methodL([arg0]) => l; + methodM([arg0]) => m; + methodN([arg0]) => n; + methodO([arg0]) => o; + methodP([arg0]) => p; + methodQ([arg0]) => q; + methodR([arg0]) => r; + methodS([arg0]) => s; + methodT([arg0]) => t; +} diff --git a/scripts/karma/snapshot.sh b/scripts/karma/snapshot.sh index c2dcab877..61f00d3c3 100755 --- a/scripts/karma/snapshot.sh +++ b/scripts/karma/snapshot.sh @@ -17,4 +17,4 @@ echo ================ curl $URL/debug.html --silent | sed -e 's/\/base/\.\/base/' > debug.html -curl $URL/base/__adapter_dart_unittest.dart --silent | sed -e 's/\/base/\./' > base/__adapter_dart_unittest.dart +curl $URL/__adapter_dart_unittest.dart --silent | sed -e 's/\/base/\./' > __adapter_dart_unittest.dart diff --git a/test/_specs.dart b/test/_specs.dart index b75571ac6..4122c9574 100644 --- a/test/_specs.dart +++ b/test/_specs.dart @@ -118,11 +118,11 @@ class NotExpect { toHaveClass(cls) => unit.expect(actual.classes.contains(cls), false, reason: ' Expected ${actual} to not have css class ${cls}'); toBe(expected) => unit.expect(actual, - unit.predicate((actual) => !identical(expected, actual), '$expected')); + unit.predicate((actual) => !identical(expected, actual), 'not $expected')); toEqual(expected) => unit.expect(actual, - unit.predicate((actual) => expected != actual, '$expected')); + unit.predicate((actual) => expected != actual, 'not $expected')); toContain(expected) => unit.expect(actual, - unit.predicate((actual) => !actual.contains(expected), '$expected')); + unit.predicate((actual) => !actual.contains(expected), 'not $expected')); } class ExceptionContains extends unit.Matcher { diff --git a/test/change_detection/dirty_checking_change_detector_spec.dart b/test/change_detection/dirty_checking_change_detector_spec.dart new file mode 100644 index 000000000..8e3b954ee --- /dev/null +++ b/test/change_detection/dirty_checking_change_detector_spec.dart @@ -0,0 +1,566 @@ +library dirty_chekcing_change_detector_spec; + +import '../_specs.dart'; +import 'package:angular/change_detection/change_detection.dart'; +import 'package:angular/change_detection/dirty_checking_change_detector.dart'; +import 'dart:collection'; + +main() => describe('DirtyCheckingChangeDetector', () { + DirtyCheckingChangeDetector detector; + + beforeEach(() { + GetterCache getterCache = new GetterCache({ + "first": (o) => o.first, + "age": (o) => o.age + }); + detector = new DirtyCheckingChangeDetector(getterCache); + }); + + describe('object field', () { + it('should detect nothing', () { + var changes = detector.collectChanges(); + expect(changes).toEqual(null); + }); + + it('should detect field changes', () { + var user = new _User('', ''); + var change; + + detector.watch(user, 'first', null); + detector.watch(user, 'last', null); + detector.collectChanges(); // throw away first set + + change = detector.collectChanges(); + expect(change).toEqual(null); + user.first = 'misko'; + user.last = 'hevery'; + + change = detector.collectChanges(); + expect(change.currentValue).toEqual('misko'); + expect(change.previousValue).toEqual(''); + expect(change.nextChange.currentValue).toEqual('hevery'); + expect(change.nextChange.previousValue).toEqual(''); + expect(change.nextChange.nextChange).toEqual(null); + + // force different instance + user.first = 'mis'; + user.first += 'ko'; + + change = detector.collectChanges(); + expect(change).toEqual(null); + + user.last = 'Hevery'; + change = detector.collectChanges(); + expect(change.currentValue).toEqual('Hevery'); + expect(change.previousValue).toEqual('hevery'); + expect(change.nextChange).toEqual(null); + }); + + it('should ignore NaN != NaN', () { + var user = new _User(); + user.age = double.NAN; + detector.watch(user, 'age', null); + detector.collectChanges(); // throw away first set + + var changes = detector.collectChanges(); + expect(changes).toEqual(null); + + user.age = 123; + changes = detector.collectChanges(); + expect(changes.currentValue).toEqual(123); + expect(changes.previousValue.isNaN).toEqual(true); + expect(changes.nextChange).toEqual(null); + }); + + it('should treat map field dereference as []', () { + var obj = {'name':'misko'}; + detector.watch(obj, 'name', null); + detector.collectChanges(); // throw away first set + + obj['name'] = 'Misko'; + var changes = detector.collectChanges(); + expect(changes.currentValue).toEqual('Misko'); + expect(changes.previousValue).toEqual('misko'); + }); + }); + + describe('insertions / removals', () { + it('should insert at the end of list', () { + var obj = {}; + var a = detector.watch(obj, 'a', 'a'); + var b = detector.watch(obj, 'b', 'b'); + + obj['a'] = obj['b'] = 1; + var changes = detector.collectChanges(); + expect(changes.handler).toEqual('a'); + expect(changes.nextChange.handler).toEqual('b'); + expect(changes.nextChange.nextChange).toEqual(null); + + obj['a'] = obj['b'] = 2; + a.remove(); + changes = detector.collectChanges(); + expect(changes.handler).toEqual('b'); + expect(changes.nextChange).toEqual(null); + + obj['a'] = obj['b'] = 3; + b.remove(); + changes = detector.collectChanges(); + expect(changes).toEqual(null); + }); + + it('should remove all watches in group and group\'s children', () { + var obj = {}; + detector.watch(obj, 'a', '0a'); + var child1a = detector.newGroup(); + var child1b = detector.newGroup(); + var child2 = child1a.newGroup(); + child1a.watch(obj,'a', '1a'); + child1b.watch(obj,'a', '1b'); + detector.watch(obj, 'a', '0A'); + child1a.watch(obj,'a', '1A'); + child2.watch(obj,'a', '2A'); + + obj['a'] = 1; + expect(detector.collectChanges(), toEqualChanges(['0a', '0A', '1a', '1A', '2A', '1b'])); + + obj['a'] = 2; + child1a.remove(); // should also remove child2 + expect(detector.collectChanges(), toEqualChanges(['0a', '0A', '1b'])); + }); + + it('should add watches within its own group', () { + var obj = {}; + var ra = detector.watch(obj, 'a', 'a'); + var child = detector.newGroup(); + var cb = child.watch(obj,'b', 'b'); + + obj['a'] = obj['b'] = 1; + expect(detector.collectChanges(), toEqualChanges(['a', 'b'])); + + obj['a'] = obj['b'] = 2; + ra.remove(); + expect(detector.collectChanges(), toEqualChanges(['b'])); + + obj['a'] = obj['b'] = 3; + cb.remove(); + expect(detector.collectChanges(), toEqualChanges([])); + + // TODO: add them back in wrong order, assert events in right order + cb = child.watch(obj,'b', 'b'); + ra = detector.watch(obj, 'a', 'a'); + obj['a'] = obj['b'] = 4; + expect(detector.collectChanges(), toEqualChanges(['a', 'b'])); + }); + }); + + describe('list watching', () { + it('should detect changes in list', () { + var list = []; + var record = detector.watch(list, null, 'handler'); + expect(detector.collectChanges()).toEqual(null); + + list.add('a'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a[null -> 0]'], + additions: ['a[null -> 0]'], + moves: [], + removals: [])); + + list.add('b'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'b[null -> 1]'], + additions: ['b[null -> 1]'], + moves: [], + removals: [])); + + list.add('c'); + list.add('d'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'b', 'c[null -> 2]', 'd[null -> 3]'], + additions: ['c[null -> 2]', 'd[null -> 3]'], + moves: [], + removals: [])); + + list.remove('c'); + expect(list).toEqual(['a', 'b', 'd']); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'b', 'd[3 -> 2]'], + additions: [], + moves: ['d[3 -> 2]'], + removals: ['c[2 -> null]'])); + + list.clear(); + list.addAll(['d', 'c', 'b', 'a']); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['d[2 -> 0]', 'c[null -> 1]', 'b[1 -> 2]', 'a[0 -> 3]'], + additions: ['c[null -> 1]'], + moves: ['d[2 -> 0]', 'b[1 -> 2]', 'a[0 -> 3]'], + removals: [])); + }); + + it('should detect changes in list', () { + var list = []; + var record = detector.watch(list.map((i) => i), null, 'handler'); + expect(detector.collectChanges()).toEqual(null); + + list.add('a'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a[null -> 0]'], + additions: ['a[null -> 0]'], + moves: [], + removals: [])); + + list.add('b'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'b[null -> 1]'], + additions: ['b[null -> 1]'], + moves: [], + removals: [])); + + list.add('c'); + list.add('d'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'b', 'c[null -> 2]', 'd[null -> 3]'], + additions: ['c[null -> 2]', 'd[null -> 3]'], + moves: [], + removals: [])); + + list.remove('c'); + expect(list).toEqual(['a', 'b', 'd']); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'b', 'd[3 -> 2]'], + additions: [], + moves: ['d[3 -> 2]'], + removals: ['c[2 -> null]'])); + + list.clear(); + list.addAll(['d', 'c', 'b', 'a']); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['d[2 -> 0]', 'c[null -> 1]', 'b[1 -> 2]', 'a[0 -> 3]'], + additions: ['c[null -> 1]'], + moves: ['d[2 -> 0]', 'b[1 -> 2]', 'a[0 -> 3]'], + removals: [])); + }); + + it('should remove and add same item', () { + var list = ['a', 'b', 'c']; + var record = detector.watch(list, null, 'handler'); + detector.collectChanges(); + + list.remove('b'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'c[2 -> 1]'], + additions: [], + moves: ['c[2 -> 1]'], + removals: ['b[1 -> null]'])); + + list.insert(1, 'b'); + expect(list).toEqual(['a', 'b', 'c']); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'b[null -> 1]', 'c[1 -> 2]'], + additions: ['b[null -> 1]'], + moves: ['c[1 -> 2]'], + removals: [])); + }); + + it('should support duplicates', () { + var list = ['a', 'a', 'a', 'b', 'b']; + var record = detector.watch(list, null, 'handler'); + detector.collectChanges(); + + list.removeAt(0); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'a', 'b[3 -> 2]', 'b[4 -> 3]'], + additions: [], + moves: ['b[3 -> 2]', 'b[4 -> 3]'], + removals: ['a[2 -> null]'])); + }); + + + it('should support insertions/moves', () { + var list = ['a', 'a', 'b', 'b']; + var record = detector.watch(list, null, 'handler'); + detector.collectChanges(); + list.insert(0, 'b'); + expect(list).toEqual(['b', 'a', 'a', 'b', 'b']); + // todo(vbe) There is something wrong when running this test w/ karma +// expect(detector.collectChanges().currentValue, toEqualCollectionRecord( +// collection: ['b[2 -> 0]', 'a[0 -> 1]', 'a[1 -> 2]', 'b', 'b[null -> 4]'], +// additions: ['b[null -> 4]'], +// moves: ['b[2 -> 0]', 'a[0 -> 1]', 'a[1 -> 2]'], +// removals: [])); + }); + + it('should support UnmodifiableListView', () { + var hiddenList = [1]; + var list = new UnmodifiableListView(hiddenList); + var record = detector.watch(list, null, 'handler'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['1[null -> 0]'], + additions: ['1[null -> 0]'], + moves: [], + removals: [])); + + // assert no changes detected + expect(detector.collectChanges()).toEqual(null); + + // change the hiddenList normally this should trigger change detection + // but because we are wrapped in UnmodifiableListView we see nothing. + hiddenList[0] = 2; + expect(detector.collectChanges()).toEqual(null); + }); + }); + + describe('map watching', () { + xit('should do basic map watching', () { + var map = {}; + var record = detector.watch(map, null, 'handler'); + expect(detector.collectChanges()).toEqual(null); + + map.add('a'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a[null -> 0]'], + additions: ['a[null -> 0]'], + moves: [], + removals: [])); + + map.add('b'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'b[null -> 1]'], + additions: ['b[null -> 1]'], + moves: [], + removals: [])); + + map.add('c'); + map.add('d'); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'b', 'c[null -> 2]', 'd[null -> 3]'], + additions: ['c[null -> 2]', 'd[null -> 3]'], + moves: [], + removals: [])); + + map.remove('c'); + expect(map).toEqual(['a', 'b', 'd']); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['a', 'b', 'd[3 -> 2]'], + additions: [], + moves: ['d[3 -> 2]'], + removals: ['c[2 -> null]'])); + + map.clear(); + map.addAll(['d', 'c', 'b', 'a']); + expect(detector.collectChanges().currentValue, toEqualCollectionRecord( + collection: ['d[2 -> 0]', 'c[null -> 1]', 'b[1 -> 2]', 'a[0 -> 3]'], + additions: ['c[null -> 1]'], + moves: ['d[2 -> 0]', 'b[1 -> 2]', 'a[0 -> 3]'], + removals: [])); + }); + }); + + describe('DuplicateMap', () { + DuplicateMap map; + beforeEach(() => map = new DuplicateMap()); + + it('should do basic operations', () { + var k1 = 'a'; + var r1 = new ItemRecord(k1); + r1.currentKey = 1; + map.put(r1); + expect(map.get(k1, 2)).toEqual(null); + expect(map.get(k1, 1)).toEqual(null); + expect(map.get(k1, 0)).toEqual(r1); + expect(map.remove(r1)).toEqual(r1); + expect(map.get(k1, -1)).toEqual(null); + }); + + it('should do basic operations on duplicate keys', () { + var k1 = 'a'; + var r1 = new ItemRecord(k1); + var r2 = new ItemRecord(k1); + r1.currentKey = 1; + r2.currentKey = 2; + map.put(r1); + map.put(r2); + expect(map.get(k1, 0)).toEqual(r1); + expect(map.get(k1, 1)).toEqual(r2); + expect(map.get(k1, 2)).toEqual(null); + expect(map.remove(r2)).toEqual(r2); + expect(map.get(k1, 0)).toEqual(r1); + expect(map.remove(r1)).toEqual(r1); + expect(map.get(k1, 0)).toEqual(null); + }); + }); +}); + +class _User { + String first; + String last; + num age; + + _User([this.first, this.last, this.age]); +} + +Matcher toEqualCollectionRecord({collection, additions, moves, removals}) => + new CollectionRecordMatcher(collection:collection, additions:additions, + moves:moves, removals:removals); +Matcher toEqualChanges(List changes) => new ChangeMatcher(changes); + +class ChangeMatcher extends Matcher { + List expected; + + ChangeMatcher(this.expected); + + Description describe(Description description) => description..add(expected.toString()); + + Description describeMismatch(changes, Description mismatchDescription, Map matchState, bool verbose) { + List list = []; + while(changes != null) { + list.add(changes.handler); + changes = changes.nextChange; + } + return mismatchDescription..add(list.toString()); + } + + bool matches(changes, Map matchState) { + int count = 0; + while(changes != null) { + if (changes.handler != expected[count++]) return false; + changes = changes.nextChange; + } + return count == expected.length; + } +} + +class CollectionRecordMatcher extends Matcher { + List collection; + List additions; + List moves; + List removals; + + CollectionRecordMatcher({this.collection, this.additions, this.moves, this.removals}); + + Description describeMismatch(changes, Description mismatchDescription, Map matchState, bool verbose) { + List diffs = matchState['diffs']; + return mismatchDescription..add(diffs.join('\n')); + } + + Description describe(Description description) { + add(name, collection) { + if (collection != null) { + description.add('$name: ${collection.join(', ')}\n '); + } + } + + add('collection', collection); + add('additions', additions); + add('moves', moves); + add('removals', removals); + return description; + } + + bool matches(CollectionChangeRecord changeRecord, Map matchState) { + List diffs = matchState['diffs'] = []; + var equals = true; + equals = equals && checkCollection(changeRecord, diffs); + equals = equals && checkAdditions(changeRecord, diffs); + equals = equals && checkMoves(changeRecord, diffs); + equals = equals && checkRemovals(changeRecord, diffs); + return equals; + } + + checkCollection(CollectionChangeRecord changeRecord, List diffs) { + var equals = true; + if (collection != null) { + CollectionItem collectionItem = changeRecord.collectionHead; + for(var item in collection) { + if (collectionItem == null) { + equals = false; + diffs.add('collection too short: $item'); + } else { + if (collectionItem.toString() != item) { + equals = false; + diffs.add('collection mismatch: $collectionItem != $item'); + } + collectionItem = collectionItem.nextCollectionItem; + } + } + if (collectionItem != null) { + diffs.add('collection too long: $collectionItem'); + equals = false; + } + } + return equals; + } + + checkAdditions(CollectionChangeRecord changeRecord, List diffs) { + var equals = true; + if (additions != null) { + CollectionItem addedItem = changeRecord.additionsHead; + for(var item in additions) { + if (addedItem == null) { + equals = false; + diffs.add('additions too short: $item'); + } else { + if (addedItem.toString() != item) { + equals = false; + diffs.add('additions mismatch: $addedItem != $item'); + } + addedItem = addedItem.nextAddedItem; + } + } + if (addedItem != null) { + equals = false; + diffs.add('additions too long: $addedItem'); + } + } + return equals; + } + + checkMoves(CollectionChangeRecord changeRecord, List diffs) { + var equals = true; + if (moves != null) { + CollectionItem movedItem = changeRecord.movesHead; + for(var item in moves) { + if (movedItem == null) { + equals = false; + diffs.add('moves too short: $item'); + } else { + if (movedItem.toString() != item) { + equals = false; + diffs.add('moves too mismatch: $movedItem != $item'); + } + movedItem = movedItem.nextMovedItem; + } + } + if (movedItem != null) { + equals = false; + diffs.add('moves too long: $movedItem'); + } + } + return equals; + } + + checkRemovals(CollectionChangeRecord changeRecord, List diffs) { + var equals = true; + if (removals != null) { + CollectionItem removedItem = changeRecord.removalsHead; + for(var item in removals) { + if (removedItem == null) { + equals = false; + diffs.add('removes too short: $item'); + } else { + if (removedItem.toString() != item) { + equals = false; + diffs.add('removes too mismatch: $removedItem != $item'); + } + removedItem = removedItem.nextRemovedItem; + } + } + if (removedItem != null) { + equals = false; + diffs.add('removes too long: $removedItem'); + } + } + return equals; + } +} diff --git a/test/change_detection/watch_group_spec.dart b/test/change_detection/watch_group_spec.dart new file mode 100644 index 000000000..8ccc4a44b --- /dev/null +++ b/test/change_detection/watch_group_spec.dart @@ -0,0 +1,562 @@ +library scope2_spec; + +import '../_specs.dart'; +import 'package:angular/change_detection/watch_group.dart'; +import 'package:angular/change_detection/dirty_checking_change_detector.dart'; +import 'dirty_checking_change_detector_spec.dart' hide main; + +main() => describe('WatchGroup', () { + var context; + var watchGrp; + DirtyCheckingChangeDetector changeDetector; + Logger logger; + + AST parse(String expression) { + var currentAST = new ContextReferenceAST(); + expression.split('.').forEach((name) { + currentAST = new FieldReadAST(currentAST, name); + }); + return currentAST; + } + + expectOrder(list) { + logger.clear(); + watchGrp.detectChanges(); // Clear the initial queue + logger.clear(); + watchGrp.detectChanges(); + expect(logger).toEqual(list); + } + + beforeEach(inject((Logger _logger) { + context = {}; + changeDetector = new DirtyCheckingChangeDetector(new GetterCache({})); + watchGrp = new RootWatchGroup(changeDetector, context); + logger = _logger; + })); + + describe('property chaining', () { + it('should read property', () { + context['a'] = 'hello'; + + // should fire on initial adding + expect(watchGrp.fieldCost).toEqual(0); + var watch = watchGrp.watch(parse('a'), (v, p, o) => logger(v)); + expect(watch.expression).toEqual('a'); + expect(watchGrp.fieldCost).toEqual(1); + watchGrp.detectChanges(); + expect(logger).toEqual(['hello']); + + // make sore no new changes are logged on extra detectChanges + watchGrp.detectChanges(); + expect(logger).toEqual(['hello']); + + // Should detect value change + context['a'] = 'bye'; + watchGrp.detectChanges(); + expect(logger).toEqual(['hello', 'bye']); + + // should cleanup after itself + watch.remove(); + expect(watchGrp.fieldCost).toEqual(0); + context['a'] = 'cant see me'; + watchGrp.detectChanges(); + expect(logger).toEqual(['hello', 'bye']); + }); + + it('should read property chain', () { + context['a'] = {'b': 'hello'}; + + // should fire on initial adding + expect(watchGrp.fieldCost).toEqual(0); + expect(changeDetector.count).toEqual(0); + var watch = watchGrp.watch(parse('a.b'), (v, p, o) => logger(v)); + expect(watch.expression).toEqual('a.b'); + expect(watchGrp.fieldCost).toEqual(2); + expect(changeDetector.count).toEqual(2); + watchGrp.detectChanges(); + expect(logger).toEqual(['hello']); + + // make sore no new changes are logged on extra detectChanges + watchGrp.detectChanges(); + expect(logger).toEqual(['hello']); + + // make sure no changes or logged when intermediary object changes + context['a'] = {'b': 'hello'}; + watchGrp.detectChanges(); + expect(logger).toEqual(['hello']); + + // Should detect value change + context['a'] = {'b': 'hello2'}; + watchGrp.detectChanges(); + expect(logger).toEqual(['hello', 'hello2']); + + // Should detect value change + context['a']['b'] = 'bye'; + watchGrp.detectChanges(); + expect(logger).toEqual(['hello', 'hello2', 'bye']); + + // should cleanup after itself + watch.remove(); + expect(watchGrp.fieldCost).toEqual(0); + context['a']['b'] = 'cant see me'; + watchGrp.detectChanges(); + expect(logger).toEqual(['hello', 'hello2', 'bye']); + }); + + it('should reuse handlers', () { + var user1 = {'first': 'misko', 'last': 'hevery'}; + var user2 = {'first': 'misko', 'last': 'Hevery'}; + + context['user'] = user1; + + // should fire on initial adding + expect(watchGrp.fieldCost).toEqual(0); + var watch = watchGrp.watch(parse('user'), (v, p, o) => logger(v)); + var watchFirst = watchGrp.watch(parse('user.first'), (v, p, o) => logger(v)); + var watchLast = watchGrp.watch(parse('user.last'), (v, p, o) => logger(v)); + expect(watchGrp.fieldCost).toEqual(3); + + watchGrp.detectChanges(); + expect(logger).toEqual([user1, 'misko', 'hevery']); + logger.clear(); + + context['user'] = user2; + watchGrp.detectChanges(); + expect(logger).toEqual([user2, 'Hevery']); + + + watch.remove(); + expect(watchGrp.fieldCost).toEqual(3); + + watchFirst.remove(); + expect(watchGrp.fieldCost).toEqual(2); + + watchLast.remove(); + expect(watchGrp.fieldCost).toEqual(0); + + expect(() => watch.remove()).toThrow('Already deleted!'); + }); + + it('should eval pure FunctionApply', () { + context['a'] = {'val': 1}; + + FunctionApply fn = new LoggingFunctionApply(logger); + var watch = watchGrp.watch( + new PureFunctionAST('add', fn, [parse('a.val')]), + (v, p, o) => logger(v) + ); + + // a; a.val; b; b.val; + expect(watchGrp.fieldCost).toEqual(2); + // add + expect(watchGrp.evalCost).toEqual(1); + + watchGrp.detectChanges(); + expect(logger).toEqual([[1], null]); + }); + + + it('should eval pure function', () { + context['a'] = {'val': 1}; + context['b'] = {'val': 2}; + + var watch = watchGrp.watch( + new PureFunctionAST('add', + (a, b) { logger('+'); return a+b; }, + [parse('a.val'), parse('b.val')] + ), + (v, p, o) => logger(v) + ); + + // a; a.val; b; b.val; + expect(watchGrp.fieldCost).toEqual(4); + // add + expect(watchGrp.evalCost).toEqual(1); + + watchGrp.detectChanges(); + expect(logger).toEqual(['+', 3]); + + // extra checks should not trigger functions + watchGrp.detectChanges(); + watchGrp.detectChanges(); + expect(logger).toEqual(['+', 3]); + + // multiple arg changes should only trigger function once. + context['a']['val'] = 3; + context['b']['val'] = 4; + + watchGrp.detectChanges(); + expect(logger).toEqual(['+', 3, '+', 7]); + + watch.remove(); + expect(watchGrp.fieldCost).toEqual(0); + expect(watchGrp.evalCost).toEqual(0); + + context['a']['val'] = 0; + context['b']['val'] = 0; + + watchGrp.detectChanges(); + expect(logger).toEqual(['+', 3, '+', 7]); + }); + + + it('should eval chained pure function', () { + context['a'] = {'val': 1}; + context['b'] = {'val': 2}; + context['c'] = {'val': 3}; + + var a_plus_b = new PureFunctionAST('add1', + (a, b) { logger('$a+$b'); return a + b; }, + [parse('a.val'), parse('b.val')]); + + var a_plus_b_plus_c = new PureFunctionAST('add2', + (b, c) { logger('$b+$c'); return b + c; }, + [a_plus_b, parse('c.val')]); + + var watch = watchGrp.watch(a_plus_b_plus_c, (v, p, o) => logger(v)); + + // a; a.val; b; b.val; c; c.val; + expect(watchGrp.fieldCost).toEqual(6); + // add + expect(watchGrp.evalCost).toEqual(2); + + watchGrp.detectChanges(); + expect(logger).toEqual(['1+2', '3+3', 6]); + logger.clear(); + + // extra checks should not trigger functions + watchGrp.detectChanges(); + watchGrp.detectChanges(); + expect(logger).toEqual([]); + logger.clear(); + + // multiple arg changes should only trigger function once. + context['a']['val'] = 3; + context['b']['val'] = 4; + context['c']['val'] = 5; + watchGrp.detectChanges(); + expect(logger).toEqual(['3+4', '7+5', 12]); + logger.clear(); + + context['a']['val'] = 9; + watchGrp.detectChanges(); + expect(logger).toEqual(['9+4', '13+5', 18]); + logger.clear(); + + context['c']['val'] = 9; + watchGrp.detectChanges(); + expect(logger).toEqual(['13+9', 22]); + logger.clear(); + + + watch.remove(); + expect(watchGrp.fieldCost).toEqual(0); + expect(watchGrp.evalCost).toEqual(0); + + context['a']['val'] = 0; + context['b']['val'] = 0; + + watchGrp.detectChanges(); + expect(logger).toEqual([]); + }); + + + it('should eval closure', () { + var obj; + obj = { + 'methodA': (arg1) { + logger('methodA($arg1) => ${obj['valA']}'); + return obj['valA']; + }, + 'valA': 'A' + }; + context['obj'] = obj; + context['arg0'] = 1; + + var watch = watchGrp.watch( + new MethodAST(parse('obj'), 'methodA', [parse('arg0')]), + (v, p, o) => logger(v) + ); + + // obj, arg0; + expect(watchGrp.fieldCost).toEqual(2); + // methodA() + expect(watchGrp.evalCost).toEqual(1); + + watchGrp.detectChanges(); + expect(logger).toEqual(['methodA(1) => A', 'A']); + logger.clear(); + + watchGrp.detectChanges(); + watchGrp.detectChanges(); + expect(logger).toEqual(['methodA(1) => A', 'methodA(1) => A']); + logger.clear(); + + obj['valA'] = 'B'; + context['arg0'] = 2; + + watchGrp.detectChanges(); + expect(logger).toEqual(['methodA(2) => B', 'B']); + logger.clear(); + + watch.remove(); + expect(watchGrp.fieldCost).toEqual(0); + expect(watchGrp.evalCost).toEqual(0); + + obj['valA'] = 'C'; + context['arg0'] = 3; + + watchGrp.detectChanges(); + expect(logger).toEqual([]); + }); + + + it('should eval method', () { + var obj = new MyClass(logger); + obj.valA = 'A'; + context['obj'] = obj; + context['arg0'] = 1; + + var watch = watchGrp.watch( + new MethodAST(parse('obj'), 'methodA', [parse('arg0')]), + (v, p, o) => logger(v) + ); + + // obj, arg0; + expect(watchGrp.fieldCost).toEqual(2); + // methodA() + expect(watchGrp.evalCost).toEqual(1); + + watchGrp.detectChanges(); + expect(logger).toEqual(['methodA(1) => A', 'A']); + logger.clear(); + + watchGrp.detectChanges(); + watchGrp.detectChanges(); + expect(logger).toEqual(['methodA(1) => A', 'methodA(1) => A']); + logger.clear(); + + obj.valA = 'B'; + context['arg0'] = 2; + + watchGrp.detectChanges(); + expect(logger).toEqual(['methodA(2) => B', 'B']); + logger.clear(); + + watch.remove(); + expect(watchGrp.fieldCost).toEqual(0); + expect(watchGrp.evalCost).toEqual(0); + + obj.valA = 'C'; + context['arg0'] = 3; + + watchGrp.detectChanges(); + expect(logger).toEqual([]); + }); + + it('should eval method chain', () { + var obj1 = new MyClass(logger); + var obj2 = new MyClass(logger); + obj1.valA = obj2; + obj2.valA = 'A'; + context['obj'] = obj1; + context['arg0'] = 0; + context['arg1'] = 1; + + // obj.methodA(arg0) + var ast = new MethodAST(parse('obj'), 'methodA', [parse('arg0')]); + ast = new MethodAST(ast, 'methodA', [parse('arg1')]); + var watch = watchGrp.watch(ast, (v, p, o) => logger(v)); + + // obj, arg0, arg1; + expect(watchGrp.fieldCost).toEqual(3); + // methodA(), mothodA() + expect(watchGrp.evalCost).toEqual(2); + + watchGrp.detectChanges(); + expect(logger).toEqual(['methodA(0) => MyClass', 'methodA(1) => A', 'A']); + logger.clear(); + + watchGrp.detectChanges(); + watchGrp.detectChanges(); + expect(logger).toEqual(['methodA(0) => MyClass', 'methodA(1) => A', + 'methodA(0) => MyClass', 'methodA(1) => A']); + logger.clear(); + + obj2.valA = 'B'; + context['arg0'] = 10; + context['arg1'] = 11; + + watchGrp.detectChanges(); + expect(logger).toEqual(['methodA(10) => MyClass', 'methodA(11) => B', 'B']); + logger.clear(); + + watch.remove(); + expect(watchGrp.fieldCost).toEqual(0); + expect(watchGrp.evalCost).toEqual(0); + + obj2.valA = 'C'; + context['arg0'] = 20; + context['arg1'] = 21; + + watchGrp.detectChanges(); + expect(logger).toEqual([]); + }); + + it('should read connstant', () { + // should fire on initial adding + expect(watchGrp.fieldCost).toEqual(0); + var watch = watchGrp.watch(new ConstantAST(123), (v, p, o) => logger(v)); + expect(watch.expression).toEqual('123'); + expect(watchGrp.fieldCost).toEqual(0); + watchGrp.detectChanges(); + expect(logger).toEqual([123]); + + // make sore no new changes are logged on extra detectChanges + watchGrp.detectChanges(); + expect(logger).toEqual([123]); + }); + + it('should wrap iterable in ObservableList', () { + context['list'] = []; + var watch = watchGrp.watch(new CollectionAST(parse('list')), (v, p, o) => logger(v)); + + expect(watchGrp.fieldCost).toEqual(1); + expect(watchGrp.collectionCost).toEqual(1); + expect(watchGrp.evalCost).toEqual(0); + + watchGrp.detectChanges(); + expect(logger.length).toEqual(1); + expect(logger[0], toEqualCollectionRecord( + collection: [], + additions: [], + moves: [], + removals: [])); + logger.clear(); + + context['list'] = [1]; + watchGrp.detectChanges(); + expect(logger.length).toEqual(1); + expect(logger[0], toEqualCollectionRecord( + collection: ['1[null -> 0]'], + additions: ['1[null -> 0]'], + moves: [], + removals: [])); + logger.clear(); + + watch.remove(); + expect(watchGrp.fieldCost).toEqual(0); + expect(watchGrp.collectionCost).toEqual(0); + expect(watchGrp.evalCost).toEqual(0); + }); + }); + + describe('child group', () { + it('should remove all field watches in group and group\'s children', () { + watchGrp.watch(parse('a'), (v, p, o) => logger('0a')); + var child1a = watchGrp.newGroup(new PrototypeMap(context)); + var child1b = watchGrp.newGroup(new PrototypeMap(context)); + var child2 = child1a.newGroup(new PrototypeMap(context)); + child1a.watch(parse('a'), (v, p, o) => logger('1a')); + child1b.watch(parse('a'), (v, p, o) => logger('1b')); + watchGrp.watch(parse('a'), (v, p, o) => logger('0A')); + child1a.watch(parse('a'), (v, p, o) => logger('1A')); + child2.watch(parse('a'), (v, p, o) => logger('2A')); + + // flush initial reaction functions + expect(watchGrp.detectChanges()).toEqual(6); + // expect(logger).toEqual(['0a', '0A', '1a', '1A', '2A', '1b']); + expect(logger).toEqual(['0a', '1a', '1b', '0A', '1A', '2A']); // we go by registration order + expect(watchGrp.fieldCost).toEqual(1); + expect(watchGrp.totalFieldCost).toEqual(4); + logger.clear(); + + context['a'] = 1; + expect(watchGrp.detectChanges()).toEqual(6); + expect(logger).toEqual(['0a', '0A', '1a', '1A', '2A', '1b']); // we go by group order + logger.clear(); + + context['a'] = 2; + child1a.remove(); // should also remove child2 + expect(watchGrp.detectChanges()).toEqual(3); + expect(logger).toEqual(['0a', '0A', '1b']); + expect(watchGrp.fieldCost).toEqual(1); + expect(watchGrp.totalFieldCost).toEqual(2); + }); + + it('should remove all method watches in group and group\'s children', () { + context['my'] = new MyClass(logger); + AST countMethod = new MethodAST(parse('my'), 'count', []); + watchGrp.watch(countMethod, (v, p, o) => logger('0a')); + expectOrder(['0a']); + + var child1a = watchGrp.newGroup(new PrototypeMap(context)); + var child1b = watchGrp.newGroup(new PrototypeMap(context)); + var child2 = child1a.newGroup(new PrototypeMap(context)); + child1a.watch(countMethod, (v, p, o) => logger('1a')); + expectOrder(['0a', '1a']); + child1b.watch(countMethod, (v, p, o) => logger('1b')); + expectOrder(['0a', '1a', '1b']); + watchGrp.watch(countMethod, (v, p, o) => logger('0A')); + expectOrder(['0a', '0A', '1a', '1b']); + child1a.watch(countMethod, (v, p, o) => logger('1A')); + expectOrder(['0a', '0A', '1a', '1A', '1b']); + child2.watch(countMethod, (v, p, o) => logger('2A')); + expectOrder(['0a', '0A', '1a', '1A', '2A', '1b']); + + // flush initial reaction functions + expect(watchGrp.detectChanges()).toEqual(6); + expectOrder(['0a', '0A', '1a', '1A', '2A', '1b']); + + child1a.remove(); // should also remove child2 + expect(watchGrp.detectChanges()).toEqual(3); + expectOrder(['0a', '0A', '1b']); + }); + + it('should add watches within its own group', () { + context['my'] = new MyClass(logger); + AST countMethod = new MethodAST(parse('my'), 'count', []); + var ra = watchGrp.watch(countMethod, (v, p, o) => logger('a')); + var child = watchGrp.newGroup(new PrototypeMap(context)); + var cb = child.watch(countMethod, (v, p, o) => logger('b')); + + expectOrder(['a', 'b']); + expectOrder(['a', 'b']); + + ra.remove(); + expectOrder(['b']); + + cb.remove(); + expectOrder([]); + + // TODO: add them back in wrong order, assert events in right order + cb = child.watch(countMethod, (v, p, o) => logger('b')); + ra = watchGrp.watch(countMethod, (v, p, o) => logger('a'));; + expectOrder(['a', 'b']); + }); + }); + +}); + +class MyClass { + final Logger logger; + var valA; + int _count = 0; + + MyClass(this.logger); + + methodA(arg1) { + logger('methodA($arg1) => $valA'); + return valA; + } + + count() => _count++; + + toString() => 'MyClass'; +} + +class LoggingFunctionApply extends FunctionApply { + Logger logger; + LoggingFunctionApply(this.logger); + apply(List args) => logger(args); +} diff --git a/test/core/scope2_spec.dart b/test/core/scope2_spec.dart new file mode 100644 index 000000000..c82ece231 --- /dev/null +++ b/test/core/scope2_spec.dart @@ -0,0 +1,224 @@ +library scope_spec; + +import '../_specs.dart'; +import 'package:angular/change_detection/watch_group.dart'; +import 'package:angular/change_detection/change_detection.dart'; +import 'package:angular/change_detection/dirty_checking_change_detector.dart'; +import 'package:angular/core/scope2.dart'; + +main() => describe('scope2', () { + Scope2 scope; + Map context; + + beforeEach(module((Module module) { + context = {}; + module.value(GetterCache, new GetterCache({})); + module.type(ChangeDetector, implementedBy: DirtyCheckingChangeDetector); + module.value(Object, context); + module.type(WatchGroup, implementedBy: RootWatchGroup); + module.type(Scope2); + module.type(_MultiplyFilter); + module.type(_ListHeadFilter); + module.type(_ListTailFilter); + module.type(_SortFilter); + })); + beforeEach(inject((Scope2 s) { + scope = s; + })); + + describe('AST Bridge', () { + it('should watch field', inject((Logger logger) { + context['field'] = 'Worked!'; + scope.watch('field', (value, previous, context) => logger([value, previous, context])); + expect(logger).toEqual([]); + scope.digest(); + expect(logger).toEqual([['Worked!', null, context]]); + scope.digest(); + expect(logger).toEqual([['Worked!', null, context]]); + })); + + it('should watch field path', inject((Logger logger) { + context['a'] = {'b': 'AB'}; + scope.watch('a.b', (value, previous, context) => logger(value)); + scope.digest(); + expect(logger).toEqual(['AB']); + context['a']['b'] = '123'; + scope.digest(); + expect(logger).toEqual(['AB', '123']); + context['a'] = {'b': 'XYZ'}; + scope.digest(); + expect(logger).toEqual(['AB', '123', 'XYZ']); + })); + + it('should watch math operations', inject((Logger logger) { + context['a'] = 1; + context['b'] = 2; + scope.watch('a + b + 1', (value, previous, context) => logger(value)); + scope.digest(); + expect(logger).toEqual([4]); + context['a'] = 3; + scope.digest(); + expect(logger).toEqual([4, 6]); + context['b'] = 5; + scope.digest(); + expect(logger).toEqual([4, 6, 9]); + })); + + + it('should watch literals', inject((Logger logger) { + context['a'] = 1; + scope.watch('1', (value, previous, context) => logger(value)); + scope.watch('"str"', (value, previous, context) => logger(value)); + scope.watch('[a, 2, 3]', (value, previous, context) => logger(value)); + scope.watch('{a:a, b:2}', (value, previous, context) => logger(value)); + scope.digest(); + expect(logger).toEqual([1, 'str', [1, 2, 3], {'a': 1, 'b': 2}]); + logger.clear(); + context['a'] = 3; + scope.digest(); + // Even though we changed the 'a' field none of the watches fire because + // the underlying array/map identity does not change. We just update the + // properties on the same array/map. + expect(logger).toEqual([]); + })); + + it('should invoke closures', inject((Logger logger) { + context['fn'] = () { + logger('fn'); + return 1; + }; + context['a'] = {'fn': () { + logger('a.fn'); + return 2; + }}; + scope.watch('fn()', (value, previous, context) => logger('=> $value')); + scope.watch('a.fn()', (value, previous, context) => logger('-> $value')); + scope.digest(); + expect(logger).toEqual(['fn', 'a.fn', '=> 1', '-> 2']); + logger.clear(); + scope.digest(); + expect(logger).toEqual(['fn', 'a.fn']); + })); + + it('should perform conditionals', inject((Logger logger) { + context['a'] = 1; + context['b'] = 2; + context['c'] = 3; + scope.watch('a?b:c', (value, previous, context) => logger(value)); + scope.digest(); + expect(logger).toEqual([2]); + logger.clear(); + context['a'] = 0; + scope.digest(); + expect(logger).toEqual([3]); + })); + + + xit('should call function', inject((Logger logger) { + context['a'] = () { + return () { return 123; }; + }; + scope.watch('a()()', (value, previous, context) => logger(value)); + scope.digest(); + expect(logger).toEqual([123]); + logger.clear(); + scope.digest(); + expect(logger).toEqual([]); + })); + + it('should access bracket', inject((Logger logger) { + context['a'] = {'b': 123}; + scope.watch('a["b"]', (value, previous, context) => logger(value)); + scope.digest(); + expect(logger).toEqual([123]); + logger.clear(); + scope.digest(); + expect(logger).toEqual([]); + })); + + + it('should prefix', inject((Logger logger) { + context['a'] = true; + scope.watch('!a', (value, previous, context) => logger(value)); + scope.digest(); + expect(logger).toEqual([false]); + logger.clear(); + context['a'] = false; + scope.digest(); + expect(logger).toEqual([true]); + })); + + it('should support filters', inject((Logger logger) { + context['a'] = 123; + context['b'] = 2; + scope.watch('a | multiply:b', (value, previous, context) => logger(value)); + scope.digest(); + expect(logger).toEqual([246]); + logger.clear(); + scope.digest(); + expect(logger).toEqual([]); + logger.clear(); + })); + + it('should support arrays in filters', inject((Logger logger) { + context['a'] = [1]; + scope.watch('a | sort | listHead:"A" | listTail:"B"', (value, previous, context) => logger(value)); + scope.digest(); + expect(logger).toEqual(['sort', 'listHead', 'listTail', ['A', 1, 'B']]); + logger.clear(); + + scope.digest(); + expect(logger).toEqual([]); + logger.clear(); + + context['a'].add(2); + scope.digest(); + expect(logger).toEqual(['sort', 'listHead', 'listTail', ['A', 1, 2, 'B']]); + logger.clear(); + + // We change the order, but sort should change it to same one and it should not + // call subsequent filters. + context['a'] = [2, 1]; + scope.digest(); + expect(logger).toEqual(['sort']); + logger.clear(); + })); + }); + +}); + +@NgFilter(name: 'multiply') +class _MultiplyFilter { + call(a, b) => a * b; +} + +@NgFilter(name: 'listHead') +class _ListHeadFilter { + Logger logger; + _ListHeadFilter(Logger this.logger); + call(list, head) { + logger('listHead'); + return [head]..addAll(list); + } +} + + +@NgFilter(name: 'listTail') +class _ListTailFilter { + Logger logger; + _ListTailFilter(Logger this.logger); + call(list, tail) { + logger('listTail'); + return new List.from(list)..add(tail); + } +} + +@NgFilter(name: 'sort') +class _SortFilter { + Logger logger; + _SortFilter(Logger this.logger); + call(list) { + logger('sort'); + return new List.from(list)..sort(); + } +}