Refactor List diffing/change records, add diffing interface (#5)

* Refactor our a differ/list differ.

* Fix bugs in mergeSplice

* More fixes to the list differ.

* Some debugging.

* More fixes.

* Fix remaining edge case missed.

* Slight cleanups before PR

* Update README

* Add license headers.
diff --git a/.analysis_options b/.analysis_options
index dbb6342..6f04432 100644
--- a/.analysis_options
+++ b/.analysis_options
@@ -2,48 +2,27 @@
   strong-mode: true
 linter:
   rules:
-    #- always_declare_return_types
-    #- always_specify_types
-    #- annotate_overrides
-    #- avoid_as
+    # Errors
     - avoid_empty_else
+    - control_flow_in_finally
+    - empty_statements
+    - test_types_in_equals
+    - throw_in_finally
+    - valid_regexps
+
+    # Style
+    - annotate_overrides
     - avoid_init_to_null
     - avoid_return_types_on_setters
     - await_only_futures
     - camel_case_types
-    - cancel_subscriptions
-    #- close_sinks
-    #- comment_references
-    - constant_identifier_names
-    - control_flow_in_finally
+    - comment_references
     - empty_catches
     - empty_constructor_bodies
-    - empty_statements
     - hash_and_equals
-    - implementation_imports
-    - iterable_contains_unrelated_type
-    - library_names
     - library_prefixes
-    - list_remove_unrelated_type
     - non_constant_identifier_names
-    - one_member_abstracts
-    - only_throw_errors
-    - overridden_fields
-    - package_api_docs
-    - package_names
-    - package_prefixed_library_names
     - prefer_is_not_empty
-    #- public_member_api_docs
-    #- slash_for_doc_comments
-    #- sort_constructors_first
-    #- sort_unnamed_constructors_first
-    - super_goes_last
-    - test_types_in_equals
-    - throw_in_finally
-    #- type_annotate_public_apis
+    - slash_for_doc_comments
     - type_init_formals
-    #- unawaited_futures
-    - unnecessary_brace_in_string_interp
-    - unnecessary_getters_setters
     - unrelated_type_equality_checks
-    - valid_regexps
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aca61a5..bfcf1b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+## 0.15.0
+
+* Added the `Differ` interface, as well as `EqualityDiffer`
+* Refactored list diffing into a `ListDiffer`
+* Added concept of `ChangeRecord.ANY` and `ChangeRecord.NONE`
+    * Low-GC ways to expression "something/nothing" changed
+* Refactored `ListChangeRecord`
+    * Added named constructors for common use cases
+    * Added equality and hashCode checks
+    * Added `ListChangeRecord.apply` to apply a change record
+* Added missing `@override` annotations to satisfy `annotate_overrides`
+
 ## 0.14.0+1
 
 * Add a missing dependency on `pkg/meta`.
diff --git a/README.md b/README.md
index 3965a6f..c688514 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
-Support for marking objects as observable, and getting notifications when those
-objects are mutated.
+Support for detecting and being notified when an object is mutated.
 
-This library is used to observe changes to observable types. It also
-has helpers to make implementing and using observable objects easy.
+There are two general ways to detect changes:
+
+* Listen to `Observable.changes` and be notified when an object changes
+* Use `Differ.diff` to determine changes between two objects
diff --git a/lib/observable.dart b/lib/observable.dart
index ce82061..179a938 100644
--- a/lib/observable.dart
+++ b/lib/observable.dart
@@ -4,8 +4,8 @@
 
 library observable;
 
-export 'src/change_record.dart';
-export 'src/list_diff.dart' show ListChangeRecord;
+export 'src/differs.dart' show Differ, EqualityDiffer, ListDiffer;
+export 'src/records.dart' show ChangeRecord, ListChangeRecord;
 export 'src/observable.dart';
 export 'src/observable_list.dart';
 export 'src/observable_map.dart';
diff --git a/lib/src/change_record.dart b/lib/src/change_record.dart
deleted file mode 100644
index b4c4f24..0000000
--- a/lib/src/change_record.dart
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
-// for details. All rights reserved. Use of this source code is governed by a
-// BSD-style license that can be found in the LICENSE file.
-
-library observable.src.change_record;
-
-/// Records a change to an [Observable].
-// TODO(jmesserly): remove this type
-abstract class ChangeRecord {}
diff --git a/lib/src/differs.dart b/lib/src/differs.dart
new file mode 100644
index 0000000..41f8a89
--- /dev/null
+++ b/lib/src/differs.dart
@@ -0,0 +1,35 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable.src.differs;
+
+import 'dart:math' as math;
+
+import 'package:collection/collection.dart';
+
+import 'records.dart';
+
+part 'differs/list_differ.dart';
+
+/// Generic comparisons between two comparable objects.
+abstract class Differ<E> {
+  /// Returns a list of change records between [e1] and [e2].
+  ///
+  /// A return value of an empty [ChangeRecord.NONE] means no changes found.
+  List<ChangeRecord> diff(E e1, E e2);
+}
+
+/// Uses [Equality] to determine a simple [ChangeRecord.ANY] response.
+class EqualityDiffer<E> implements Differ<E> {
+  final Equality<E> _equality;
+
+  const EqualityDiffer([this._equality = const DefaultEquality()]);
+
+  const EqualityDiffer.identity() : this._equality = const IdentityEquality();
+
+  @override
+  List<ChangeRecord> diff(E e1, E e2) {
+    return _equality.equals(e1, e2) ? ChangeRecord.NONE : ChangeRecord.ANY;
+  }
+}
diff --git a/lib/src/differs/list_differ.dart b/lib/src/differs/list_differ.dart
new file mode 100644
index 0000000..2ca0c09
--- /dev/null
+++ b/lib/src/differs/list_differ.dart
@@ -0,0 +1,470 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+part of observable.src.differs;
+
+/// Determines differences between two lists, returning [ListChangeRecord]s.
+///
+/// While [ListChangeRecord] has more information and can be replayed they carry
+/// a more significant cost to calculate and create and should only be used when
+/// the details in the record will actually be used.
+///
+/// See also [EqualityDiffer] for a simpler comparison.
+class ListDiffer<E> implements Differ<List<E>> {
+  final Equality<E> _equality;
+
+  const ListDiffer([this._equality = const DefaultEquality<E>()]);
+
+  @override
+  List<ListChangeRecord<E>> diff(List<E> e1, List<E> e2) {
+    return _calcSplices/*<E>*/(
+      e2,
+      _equality,
+      0,
+      e2.length,
+      e1,
+      0,
+      e1.length,
+    );
+  }
+}
+
+enum _Edit {
+  leave,
+  update,
+  add,
+  delete,
+}
+
+// Note: This function is *based* on the computation of the Levenshtein
+// "edit" distance. The one change is that "updates" are treated as two
+// edits - not one. With List splices, an update is really a delete
+// followed by an add. By retaining this, we optimize for "keeping" the
+// maximum array items in the original array. For example:
+//
+//   'xxxx123' -> '123yyyy'
+//
+// With 1-edit updates, the shortest path would be just to update all seven
+// characters. With 2-edit updates, we delete 4, leave 3, and add 4. This
+// leaves the substring '123' intact.
+List<List<int>> _calcEditDistance/*<E>*/(
+  List/*<E>*/ current,
+  int currentStart,
+  int currentEnd,
+  List/*<E>*/ old,
+  int oldStart,
+  int oldEnd,
+) {
+  // 'Deletion' columns.
+  final rowCount = oldEnd - oldStart + 1;
+  final columnCount = currentEnd - currentStart + 1;
+  final distances = new List<List<int>>(rowCount);
+
+  // 'Addition' rows. Initialize null column.
+  for (var i = 0; i < rowCount; i++) {
+    distances[i] = new List<int>(columnCount);
+    distances[i][0] = i;
+  }
+
+  // Initialize null row.
+  for (var j = 0; j < columnCount; j++) {
+    distances[0][j] = j;
+  }
+
+  for (var i = 1; i < rowCount; i++) {
+    for (var j = 1; j < columnCount; j++) {
+      if (old[oldStart + i - 1] == current[currentStart + j - 1]) {
+        distances[i][j] = distances[i - 1][j - 1];
+      } else {
+        final north = distances[i - 1][j] + 1;
+        final west = distances[i][j - 1] + 1;
+        distances[i][j] = math.min(north, west);
+      }
+    }
+  }
+
+  return distances;
+}
+
+// This starts at the final weight, and walks "backward" by finding
+// the minimum previous weight recursively until the origin of the weight
+// matrix.
+Iterable<_Edit> _spliceOperationsFromEditDistances(List<List<int>> distances) {
+  var i = distances.length - 1;
+  var j = distances[0].length - 1;
+  var current = distances[i][j];
+  final edits = <_Edit>[];
+  while (i > 0 || j > 0) {
+    if (i == 0) {
+      edits.add(_Edit.add);
+      j--;
+      continue;
+    }
+    if (j == 0) {
+      edits.add(_Edit.delete);
+      i--;
+      continue;
+    }
+    final northWest = distances[i - 1][j - 1];
+    final west = distances[i - 1][j];
+    final north = distances[i][j - 1];
+
+    final min = math.min(math.min(west, north), northWest);
+    if (min == northWest) {
+      if (northWest == current) {
+        edits.add(_Edit.leave);
+      } else {
+        edits.add(_Edit.update);
+        current = northWest;
+      }
+      i--;
+      j--;
+    } else if (min == west) {
+      edits.add(_Edit.delete);
+      i--;
+      current = west;
+    } else {
+      edits.add(_Edit.add);
+      j--;
+      current = north;
+    }
+  }
+
+  return edits.reversed;
+}
+
+int _sharedPrefix/*<E>*/(
+  Equality/*<E>*/ equality,
+  List/*<E>*/ e1,
+  List/*<E>*/ e2,
+  int searchLength,
+) {
+  for (var i = 0; i < searchLength; i++) {
+    if (!equality.equals(e1[i], e2[i])) {
+      return i;
+    }
+  }
+  return searchLength;
+}
+
+int _sharedSuffix/*<E>*/(
+  Equality/*<E>*/ equality,
+  List/*<E>*/ e1,
+  List/*<E>*/ e2,
+  int searchLength,
+) {
+  var index1 = e1.length;
+  var index2 = e2.length;
+  var count = 0;
+  while (count < searchLength && equality.equals(e1[--index1], e2[--index2])) {
+    count++;
+  }
+  return count;
+}
+
+// Lacking individual splice mutation information, the minimal set of
+// splices can be synthesized given the previous state and final state of an
+// array. The basic approach is to calculate the edit distance matrix and
+// choose the shortest path through it.
+//
+// Complexity: O(l * p)
+//   l: The length of the current array
+//   p: The length of the old array
+List<ListChangeRecord/*<E>*/ > _calcSplices/*<E>*/(
+  List/*<E>*/ current,
+  Equality/*<E>*/ equality,
+  int currentStart,
+  int currentEnd,
+  List/*<E>*/ old,
+  int oldStart,
+  int oldEnd,
+) {
+  var prefixCount = 0;
+  var suffixCount = 0;
+  final minLength = math.min(currentEnd - currentStart, oldEnd - oldStart);
+  if (currentStart == 0 && oldStart == 0) {
+    prefixCount = _sharedPrefix(
+      equality,
+      current,
+      old,
+      minLength,
+    );
+  }
+  if (currentEnd == current.length && oldEnd == old.length) {
+    suffixCount = _sharedSuffix(
+      equality,
+      current,
+      old,
+      minLength - prefixCount,
+    );
+  }
+
+  currentStart += prefixCount;
+  oldStart += prefixCount;
+  currentEnd -= suffixCount;
+  oldEnd -= suffixCount;
+
+  if (currentEnd - currentStart == 0 && oldEnd - oldStart == 0) {
+    return ChangeRecord.NONE;
+  }
+
+  if (currentStart == currentEnd) {
+    final spliceRemoved = old.sublist(oldStart, oldStart + oldEnd);
+    return [
+      new ListChangeRecord/*<E>*/ .remove(
+        current,
+        currentStart,
+        spliceRemoved,
+      ),
+    ];
+  }
+  if (oldStart == oldEnd) {
+    return [
+      new ListChangeRecord/*<E>*/ .add(
+        current,
+        currentStart,
+        currentEnd - currentStart,
+      ),
+    ];
+  }
+
+  final ops = _spliceOperationsFromEditDistances(
+    _calcEditDistance(
+      current,
+      currentStart,
+      currentEnd,
+      old,
+      oldStart,
+      oldEnd,
+    ),
+  );
+
+  var spliceIndex = -1;
+  var spliceRemovals = /*<E>*/ [];
+  var spliceAddedCount = 0;
+
+  bool hasSplice() => spliceIndex != -1;
+  void resetSplice() {
+    spliceIndex = -1;
+    spliceRemovals = /*<E>*/ [];
+    spliceAddedCount = 0;
+  }
+
+  var splices = <ListChangeRecord/*<E>*/ >[];
+
+  var index = currentStart;
+  var oldIndex = oldStart;
+  for (final op in ops) {
+    switch (op) {
+      case _Edit.leave:
+        if (hasSplice()) {
+          splices.add(new ListChangeRecord/*<E>*/(
+            current,
+            spliceIndex,
+            removed: spliceRemovals,
+            addedCount: spliceAddedCount,
+          ));
+          resetSplice();
+        }
+        index++;
+        oldIndex++;
+        break;
+      case _Edit.update:
+        if (!hasSplice()) {
+          spliceIndex = index;
+        }
+        spliceAddedCount++;
+        index++;
+        spliceRemovals.add(old[oldIndex]);
+        oldIndex++;
+        break;
+      case _Edit.add:
+        if (!hasSplice()) {
+          spliceIndex = index;
+        }
+        spliceAddedCount++;
+        index++;
+        break;
+      case _Edit.delete:
+        if (!hasSplice()) {
+          spliceIndex = index;
+        }
+        spliceRemovals.add(old[oldIndex]);
+        oldIndex++;
+        break;
+    }
+  }
+  if (hasSplice()) {
+    splices.add(new ListChangeRecord<E>(
+      current,
+      spliceIndex,
+      removed: spliceRemovals,
+      addedCount: spliceAddedCount,
+    ));
+  }
+  assert(() {
+    splices = new List<ListChangeRecord/*<E>*/ >.unmodifiable(splices);
+    return true;
+  });
+  return splices;
+}
+
+int _intersect(int start1, int end1, int start2, int end2) {
+  return math.min(end1, end2) - math.max(start1, start2);
+}
+
+void _mergeSplices/*<E>*/(
+  List<ListChangeRecord/*<E>*/ > splices,
+  ListChangeRecord/*<E>*/ record,
+) {
+  var spliceIndex = record.index;
+  var spliceRemoved = record.removed;
+  var spliceAdded = record.addedCount;
+
+  var inserted = false;
+  var insertionOffset = 0;
+
+  // I think the way this works is:
+  // - the loop finds where the merge should happen
+  // - it applies the merge in a particular splice
+  // - then continues and updates the subsequent splices with any offset diff.
+  for (var i = 0; i < splices.length; i++) {
+    var current = splices[i];
+    current = splices[i] = new ListChangeRecord<E>(
+      current.object,
+      current.index + insertionOffset,
+      removed: current.removed,
+      addedCount: current.addedCount,
+    );
+
+    if (inserted) continue;
+
+    var intersectCount = _intersect(
+      spliceIndex,
+      spliceIndex + spliceRemoved.length,
+      current.index,
+      current.index + current.addedCount,
+    );
+    if (intersectCount >= 0) {
+      // Merge the two splices.
+      splices.removeAt(i);
+      i--;
+
+      insertionOffset -= current.addedCount - current.removed.length;
+      spliceAdded += current.addedCount - intersectCount;
+
+      final deleteCount =
+          spliceRemoved.length + current.removed.length - intersectCount;
+      if (spliceAdded == 0 && deleteCount == 0) {
+        // Merged splice is a no-op, discard.
+        inserted = true;
+      } else {
+        final removed = current.removed.toList();
+        if (spliceIndex < current.index) {
+          // Some prefix of splice.removed is prepended to current.removed.
+          removed.insertAll(
+            0,
+            spliceRemoved.getRange(0, current.index - spliceIndex),
+          );
+        }
+        if (spliceIndex + spliceRemoved.length >
+            current.index + current.addedCount) {
+          // Some suffix of splice.removed is appended to current.removed.
+          removed.addAll(spliceRemoved.getRange(
+            current.index + current.addedCount - spliceIndex,
+            spliceRemoved.length,
+          ));
+        }
+        spliceRemoved = removed;
+        if (current.index < spliceIndex) {
+          spliceIndex = current.index;
+        }
+      }
+    } else if (spliceIndex < current.index) {
+      // Insert splice here.
+      inserted = true;
+      splices.insert(
+        i,
+        new ListChangeRecord/*<E>*/(
+          record.object,
+          spliceIndex,
+          removed: spliceRemoved,
+          addedCount: spliceAdded,
+        ),
+      );
+      i++;
+      final offset = spliceAdded - spliceRemoved.length;
+      current = splices[i] = new ListChangeRecord<E>(
+        current.object,
+        current.index + offset,
+        removed: current.removed,
+        addedCount: current.addedCount,
+      );
+      insertionOffset += offset;
+    }
+  }
+  if (!inserted) {
+    splices.add(new ListChangeRecord/*<E>*/(
+      record.object,
+      spliceIndex,
+      removed: spliceRemoved,
+      addedCount: spliceAdded,
+    ));
+  }
+}
+
+List<ListChangeRecord/*<E>*/ > _createInitialSplices/*<E>*/(
+  List/*<E>*/ list,
+  List<ListChangeRecord/*<E>*/ > records,
+) {
+  final splices = <ListChangeRecord/*<E>*/ >[];
+  for (var i = 0; i < records.length; i++) {
+    _mergeSplices(splices, records[i]);
+  }
+  return splices;
+}
+
+// We need to summarize change records. Consumers of these records want to
+// apply the batch sequentially, and ensure that they can find inserted
+// items by looking at that position in the list. This property does not
+// hold in our record-as-you-go records. Consider:
+//
+//     var model = toObservable(['a', 'b']);
+//     model.removeAt(1);
+//     model.insertAll(0, ['c', 'd', 'e']);
+//     model.removeRange(1, 3);
+//     model.insert(1, 'f');
+//
+// Here, we inserted some records and then removed some of them.
+// If someone processed these records naively, they would "play back" the
+// insert incorrectly, because those items will be shifted.
+List<ListChangeRecord/*<E>*/ > projectListSplices/*<E>*/(
+  List/*<E>*/ list,
+  List<ListChangeRecord/*<E>*/ > records, [
+  Equality/*<E>*/ equality = const DefaultEquality/*<E>*/(),
+]) {
+  if (records.length <= 1) return records;
+  final splices = <ListChangeRecord/*<E>*/ >[];
+  final initialSplices = _createInitialSplices(list, records);
+  for (final splice in initialSplices) {
+    if (splice.addedCount == 1 && splice.removed.length == 1) {
+      if (splice.removed[0] != list[splice.index]) {
+        splices.add(splice);
+      }
+      continue;
+    }
+    splices.addAll(
+      _calcSplices(
+        list,
+        equality,
+        splice.index,
+        splice.index + splice.addedCount,
+        splice.removed,
+        0,
+        splice.removed.length,
+      ),
+    );
+  }
+  return splices;
+}
diff --git a/lib/src/list_diff.dart b/lib/src/list_diff.dart
deleted file mode 100644
index c16a613..0000000
--- a/lib/src/list_diff.dart
+++ /dev/null
@@ -1,398 +0,0 @@
-// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
-// for details. All rights reserved. Use of this source code is governed by a
-// BSD-style license that can be found in the LICENSE file.
-
-library observable.src.list_diff;
-
-import 'dart:collection' show UnmodifiableListView;
-import 'dart:math' as math;
-
-import 'change_record.dart' show ChangeRecord;
-
-/// A summary of an individual change to a [List].
-///
-/// Each delta represents that at the [index], [removed] sequence of items were
-/// removed, and counting forward from [index], [addedCount] items were added.
-class ListChangeRecord /* TODO(tvolkert): remove */ extends ChangeRecord {
-  /// The list that changed.
-  final List object;
-
-  /// The index of the change.
-  int get index => _index;
-
-  /// The items removed, if any. Otherwise this will be an empty list.
-  List get removed => _unmodifiableRemoved;
-  UnmodifiableListView _unmodifiableRemoved;
-
-  /// Mutable version of [removed], used during the algorithms as they are
-  /// constructing the object.
-  List _removed;
-
-  /// The number of items added.
-  int get addedCount => _addedCount;
-
-  // Note: conceptually these are final, but for convenience we increment it as
-  // we build the object. It will be "frozen" by the time it is returned the the
-  // user.
-  int _index, _addedCount;
-
-  ListChangeRecord._(this.object, this._index, List removed, this._addedCount)
-      : _removed = removed,
-        _unmodifiableRemoved = new UnmodifiableListView(removed);
-
-  factory ListChangeRecord(List object, int index,
-      {List removed, int addedCount}) {
-    if (removed == null) removed = [];
-    if (addedCount == null) addedCount = 0;
-    return new ListChangeRecord._(object, index, removed, addedCount);
-  }
-
-  /// Returns true if the provided [ref] index was changed by this operation.
-  bool indexChanged(int ref) {
-    // If ref is before the index, then it wasn't changed.
-    if (ref < index) return false;
-
-    // If this was a shift operation, anything after index is changed.
-    if (addedCount != removed.length) return true;
-
-    // Otherwise, anything in the update range was changed.
-    return ref < index + addedCount;
-  }
-
-  String toString() => '#<ListChangeRecord index: $index, '
-      'removed: $removed, addedCount: $addedCount>';
-}
-
-// Note: This function is *based* on the computation of the Levenshtein
-// "edit" distance. The one change is that "updates" are treated as two
-// edits - not one. With List splices, an update is really a delete
-// followed by an add. By retaining this, we optimize for "keeping" the
-// maximum array items in the original array. For example:
-//
-//   'xxxx123' -> '123yyyy'
-//
-// With 1-edit updates, the shortest path would be just to update all seven
-// characters. With 2-edit updates, we delete 4, leave 3, and add 4. This
-// leaves the substring '123' intact.
-List<List<int>> _calcEditDistances(List current, int currentStart,
-    int currentEnd, List old, int oldStart, int oldEnd) {
-  // "Deletion" columns
-  int rowCount = oldEnd - oldStart + 1;
-  int columnCount = currentEnd - currentStart + 1;
-  List<List<int>> distances = new List<List<int>>(rowCount);
-
-  // "Addition" rows. Initialize null column.
-  for (int i = 0; i < rowCount; i++) {
-    distances[i] = new List(columnCount);
-    distances[i][0] = i;
-  }
-
-  // Initialize null row
-  for (int j = 0; j < columnCount; j++) {
-    distances[0][j] = j;
-  }
-
-  for (int i = 1; i < rowCount; i++) {
-    for (int j = 1; j < columnCount; j++) {
-      if (old[oldStart + i - 1] == current[currentStart + j - 1]) {
-        distances[i][j] = distances[i - 1][j - 1];
-      } else {
-        int north = distances[i - 1][j] + 1;
-        int west = distances[i][j - 1] + 1;
-        distances[i][j] = math.min(north, west);
-      }
-    }
-  }
-
-  return distances;
-}
-
-const _kEditLeave = 0;
-const _kEditUpdate = 1;
-const _kEditAdd = 2;
-const _kEditDelete = 3;
-
-// This starts at the final weight, and walks "backward" by finding
-// the minimum previous weight recursively until the origin of the weight
-// matrix.
-List<int> _spliceOperationsFromEditDistances(List<List<int>> distances) {
-  int i = distances.length - 1;
-  int j = distances[0].length - 1;
-  int current = distances[i][j];
-  List<int> edits = <int>[];
-  while (i > 0 || j > 0) {
-    if (i == 0) {
-      edits.add(_kEditAdd);
-      j--;
-      continue;
-    }
-    if (j == 0) {
-      edits.add(_kEditDelete);
-      i--;
-      continue;
-    }
-    int northWest = distances[i - 1][j - 1];
-    int west = distances[i - 1][j];
-    int north = distances[i][j - 1];
-
-    int min = math.min(math.min(west, north), northWest);
-
-    if (min == northWest) {
-      if (northWest == current) {
-        edits.add(_kEditLeave);
-      } else {
-        edits.add(_kEditUpdate);
-        current = northWest;
-      }
-      i--;
-      j--;
-    } else if (min == west) {
-      edits.add(_kEditDelete);
-      i--;
-      current = west;
-    } else {
-      edits.add(_kEditAdd);
-      j--;
-      current = north;
-    }
-  }
-
-  return edits.reversed.toList();
-}
-
-int _sharedPrefix(List arr1, List arr2, int searchLength) {
-  for (int i = 0; i < searchLength; i++) {
-    if (arr1[i] != arr2[i]) {
-      return i;
-    }
-  }
-  return searchLength;
-}
-
-int _sharedSuffix(List arr1, List arr2, int searchLength) {
-  int index1 = arr1.length;
-  int index2 = arr2.length;
-  int count = 0;
-  while (count < searchLength && arr1[--index1] == arr2[--index2]) {
-    count++;
-  }
-  return count;
-}
-
-/// Lacking individual splice mutation information, the minimal set of
-/// splices can be synthesized given the previous state and final state of an
-/// array. The basic approach is to calculate the edit distance matrix and
-/// choose the shortest path through it.
-///
-/// Complexity: O(l * p)
-///   l: The length of the current array
-///   p: The length of the old array
-List<ListChangeRecord> calcSplices(List current, int currentStart,
-    int currentEnd, List old, int oldStart, int oldEnd) {
-  int prefixCount = 0;
-  int suffixCount = 0;
-
-  int minLength = math.min(currentEnd - currentStart, oldEnd - oldStart);
-  if (currentStart == 0 && oldStart == 0) {
-    prefixCount = _sharedPrefix(current, old, minLength);
-  }
-
-  if (currentEnd == current.length && oldEnd == old.length) {
-    suffixCount = _sharedSuffix(current, old, minLength - prefixCount);
-  }
-
-  currentStart += prefixCount;
-  oldStart += prefixCount;
-  currentEnd -= suffixCount;
-  oldEnd -= suffixCount;
-
-  if (currentEnd - currentStart == 0 && oldEnd - oldStart == 0) {
-    return const [];
-  }
-
-  if (currentStart == currentEnd) {
-    ListChangeRecord splice = new ListChangeRecord(current, currentStart);
-    while (oldStart < oldEnd) {
-      splice._removed.add(old[oldStart++]);
-    }
-
-    return [splice];
-  } else if (oldStart == oldEnd) {
-    return [
-      new ListChangeRecord(current, currentStart,
-          addedCount: currentEnd - currentStart)
-    ];
-  }
-
-  List<int> ops = _spliceOperationsFromEditDistances(_calcEditDistances(
-      current, currentStart, currentEnd, old, oldStart, oldEnd));
-
-  ListChangeRecord splice;
-  List<ListChangeRecord> splices = <ListChangeRecord>[];
-  int index = currentStart;
-  int oldIndex = oldStart;
-  for (int i = 0; i < ops.length; i++) {
-    switch (ops[i]) {
-      case _kEditLeave:
-        if (splice != null) {
-          splices.add(splice);
-          splice = null;
-        }
-
-        index++;
-        oldIndex++;
-        break;
-      case _kEditUpdate:
-        if (splice == null) splice = new ListChangeRecord(current, index);
-
-        splice._addedCount++;
-        index++;
-
-        splice._removed.add(old[oldIndex]);
-        oldIndex++;
-        break;
-      case _kEditAdd:
-        if (splice == null) splice = new ListChangeRecord(current, index);
-
-        splice._addedCount++;
-        index++;
-        break;
-      case _kEditDelete:
-        if (splice == null) splice = new ListChangeRecord(current, index);
-
-        splice._removed.add(old[oldIndex]);
-        oldIndex++;
-        break;
-    }
-  }
-
-  if (splice != null) splices.add(splice);
-  return splices;
-}
-
-int _intersect(int start1, int end1, int start2, int end2) =>
-    math.min(end1, end2) - math.max(start1, start2);
-
-void _mergeSplice(List<ListChangeRecord> splices, ListChangeRecord record) {
-  ListChangeRecord splice = new ListChangeRecord(record.object, record.index,
-      removed: record._removed.toList(), addedCount: record.addedCount);
-
-  bool inserted = false;
-  int insertionOffset = 0;
-
-  // I think the way this works is:
-  // - the loop finds where the merge should happen
-  // - it applies the merge in a particular splice
-  // - then continues and updates the subsequent splices with any offset diff.
-  for (int i = 0; i < splices.length; i++) {
-    final ListChangeRecord current = splices[i];
-    current._index += insertionOffset;
-
-    if (inserted) continue;
-
-    int intersectCount = _intersect(
-        splice.index,
-        splice.index + splice.removed.length,
-        current.index,
-        current.index + current.addedCount);
-
-    if (intersectCount >= 0) {
-      // Merge the two splices
-
-      splices.removeAt(i);
-      i--;
-
-      insertionOffset -= current.addedCount - current.removed.length;
-
-      splice._addedCount += current.addedCount - intersectCount;
-      int deleteCount =
-          splice.removed.length + current.removed.length - intersectCount;
-
-      if (splice.addedCount == 0 && deleteCount == 0) {
-        // merged splice is a noop. discard.
-        inserted = true;
-      } else {
-        List removed = current._removed;
-
-        if (splice.index < current.index) {
-          // some prefix of splice.removed is prepended to current.removed.
-          removed.insertAll(
-              0, splice.removed.getRange(0, current.index - splice.index));
-        }
-
-        if (splice.index + splice.removed.length >
-            current.index + current.addedCount) {
-          // some suffix of splice.removed is appended to current.removed.
-          removed.addAll(splice.removed.getRange(
-              current.index + current.addedCount - splice.index,
-              splice.removed.length));
-        }
-
-        splice._removed = removed;
-        splice._unmodifiableRemoved = current._unmodifiableRemoved;
-        if (current.index < splice.index) {
-          splice._index = current.index;
-        }
-      }
-    } else if (splice.index < current.index) {
-      // Insert splice here.
-
-      inserted = true;
-
-      splices.insert(i, splice);
-      i++;
-
-      int offset = splice.addedCount - splice.removed.length;
-      current._index += offset;
-      insertionOffset += offset;
-    }
-  }
-
-  if (!inserted) splices.add(splice);
-}
-
-List<ListChangeRecord> _createInitialSplices(
-    List<Object> list, List<ListChangeRecord> records) {
-  List<ListChangeRecord> splices = [];
-  for (ListChangeRecord record in records) {
-    _mergeSplice(splices, record);
-  }
-  return splices;
-}
-
-/// We need to summarize change records. Consumers of these records want to
-/// apply the batch sequentially, and ensure that they can find inserted
-/// items by looking at that position in the list. This property does not
-/// hold in our record-as-you-go records. Consider:
-///
-///     var model = toObservable(['a', 'b']);
-///     model.removeAt(1);
-///     model.insertAll(0, ['c', 'd', 'e']);
-///     model.removeRange(1, 3);
-///     model.insert(1, 'f');
-///
-/// Here, we inserted some records and then removed some of them.
-/// If someone processed these records naively, they would "play back" the
-/// insert incorrectly, because those items will be shifted.
-List<ListChangeRecord> projectListSplices(
-    List<Object> list, List<ListChangeRecord> records) {
-  if (records.length <= 1) return records;
-
-  List<ListChangeRecord> splices = <ListChangeRecord>[];
-  for (ListChangeRecord splice in _createInitialSplices(list, records)) {
-    if (splice.addedCount == 1 && splice.removed.length == 1) {
-      if (splice.removed[0] != list[splice.index]) splices.add(splice);
-      continue;
-    }
-
-    splices.addAll(calcSplices(
-        list,
-        splice.index,
-        splice.index + splice.addedCount,
-        splice._removed,
-        0,
-        splice.removed.length));
-  }
-
-  return splices;
-}
diff --git a/lib/src/observable.dart b/lib/src/observable.dart
index 7d1ec6f..b76b13f 100644
--- a/lib/src/observable.dart
+++ b/lib/src/observable.dart
@@ -9,8 +9,9 @@
 
 import 'package:meta/meta.dart';
 
-import 'change_record.dart' show ChangeRecord;
+import 'observable_map.dart';
 import 'property_change_record.dart' show PropertyChangeRecord;
+import 'records.dart' show ChangeRecord;
 
 /// Represents an object with observable properties. This is used by data in
 /// model-view architectures to notify interested parties of [changes] to the
diff --git a/lib/src/observable_list.dart b/lib/src/observable_list.dart
index 16c49f5..9e3481b 100644
--- a/lib/src/observable_list.dart
+++ b/lib/src/observable_list.dart
@@ -7,7 +7,8 @@
 import 'dart:async';
 import 'dart:collection' show ListBase, UnmodifiableListView;
 
-import 'list_diff.dart' show ListChangeRecord, projectListSplices, calcSplices;
+import 'differs.dart';
+import 'records.dart';
 import 'observable.dart' show Observable;
 
 /// Represents an observable list of model values. If any items are added,
@@ -79,8 +80,10 @@
 
   bool get hasListObservers => _listChanges != null && _listChanges.hasListener;
 
+  @override
   int get length => _list.length;
 
+  @override
   set length(int value) {
     int len = _list.length;
     if (len == value) return;
@@ -98,8 +101,10 @@
     _list.length = value;
   }
 
+  @override
   E operator [](int index) => _list[index];
 
+  @override
   void operator []=(int index, E value) {
     E oldValue = _list[index];
     if (hasListObservers && oldValue != value) {
@@ -109,7 +114,10 @@
   }
 
   // Forwarders so we can reflect on the properties.
+  @override
   bool get isEmpty => super.isEmpty;
+
+  @override
   bool get isNotEmpty => super.isNotEmpty;
 
   // TODO(jmesserly): should we support first/last/single? They're kind of
@@ -118,7 +126,7 @@
   // existing list notifications.
 
   // The following methods are here so that we can provide nice change events.
-
+  @override
   void setAll(int index, Iterable<E> iterable) {
     if (iterable is! List && iterable is! Set) {
       iterable = iterable.toList();
@@ -131,6 +139,7 @@
     _list.setAll(index, iterable);
   }
 
+  @override
   void add(E value) {
     int len = _list.length;
     _notifyChangeLength(len, len + 1);
@@ -141,6 +150,7 @@
     _list.add(value);
   }
 
+  @override
   void addAll(Iterable<E> iterable) {
     int len = _list.length;
     _list.addAll(iterable);
@@ -153,6 +163,7 @@
     }
   }
 
+  @override
   bool remove(Object element) {
     for (int i = 0; i < this.length; i++) {
       if (this[i] == element) {
@@ -163,6 +174,7 @@
     return false;
   }
 
+  @override
   void removeRange(int start, int end) {
     _rangeCheck(start, end);
     int rangeLength = end - start;
@@ -176,6 +188,7 @@
     _list.removeRange(start, end);
   }
 
+  @override
   void insertAll(int index, Iterable<E> iterable) {
     if (index < 0 || index > length) {
       throw new RangeError.range(index, 0, length);
@@ -201,6 +214,7 @@
     }
   }
 
+  @override
   void insert(int index, E element) {
     if (index < 0 || index > length) {
       throw new RangeError.range(index, 0, length);
@@ -223,6 +237,7 @@
     _list[index] = element;
   }
 
+  @override
   E removeAt(int index) {
     E result = this[index];
     removeRange(index, index + 1);
@@ -238,14 +253,22 @@
     }
   }
 
-  void _notifyListChange(int index, {List removed, int addedCount}) {
+  void _notifyListChange(
+    int index, {
+    List removed: const [],
+    int addedCount: 0,
+  }) {
     if (!hasListObservers) return;
     if (_listRecords == null) {
       _listRecords = [];
       scheduleMicrotask(deliverListChanges);
     }
-    _listRecords.add(new ListChangeRecord(this, index,
-        removed: removed, addedCount: addedCount));
+    _listRecords.add(new ListChangeRecord(
+      this,
+      index,
+      removed: removed,
+      addedCount: addedCount,
+    ));
   }
 
   void _notifyChangeLength(int oldValue, int newValue) {
@@ -261,7 +284,8 @@
 
   bool deliverListChanges() {
     if (_listRecords == null) return false;
-    List<ListChangeRecord> records = projectListSplices(this, _listRecords);
+    List<ListChangeRecord> records =
+        projectListSplices/*<E>*/(this, _listRecords);
     _listRecords = null;
 
     if (hasListObservers && records.isNotEmpty) {
@@ -283,9 +307,12 @@
   ///
   /// Complexity is `O(l * p)` where `l` is the length of the current list and
   /// `p` is the length of the old list.
-  static List<ListChangeRecord> calculateChangeRecords(
-          List<Object> oldValue, List<Object> newValue) =>
-      calcSplices(newValue, 0, newValue.length, oldValue, 0, oldValue.length);
+  static List<ListChangeRecord/*<E>*/ > calculateChangeRecords/*<E>*/(
+    List/*<E>*/ oldValue,
+    List/*<E>*/ newValue,
+  ) {
+    return const ListDiffer<E>().diff(oldValue, newValue);
+  }
 
   /// Updates the [previous] list using the [changeRecords]. For added items,
   /// the [current] list is used to find the current value.
diff --git a/lib/src/observable_map.dart b/lib/src/observable_map.dart
index 23a7f11..c1e2c90 100644
--- a/lib/src/observable_map.dart
+++ b/lib/src/observable_map.dart
@@ -6,9 +6,10 @@
 
 import 'dart:collection';
 
-import 'change_record.dart' show ChangeRecord;
 import 'observable.dart' show Observable;
 import 'property_change_record.dart' show PropertyChangeRecord;
+import 'records.dart' show ChangeRecord;
+import 'to_observable.dart';
 
 // TODO(jmesserly): this needs to be faster. We currently require multiple
 // lookups per key to get the old value.
@@ -50,6 +51,7 @@
         isRemove = true,
         newValue = null;
 
+  @override
   String toString() {
     var kind = isInsert ? 'insert' : isRemove ? 'remove' : 'set';
     return '#<MapChangeRecord $kind $key from: $oldValue to: $newValue>';
@@ -95,22 +97,31 @@
     return result;
   }
 
+  @override
   Iterable<K> get keys => _map.keys;
 
+  @override
   Iterable<V> get values => _map.values;
 
+  @override
   int get length => _map.length;
 
+  @override
   bool get isEmpty => length == 0;
 
+  @override
   bool get isNotEmpty => !isEmpty;
 
+  @override
   bool containsValue(Object value) => _map.containsValue(value);
 
+  @override
   bool containsKey(Object key) => _map.containsKey(key);
 
+  @override
   V operator [](Object key) => _map[key];
 
+  @override
   void operator []=(K key, V value) {
     if (!hasObservers) {
       _map[key] = value;
@@ -132,12 +143,14 @@
     }
   }
 
+  @override
   void addAll(Map<K, V> other) {
     other.forEach((K key, V value) {
       this[key] = value;
     });
   }
 
+  @override
   V putIfAbsent(K key, V ifAbsent()) {
     int len = _map.length;
     V result = _map.putIfAbsent(key, ifAbsent);
@@ -149,6 +162,7 @@
     return result;
   }
 
+  @override
   V remove(Object key) {
     int len = _map.length;
     V result = _map.remove(key);
@@ -160,6 +174,7 @@
     return result;
   }
 
+  @override
   void clear() {
     int len = _map.length;
     if (hasObservers && len > 0) {
@@ -172,8 +187,10 @@
     _map.clear();
   }
 
+  @override
   void forEach(void f(K key, V value)) => _map.forEach(f);
 
+  @override
   String toString() => Maps.mapToString(this);
 
   // Note: we don't really have a reasonable old/new value to use here.
diff --git a/lib/src/property_change_record.dart b/lib/src/property_change_record.dart
index a09c196..153cb5d 100644
--- a/lib/src/property_change_record.dart
+++ b/lib/src/property_change_record.dart
@@ -4,7 +4,8 @@
 
 library observable.src.property_change_record;
 
-import 'change_record.dart';
+import 'observable.dart';
+import 'records.dart' show ChangeRecord;
 
 /// A change record to a field of an [Observable] object.
 class PropertyChangeRecord<T> extends ChangeRecord {
@@ -22,6 +23,7 @@
 
   PropertyChangeRecord(this.object, this.name, this.oldValue, this.newValue);
 
+  @override
   String toString() =>
       '#<PropertyChangeRecord $name from: $oldValue to: $newValue>';
 }
diff --git a/lib/src/records.dart b/lib/src/records.dart
new file mode 100644
index 0000000..a04ae9e
--- /dev/null
+++ b/lib/src/records.dart
@@ -0,0 +1,24 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable.src.records;
+
+import 'package:collection/collection.dart';
+import 'package:quiver/core.dart';
+
+part 'records/list_change_record.dart';
+
+/// Result of a change to an observed object.
+class ChangeRecord {
+  /// Signifies a change occurred, but without details of the specific change.
+  ///
+  /// May be used to produce lower-GC-pressure records where more verbose change
+  /// records will not be used directly.
+  static const List<ChangeRecord> ANY = const [const ChangeRecord()];
+
+  /// Signifies no changes occurred.
+  static const List<ChangeRecord> NONE = const [];
+
+  const ChangeRecord();
+}
diff --git a/lib/src/records/list_change_record.dart b/lib/src/records/list_change_record.dart
new file mode 100644
index 0000000..aeb77d6
--- /dev/null
+++ b/lib/src/records/list_change_record.dart
@@ -0,0 +1,140 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+part of observable.src.records;
+
+List/*<E>*/ _freezeInDevMode/*<E>*/(List/*<E>*/ list) {
+  if (list == null) return const [];
+  assert(() {
+    list = new List/*<E>*/ .unmodifiable(list);
+    return true;
+  });
+  return list;
+}
+
+/// A [ChangeRecord] that denotes adding or removing nodes at [index].
+///
+/// It should be assumed that elements are [removed] *before* being added.
+///
+/// A [List<ListChangeRecord>] can be "played back" against the [List] using
+/// the final list positions to figure out which item was added - this removes
+/// the need to incur costly GC on the most common operation (adding).
+class ListChangeRecord<E> implements ChangeRecord {
+  /// How many elements were added at [index] (after removing elements).
+  final int addedCount;
+
+  /// Index of where the change occurred.
+  final int index;
+
+  /// List that changed.
+  final List<E> object;
+
+  /// Elements that were removed starting at [index] (before adding elements).
+  final List<E> removed;
+
+  factory ListChangeRecord(
+    List<E> object,
+    int index, {
+    List<E> removed: const [],
+    int addedCount: 0,
+  }) {
+    return new ListChangeRecord._(object, index, removed, addedCount);
+  }
+
+  /// Records an `add` operation at `object[index]` of [addedCount] elements.
+  ListChangeRecord.add(this.object, this.index, this.addedCount)
+      : removed = const [] {
+    _assertValidState();
+  }
+
+  /// Records a `remove` operation at `object[index]` of [removed] elements.
+  ListChangeRecord.remove(this.object, this.index, List<E> removed)
+      : this.removed = _freezeInDevMode/*<E>*/(removed),
+        this.addedCount = 0 {
+    _assertValidState();
+  }
+
+  /// Records a `replace` operation at `object[index]` of [removed] elements.
+  ///
+  /// If [addedCount] is not specified it defaults to `removed.length`.
+  ListChangeRecord.replace(this.object, this.index, List<E> removed,
+      [int addedCount])
+      : this.removed = _freezeInDevMode/*<E>*/(removed),
+        this.addedCount = addedCount ?? removed.length {
+    _assertValidState();
+  }
+
+  ListChangeRecord._(
+    this.object,
+    this.index,
+    this.removed,
+    this.addedCount,
+  ) {
+    _assertValidState();
+  }
+
+  /// What elements were added to [object].
+  Iterable<E> get added {
+    return addedCount == 0 ? const [] : object.getRange(index, addedCount);
+  }
+
+  /// Apply this change record to [list].
+  void apply(List<E> list) {
+    list
+      ..removeRange(index, index + removed.length)
+      ..insertAll(index, object.getRange(index, index + addedCount));
+  }
+
+  void _assertValidState() {
+    assert(() {
+      if (object == null) {
+        throw new ArgumentError.notNull('object');
+      }
+      if (index == null) {
+        throw new ArgumentError.notNull('index');
+      }
+      if (removed == null) {
+        throw new ArgumentError.notNull('removed');
+      }
+      if (addedCount == null || addedCount < 0) {
+        throw new ArgumentError('Invalid `addedCount`: $addedCount');
+      }
+      return true;
+    });
+  }
+
+  /// Returns whether [reference] index was changed in this operation.
+  bool indexChanged(int reference) {
+    // If reference was before the change then it wasn't changed.
+    if (reference < index) return false;
+
+    // If this was a shift operation anything after index is changed.
+    if (addedCount != removed.length) return true;
+
+    // Otherwise anything in the update range was changed.
+    return reference < index + addedCount;
+  }
+
+  @override
+  bool operator ==(Object o) {
+    if (o is ListChangeRecord<E>) {
+      return identical(object, o.object) &&
+          index == o.index &&
+          addedCount == o.addedCount &&
+          const ListEquality().equals(removed, o.removed);
+    }
+    return false;
+  }
+
+  @override
+  int get hashCode {
+    return hash4(object, index, addedCount, const ListEquality().hash(removed));
+  }
+
+  @override
+  String toString() => ''
+      '#<$ListChangeRecord index: $index, '
+      'removed: $removed, '
+      'addedCount: $addedCount>';
+}
diff --git a/lib/src/to_observable.dart b/lib/src/to_observable.dart
index 0a0f316..3bf24b6 100644
--- a/lib/src/to_observable.dart
+++ b/lib/src/to_observable.dart
@@ -4,6 +4,8 @@
 
 library observable.src.to_observable;
 
+import 'dart:collection';
+
 import 'observable.dart' show Observable;
 import 'observable_list.dart' show ObservableList;
 import 'observable_map.dart' show ObservableMap;
diff --git a/pubspec.yaml b/pubspec.yaml
index b030500..edb5d55 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,11 +1,13 @@
 name: observable
-version: 0.14.0+1
+version: 0.15.0
 author: Dart Team <misc@dartlang.org>
 description: Support for marking objects as observable
 homepage: https://github.com/dart-lang/observable
 environment:
   sdk: '>=1.19.0 <2.0.0'
 dependencies:
+  collection: '^1.11.0'
   meta: '^1.0.4'
+  quiver: '^0.24.0'
 dev_dependencies:
   test: '^0.12.0'
diff --git a/test/list_change_test.dart b/test/list_change_test.dart
index b07c0b8..2c7446e 100644
--- a/test/list_change_test.dart
+++ b/test/list_change_test.dart
@@ -20,7 +20,7 @@
   var model;
 
   tearDown(() {
-    sub.cancel();
+    sub?.cancel();
     model = null;
   });
 
@@ -47,7 +47,6 @@
     sub = model.listChanges.listen((r) => summary = r);
 
     model.length = 2;
-
     return new Future(() {
       expectChanges(summary, [
         _delta(2, ['c', 'd', 'e'], 0)
@@ -62,14 +61,10 @@
   group('List deltas can be applied', () {
     applyAndCheckDeltas(model, copy, changes) => changes.then((summary) {
           // apply deltas to the copy
-          for (var delta in summary) {
-            copy.removeRange(delta.index, delta.index + delta.removed.length);
-            for (int i = delta.addedCount - 1; i >= 0; i--) {
-              copy.insert(delta.index, model[delta.index + i]);
-            }
+          for (ListChangeRecord delta in summary) {
+            delta.apply(copy);
           }
 
-          // Note: compare strings for easier debugging.
           expect('$copy', '$model', reason: 'summary $summary');
         });
 
diff --git a/test/observable_test.dart b/test/observable_test.dart
index b89d654..eb824c7 100644
--- a/test/observable_test.dart
+++ b/test/observable_test.dart
@@ -218,5 +218,6 @@
 
   T _value;
 
+  @override
   String toString() => '#<$runtimeType value: $value>';
 }
diff --git a/test/observable_test_utils.dart b/test/observable_test_utils.dart
index 91d4d8f..990cc86 100644
--- a/test/observable_test_utils.dart
+++ b/test/observable_test_utils.dart
@@ -13,8 +13,6 @@
 /// to happen in the next microtask:
 ///
 ///     future.then(newMicrotask).then(...)
-///
-/// Uses [mu].
 newMicrotask(_) => new Future.value();
 
 // TODO(jmesserly): use matchers when we have a way to compare ChangeRecords.