Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

fix(ngRepeat): trigger move animation #15072

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
160 changes: 76 additions & 84 deletions src/ng/directive/ngRepeat.js
Expand Up @@ -338,7 +338,6 @@
</example>
*/
var ngRepeatDirective = ['$parse', '$animate', '$compile', function($parse, $animate, $compile) {
var NG_REMOVED = '$$NG_REMOVED';
var ngRepeatMinErr = minErr('ngRepeat');

var updateScope = function(scope, index, valueIdentifier, value, keyIdentifier, key, arrayLength) {
Expand All @@ -353,15 +352,10 @@ var ngRepeatDirective = ['$parse', '$animate', '$compile', function($parse, $ani
scope.$odd = !(scope.$even = (index & 1) === 0);
};

var getBlockStart = function(block) {
return block.clone[0];
};

var getBlockEnd = function(block) {
return block.clone[block.clone.length - 1];
};


return {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getBlockStart function is no longer used.

restrict: 'A',
multiElement: true,
Expand Down Expand Up @@ -438,126 +432,124 @@ var ngRepeatDirective = ['$parse', '$animate', '$compile', function($parse, $ani

//watch props
$scope.$watchCollection(rhs, function ngRepeatAction(collection) {
var index, length,
previousNode = $element[0], // node that cloned nodes should be inserted after
// initialized to the comment node anchor
nextNode,
// Same as lastBlockMap but it has the current state. It will become the
// lastBlockMap on the next iteration.
nextBlockMap = createMap(),
collectionLength,
key, value, // key/value of iteration
trackById,
trackByIdFn,
collectionKeys,
block, // last object information {scope, element, id}
nextBlockOrder,
elementsToRemove;
var
block, // last object information {scope, element, id}
collectionIsLikeArray,
collectionKeys = [],
elementsToRemove,
index, key, value,
lastBlockOrder = [],
lastKey,
nextBlockMap = createMap(),
nextBlockOrder = [],
nextKey,
nextLength,
previousNode = $element[0], // node that cloned nodes should be inserted after
// initialized to the comment node anchor
trackById,
trackByIdFn;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

variables are defined in alphabetical order (mostly)


if (aliasAs) {
$scope[aliasAs] = collection;
}

if (isArrayLike(collection)) {
collectionKeys = collection;
// get collectionKeys
collectionIsLikeArray = isArrayLike(collection);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is faster to check the collectionIsLikeArray boolean than to compare value and type of collectionKeys and collection inside a loop.

if (collectionIsLikeArray) {
trackByIdFn = trackByIdExpFn || trackByIdArrayFn;
} else {
trackByIdFn = trackByIdExpFn || trackByIdObjFn;
// if object, extract keys, in enumeration order, unsorted
collectionKeys = [];
for (var itemKey in collection) {
if (hasOwnProperty.call(collection, itemKey) && itemKey.charAt(0) !== '$') {
collectionKeys.push(itemKey);
for (key in collection) {
if (hasOwnProperty.call(collection, key) && key.charAt(0) !== '$') {
collectionKeys.push(key);
}
}
}
nextLength = collectionIsLikeArray ? collection.length : collectionKeys.length;

collectionLength = collectionKeys.length;
nextBlockOrder = new Array(collectionLength);

// locate existing items
for (index = 0; index < collectionLength; index++) {
key = (collection === collectionKeys) ? index : collectionKeys[index];
// setup nextBlockMap
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "setup nextBlockMap" loop has a similar purpose to the "locate existing items" loop in the previous version. It does not delete previously seen blocks from lastBlockMap. Therefore, lastBlockMap does not need to be restored if a duplicate block is detected.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation of the comment here is wrong

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Narretz Is it? Looks good to me.

for (index = 0; index < nextLength; index++) {
key = collectionIsLikeArray ? index : collectionKeys[index];
value = collection[key];
trackById = trackByIdFn(key, value, index);
if (lastBlockMap[trackById]) {
// found previously seen block
block = lastBlockMap[trackById];
delete lastBlockMap[trackById];
nextBlockMap[trackById] = block;
nextBlockOrder[index] = block;
} else if (nextBlockMap[trackById]) {
// if collision detected. restore lastBlockMap and throw an error
forEach(nextBlockOrder, function(block) {
if (block && block.scope) lastBlockMap[block.id] = block;
});

if (nextBlockMap[trackById]) {
// if collision detected, throw an error
throw ngRepeatMinErr('dupes',
'Duplicates in a repeater are not allowed. Use \'track by\' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}, Duplicate value: {2}',
expression, trackById, value);
} else {
// new never before seen block
nextBlockOrder[index] = {id: trackById, scope: undefined, clone: undefined};
nextBlockMap[trackById] = true;
'Duplicates in a repeater are not allowed. Use \'track by\' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}, Duplicate value: {2}',
expression, trackById, value);
}
}

// remove leftover items
for (var blockKey in lastBlockMap) {
block = lastBlockMap[blockKey];
elementsToRemove = getBlockNodes(block.clone);
$animate.leave(elementsToRemove);
if (elementsToRemove[0].parentNode) {
// if the element was not removed yet because of pending animation, mark it as deleted
// so that we can ignore it later
for (index = 0, length = elementsToRemove.length; index < length; index++) {
elementsToRemove[index][NG_REMOVED] = true;
}
}
block.scope.$destroy();
nextBlockMap[trackById] = {id: trackById, clone: undefined, scope: undefined, index: index};
nextBlockOrder[index] = trackById;
}

// we are not using forEach for perf reasons (trying to avoid #call)
for (index = 0; index < collectionLength; index++) {
key = (collection === collectionKeys) ? index : collectionKeys[index];
value = collection[key];
block = nextBlockOrder[index];
// Set up lastBlockOrder. Used to determine if a block moved.
for (key in lastBlockMap) {
lastBlockOrder[lastBlockMap[key].index] = key;
}

if (block.scope) {
// if we have already seen this object, then we need to reuse the
// associated scope/element
for (index = 0; index < nextLength; index++) {
key = collectionIsLikeArray ? index : collectionKeys[index];
nextKey = nextBlockOrder[index];

nextNode = previousNode;
if (lastBlockMap[nextKey]) {
// we have already seen this object and need to reuse the associated scope/element
block = lastBlockMap[nextKey];

// skip nodes that are already pending removal via leave animation
do {
nextNode = nextNode.nextSibling;
} while (nextNode && nextNode[NG_REMOVED]);
// move
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic of when to move and where to move is a key change in this pull request.

if (lastBlockMap[nextKey].index !== nextBlockMap[nextKey].index) {
// If this block has moved because the last previous block was removed,
// then use the last previous block to set previousNode.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop removes elements and now occurs after the "enter and move" loop.

lastKey = lastBlockOrder[lastBlockMap[nextKey].index - 1];
if (lastKey && !nextBlockMap[lastKey]) {
previousNode = getBlockEnd(lastBlockMap[lastKey]);
}

if (getBlockStart(block) !== nextNode) {
// existing item which got moved
$animate.move(getBlockNodes(block.clone), null, previousNode);
block.index = nextBlockMap[nextKey].index;
}

updateScope(block.scope, index, valueIdentifier, collection[key], keyIdentifier, key, nextLength);

nextBlockMap[nextKey] = block;
previousNode = getBlockEnd(block);
updateScope(block.scope, index, valueIdentifier, value, keyIdentifier, key, collectionLength);

} else {
// enter
// new item which we don't know about
$transclude(function ngRepeatTransclude(clone, scope) {
block.scope = scope;
nextBlockMap[nextKey].scope = scope;
// http://jsperf.com/clone-vs-createcomment
var endNode = ngRepeatEndComment.cloneNode(false);
clone[clone.length++] = endNode;

$animate.enter(clone, null, previousNode);
previousNode = endNode;

// Note: We only need the first/last node of the cloned nodes.
// However, we need to keep the reference to the jqlite wrapper as it might be changed later
// by a directive with templateUrl when its template arrives.
block.clone = clone;
nextBlockMap[block.id] = block;
updateScope(block.scope, index, valueIdentifier, value, keyIdentifier, key, collectionLength);
nextBlockMap[nextKey].clone = clone;
updateScope(scope, nextBlockMap[nextKey].index, valueIdentifier, collection[key], keyIdentifier, key, nextLength);
});
}
}

// leave
// This must go after enter and move because leave prevents getting element's parent.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain this further? The leave animation section seems to have changed quite a bit from the original. Is this necessary for this PR?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main change is there no need to keep track of deleted elements because we aren't doing any more processing where we might need to ignore the element. The other change is to skip blocks that don't need removed.

for (key in lastBlockMap) {
if (nextBlockMap[key]) {
continue;
}

block = lastBlockMap[key];
elementsToRemove = getBlockNodes(block.clone);
$animate.leave(elementsToRemove);
block.scope.$destroy();
}

lastBlockMap = nextBlockMap;
});
};
Expand Down
109 changes: 108 additions & 1 deletion test/ng/directive/ngRepeatSpec.js
Expand Up @@ -1488,7 +1488,13 @@ describe('ngRepeat animations', function() {
$rootScope.items = ['1','3'];
$rootScope.$digest();

item = $animate.queue.shift();
while ($animate.queue.length) {
item = $animate.queue.shift();
if (item.event === 'leave') {
break;
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment to explain why this is needed would be helpful.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am going to revert this code. The ngRepeat tests assume the animation queue is in a certain order. During refactoring of ngRepeat, the order was different in this test so I altered the test. My latest version of ngRepeat, c969ae2, passes the original version of this test.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should tests assume that animation events are queued in a certain order? If not, then perhaps the queue should be tested like this:

// Test each queue item's event type
while ($animate.queue.length) {
  item = $animate.queue.shift();
  switch (item.element.text()) {
    case '2':
      expect(item.event).toBe('leave');
      break;
    case '3':
      expect(item.event).toBe('move');
      break;
    default:
      expect(item).toBeUndefined();
  }
}

Maybe we should add a Jasmine matcher in the "ngRepeat Animations" block that compares item.event and item.text() values between two arrays? Should this be new GitHub issue?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently, the animation queue order IS different than it used to be.
Options:

  1. use a while loop to search for the leave event, as in commit 2331c42
  2. shift the queue array a second time, assuming that the leave event is the second item in the queue
  3. add a Jasmine matcher that searches the queue for a item with a specific element text and event

As far as I can tell, tests should not assume the order of events in the queue. So I propose the following matcher be added to the 'ngRepeat animations' group of tests:

beforeEach(function() {
  jasmine.addMatchers({
    toContainQueueItem: function() {
      return {
        compare: generateCompare(false),
        negativeCompare: generateCompare(true)
      };
      function generateCompare(isNot) {
        /**
         * Matcher that checks that the expected element text is in the actual Array of
         * animation queue items and that the event matches.
         * @param {array} actual
         * @param {string} expectedElementText
         * @param {string} expectedEvent optional if isNot = true
         * @returns {{pass: boolean, message: message}}
         */
        return function(actual, expectedElementText, expectedEvent) {
          if (expectedEvent === undefined) {
            expectedEvent = '';
          }
          var actualLength = actual.length;
          var i;
          var item;
          var message = valueFn(
            'Expected animation queue to ' + (isNot ? 'not ' : '') + 'contain an item where the element\'s text is "'
            + expectedElementText + '"' + (isNot ? '' : ' and the event is "' + expectedEvent + '"')
          );
          var pass = isNot;

          for (i = 0; i < actualLength; i++) {
            item = actual[i];
            if (item.element.text() === expectedElementText) {
              pass = item.event === expectedEvent;
              break;
            }
          }

          return {'pass': pass, 'message': message};
        };
      }
    }
  });
});

The tests should use the matcher like this:

expect($animate.queue).not.toContainQueueItem('1');
expect($animate.queue).toContainQueueItem('2', 'leave');
expect($animate.queue).toContainQueueItem('3', 'move');
$animate.queue = [];

What does everyone think?


expect(item.event).toBe('leave');
expect(item.element.text()).toBe('2');
}));
Expand Down Expand Up @@ -1566,6 +1572,107 @@ describe('ngRepeat animations', function() {
item = $animate.queue.shift();
expect(item.event).toBe('move');
expect(item.element.text()).toBe('3');

item = $animate.queue.shift();
expect(item.event).toBe('move');
expect(item.element.text()).toBe('1');
})
);

it('should fire off the move animation for filtered items',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description could be more clear. Perhaps:

"should fire the move animation for items that shifted due to removal of previous items"

Or something like that.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this description?
should fire off the move animation for items that change position when other items are filtered out

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

inject(function($compile, $rootScope, $animate) {

var item;

element = $compile(html(
'<div>' +
'<div ng-repeat="item in items | filter:search">' +
'{{ item }}' +
'</div>' +
'</div>'
))($rootScope);

$rootScope.items = ['1','2','3'];
$rootScope.search = '';
$rootScope.$digest();

item = $animate.queue.shift();
expect(item.event).toBe('enter');
expect(item.element.text()).toBe('1');

item = $animate.queue.shift();
expect(item.event).toBe('enter');
expect(item.element.text()).toBe('2');

item = $animate.queue.shift();
expect(item.event).toBe('enter');
expect(item.element.text()).toBe('3');

$rootScope.search = '3';
$rootScope.$digest();

item = $animate.queue.shift();
expect(item.event).toBe('move');
expect(item.element.text()).toBe('3');

item = $animate.queue.shift();
expect(item.event).toBe('leave');
expect(item.element.text()).toBe('1');

item = $animate.queue.shift();
expect(item.event).toBe('leave');
expect(item.element.text()).toBe('2');
})
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put a newline between the two its

it('should maintain the order when the track by expression evaluates to an integer',
inject(function($compile, $rootScope, $animate, $document, $sniffer, $timeout) {
if (!$sniffer.transitions) return;

var item;
var ss = createMockStyleSheet($document);

var items = [
{id: 1, name: 'A'},
{id: 2, name: 'B'},
{id: 4, name: 'C'},
{id: 3, name: 'D'}
];

try {

$animate.enabled(true);

ss.addRule('.animate-me div',
'transition:1s linear all;');

element = $compile(html('<div class="animate-me">' +
'<div ng-repeat="item in items track by item.id">{{ item.name }}</div>' +
'</div>'))($rootScope);

$rootScope.items = [items[0], items[1], items[2]];
$rootScope.$digest();
expect(element.text()).toBe('ABC');

$rootScope.items.push(items[3]);
$rootScope.$digest();

expect(element.text()).toBe('ABCD'); // the original order should be preserved
$animate.flush();
$timeout.flush(1500); // 1s * 1.5 closing buffer
expect(element.text()).toBe('ABCD');

$rootScope.items = [items[0], items[1], items[3]];
$rootScope.$digest();

// The leaving item should maintain it's position until it is removed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's -> its (I know, I added this test ;))

expect(element.text()).toBe('ABCD');
$animate.flush();
$timeout.flush(1500); // 1s * 1.5 closing buffer
expect(element.text()).toBe('ABD');

} finally {
ss.destroy();
}
})
);
});