Skip to content

Commit

Permalink
Reduce time to render contacts list through live-list
Browse files Browse the repository at this point in the history
Performance optimizations for the Live-List class as-used in the contacts tab.

Contact controller currently calls livelist.update once per list item and then livelist.refresh after all items are added.

This change updates the interface livelist.set to avoid the incremental dom changes from livelist.update and then the full re-paint from livelist.refresh. The proposed change inserts the items each item into the model once, sorts the full list once, and then draws everything once. To avoid unnecessary scanning, update livelist.dom to a hashmap. livelist.set() accepts an optional flag reuseExistingDom which can avoid expensive calls to listItemFor when the contents of the DOM are being reused and not updated (eg. pagination).

#4445
  • Loading branch information
kennsippell authored and dianabarsan committed Dec 10, 2018
1 parent 0d309cc commit 0abf57a
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 96 deletions.
39 changes: 30 additions & 9 deletions webapp/src/js/controllers/contacts.js
Expand Up @@ -7,13 +7,11 @@ var _ = require('underscore'),
var inboxControllers = angular.module('inboxControllers');

inboxControllers.controller('ContactsCtrl', function(
$element,
$log,
$q,
$scope,
$state,
$stateParams,
$timeout,
$translate,
Auth,
Changes,
Expand Down Expand Up @@ -51,7 +49,10 @@ var _ = require('underscore'),
var _initScroll = function() {
scrollLoader.init(function() {
if (!$scope.loading && $scope.moreItems) {
_query({ skip: true });
_query({
paginating: true,
reuseExistingDom: true,
});
}
});
};
Expand All @@ -65,7 +66,7 @@ var _ = require('underscore'),
$scope.error = false;
}

if (options.skip) {
if (options.paginating) {
$scope.appending = true;
options.skip = liveList.count();
} else if (!options.silent) {
Expand Down Expand Up @@ -146,8 +147,11 @@ var _ = require('underscore'),
$scope.moreItems = liveList.moreItems =
contacts.length >= options.limit;

contacts.forEach(liveList.update);
liveList.refresh();
const mergedList = options.paginating ?
_.uniq(contacts.concat(liveList.getList()), false, _.property('_id'))
: contacts;
liveList.set(mergedList, !!options.reuseExistingDom);

_initScroll();
$scope.loading = false;
$scope.appending = false;
Expand Down Expand Up @@ -430,16 +434,33 @@ var _ = require('underscore'),
var changeListener = Changes({
key: 'contacts-list',
callback: function(change) {
var limit = liveList.count();
const limit = liveList.count();
if (change.deleted && change.doc.type !== 'data_record') {
liveList.remove(change.doc);
}

var withIds =
if (change.doc) {
liveList.invalidateCache(change.doc._id);

// Invalidate the contact for changing reports with visited_contact_uuid
if (change.doc.fields) {
liveList.invalidateCache(change.doc.fields.visited_contact_uuid);
}
}

const withIds =
isSortedByLastVisited() &&
!!isRelevantVisitReport(change.doc) &&
!change.deleted;
return _query({ limit: limit, silent: true, withIds: withIds });
return _query({
limit,
withIds,
silent: true,

// The logic for updating Primary Contact changes is complex
// So redraw everything when a person changes
reuseExistingDom: change.doc && change.doc.type !== 'person',
});
},
filter: function(change) {
return (
Expand Down
116 changes: 57 additions & 59 deletions webapp/src/js/services/live-list.js
Expand Up @@ -328,9 +328,7 @@ angular.module('inboxServices').factory('LiveList',
var activeDom = $(idx.selector);
if(activeDom.length) {
activeDom.empty();
_.each(idx.dom, function(li) {
activeDom.append(li);
});
appendDomWithListOrdering(activeDom, idx);
ResourceIcons.replacePlaceholders(activeDom);
}
}
Expand All @@ -340,45 +338,41 @@ angular.module('inboxServices').factory('LiveList',
return idx.list && idx.list.length;
}

function _set(listName, items) {
var i, len,
idx = indexes[listName];

/*
reuseExistingDom is a performance optimization wherein live-list can rely on the changes feed to
specifically update dom elements (via update/remove interfaces) making it safe to re-use existing dom
elements for certain scenarios
*/
function _set(listName, items, reuseExistingDom) {
const idx = indexes[listName];
if (!idx) {
throw new Error('LiveList not configured for: ' + listName);
}

idx.lastUpdate = new Date();

// TODO we should sort the list in place with a suitable, efficient algorithm
idx.list = [];
idx.dom = [];
for (i=0, len=items.length; i<len; ++i) {
_insert(listName, items[i], true);
idx.list = items.sort(idx.orderBy);
const newDom = {};
for (let i = 0; i < items.length; ++i) {
const item = items[i];
const useCache = reuseExistingDom && idx.dom[item._id] && !idx.dom[item._id].invalidateCache;
const li = useCache ? idx.dom[item._id] : listItemFor(idx, item);
newDom[item._id] = li;
}

$(idx.selector)
.empty()
.append(idx.dom);
idx.dom = newDom;

_refresh(listName);
}

function _initialised(listName) {
return !!indexes[listName].list;
}

function _contains(listName, item) {
var i, list = indexes[listName].list;

if (!list) {
if (!indexes[listName].list) {
return false;
}

for(i=list.length-1; i>=0; --i) {
if(list[i]._id === item._id) {
return true;
}
}
return false;
return !!indexes[listName].dom[item._id];
}

function _insert(listName, newItem, skipDomAppend, removedDomElement) {
Expand All @@ -392,7 +386,7 @@ angular.module('inboxServices').factory('LiveList',

var newItemIndex = findSortedIndex(idx.list, newItem, idx.orderBy);
idx.list.splice(newItemIndex, 0, newItem);
idx.dom.splice(newItemIndex, 0, li);
idx.dom[newItem._id] = li;

if (skipDomAppend) {
return;
Expand All @@ -410,6 +404,15 @@ angular.module('inboxServices').factory('LiveList',
}
}

function _invalidateCache(listName, id) {
const idx = indexes[listName];
if (!idx || !idx.dom || !id || !idx.dom[id]) {
return;
}

idx.dom[id].invalidateCache = true;
}

function _update(listName, updatedItem) {
var removed = _remove(listName, updatedItem);
_insert(listName, updatedItem, false, removed);
Expand All @@ -431,55 +434,44 @@ angular.module('inboxServices').factory('LiveList',
}
if (removeIndex !== null) {
idx.list.splice(removeIndex, 1);
var removed = idx.dom.splice(removeIndex, 1);
const removed = idx.dom[removedItem._id];
delete idx.dom[removedItem._id];

$(idx.selector).children().eq(removeIndex).remove();
if (removed.length) {
return removed[0];
}
return removed;
}
}

function _setSelected(listName, _id) {
var i, len, doc,
idx = indexes[listName],
list = idx.list,
previous = idx.selected;
const idx = indexes[listName],
previous = idx.selected;

idx.selected = _id;

if (!list) {
if (!idx.list) {
return;
}

for (i=0, len=list.length; i<len; ++i) {
doc = list[i];
if (doc._id === previous) {
idx.dom[i]
.removeClass('selected');
}
if (doc._id === _id) {
idx.dom[i]
.addClass('selected');
}
if (previous && idx.dom[previous]) {
idx.dom[previous].removeClass('selected');
}

if (idx.dom[_id]) {
idx.dom[_id].addClass('selected');
}
}

function _clearSelected(listName) {
var i, len,
idx = indexes[listName],
list = idx.list,
previous = idx.selected;
const idx = indexes[listName];

if (!list || !previous) {
if (!idx.list || !idx.selected) {
return;
}

for (i=0, len=list.length; i<len; ++i) {
if (list[i]._id === previous) {
idx.dom[i].removeClass('selected');
}
if (idx.dom[idx.selected]) {
idx.dom[idx.selected].removeClass('selected');
}

delete idx.selected;
}

Expand All @@ -499,19 +491,19 @@ angular.module('inboxServices').factory('LiveList',
}

function refreshAll() {
var i, now = new Date();
const now = new Date();

_.forEach(indexes, function(idx, name) {
// N.B. no need to update a list that's never been generated
if (idx.lastUpdate && !sameDay(idx.lastUpdate, now)) {
// regenerate all list contents so relative dates relate to today
// instead of yesterday
for (i=idx.list.length-1; i>=0; --i) {
idx.dom[i] = listItemFor(idx, idx.list[i]);
for (let i = 0; i < idx.list.length; ++i) {
const item = idx.list[i];
idx.dom[item._id] = listItemFor(idx, item);
}

api[name].refresh();

idx.lastUpdate = now;
}
});
Expand All @@ -534,6 +526,11 @@ angular.module('inboxServices').factory('LiveList',
return midnight.getTime() - now.getTime();
}

function appendDomWithListOrdering(activeDom, idx) {
const orderedDom = idx.list.map(item => idx.dom[item._id]);
activeDom.append(orderedDom);
}

$timeout(refreshAll, millisTilMidnight(new Date()));

api.$listFor = function(name, config) {
Expand All @@ -543,6 +540,7 @@ angular.module('inboxServices').factory('LiveList',

api[name] = {
insert: _.partial(_insert, name),
invalidateCache: _.partial(_invalidateCache, name),
update: _.partial(_update, name),
remove: _.partial(_remove, name),
getList: _.partial(_getList, name),
Expand Down

0 comments on commit 0abf57a

Please sign in to comment.