Commit 47d6c81e authored by Robert Knight's avatar Robert Knight Committed by Nick Stenning

Make thread building more idiomatic by using memoization (#3436)

The idiomatic way to create derived data structures from data in the
Redux state store is to do:

  derivedData = transform(select(store.getState()))

Where the `select` function extracts the relevant fields from the state
and the `transform` function then computes the derived data. Both
`select` and `transform` can be trivially memoized to avoid unnecessary
recalculations.

This simplifies `root-thread` by avoiding inheritance from EventEmitter
and in future will make it easier to avoid rebuilding the thread if none
of the relevant application state has changed.
parent 21bd9ddb
...@@ -17,18 +17,18 @@ function AnnotationViewerController ( ...@@ -17,18 +17,18 @@ function AnnotationViewerController (
$location.path('/stream').search('q', query); $location.path('/stream').search('q', query);
}; };
rootThread.on('changed', function (thread) { function thread() {
return rootThread.thread(annotationUI.getState());
}
annotationUI.subscribe(function () {
$scope.virtualThreadList = { $scope.virtualThreadList = {
visibleThreads: thread.children, visibleThreads: thread().children,
offscreenUpperHeight: '0px', offscreenUpperHeight: '0px',
offscreenLowerHeight: '0px', offscreenLowerHeight: '0px',
}; };
}); });
$scope.rootThread = function () {
return rootThread.thread();
};
$scope.setCollapsed = function (id, collapsed) { $scope.setCollapsed = function (id, collapsed) {
annotationUI.setCollapsed(id, collapsed); annotationUI.setCollapsed(id, collapsed);
}; };
......
'use strict'; 'use strict';
var EventEmitter = require('tiny-emitter');
var inherits = require('inherits');
var buildThread = require('./build-thread'); var buildThread = require('./build-thread');
var events = require('./events'); var events = require('./events');
var memoize = require('./util/memoize');
var metadata = require('./annotation-metadata'); var metadata = require('./annotation-metadata');
function truthyKeys(map) { function truthyKeys(map) {
...@@ -41,19 +39,18 @@ var sortFns = { ...@@ -41,19 +39,18 @@ var sortFns = {
*/ */
// @ngInject // @ngInject
function RootThread($rootScope, annotationUI, searchFilter, viewFilter) { function RootThread($rootScope, annotationUI, searchFilter, viewFilter) {
var self = this;
var thread;
/** /**
* Rebuild the root conversation thread. This should be called * Build the root conversation thread from the given UI state.
* whenever the set of annotations to render or the sort/search/filter *
* settings change. * @param state - The current UI state (loaded annotations, sort mode,
* filter settings etc.)
*/ */
function rebuildRootThread() { function buildRootThread(state) {
var sortFn = sortFns[annotationUI.getState().sortKey]; var sortFn = sortFns[state.sortKey];
var filters; var filters;
var filterQuery = annotationUI.getState().filterQuery; var filterQuery = state.filterQuery;
if (filterQuery) { if (filterQuery) {
filters = searchFilter.generateFacetedFilter(filterQuery); filters = searchFilter.generateFacetedFilter(filterQuery);
...@@ -68,18 +65,14 @@ function RootThread($rootScope, annotationUI, searchFilter, viewFilter) { ...@@ -68,18 +65,14 @@ function RootThread($rootScope, annotationUI, searchFilter, viewFilter) {
// Get the currently loaded annotations and the set of inputs which // Get the currently loaded annotations and the set of inputs which
// determines what is visible and build the visible thread structure // determines what is visible and build the visible thread structure
var state = annotationUI.getState(); return buildThread(state.annotations, {
thread = buildThread(state.annotations, {
forceVisible: truthyKeys(state.forceVisible), forceVisible: truthyKeys(state.forceVisible),
expanded: state.expanded, expanded: state.expanded,
selected: truthyKeys(state.selectedAnnotationMap || {}), selected: truthyKeys(state.selectedAnnotationMap || {}),
sortCompareFn: sortFn, sortCompareFn: sortFn,
filterFn: filterFn, filterFn: filterFn,
}); });
self.emit('changed', thread);
} }
rebuildRootThread();
annotationUI.subscribe(rebuildRootThread);
// Listen for annotations being created or loaded // Listen for annotations being created or loaded
// and show them in the UI. // and show them in the UI.
...@@ -118,19 +111,10 @@ function RootThread($rootScope, annotationUI, searchFilter, viewFilter) { ...@@ -118,19 +111,10 @@ function RootThread($rootScope, annotationUI, searchFilter, viewFilter) {
}); });
/** /**
* Rebuild the conversation thread based on the currently loaded annotations * Build the root conversation thread from the given UI state.
* and search/sort/filter settings.
*/
this.rebuild = rebuildRootThread;
/**
* Returns the current root conversation thread.
* @return {Thread} * @return {Thread}
*/ */
this.thread = function () { this.thread = memoize(buildRootThread);
return thread;
};
} }
inherits(RootThread, EventEmitter);
module.exports = RootThread; module.exports = RootThread;
...@@ -52,9 +52,12 @@ module.exports = class StreamController ...@@ -52,9 +52,12 @@ module.exports = class StreamController
$scope.forceVisible = (id) -> $scope.forceVisible = (id) ->
annotationUI.setForceVisible(id, true) annotationUI.setForceVisible(id, true)
rootThread.on('changed', (thread) -> thread = ->
rootThread.thread(annotationUI.getState())
annotationUI.subscribe( ->
$scope.virtualThreadList = { $scope.virtualThreadList = {
visibleThreads: thread.children, visibleThreads: thread().children,
offscreenUpperHeight: '0px', offscreenUpperHeight: '0px',
offscreenLowerHeight: '0px', offscreenLowerHeight: '0px',
}; };
...@@ -64,6 +67,4 @@ module.exports = class StreamController ...@@ -64,6 +67,4 @@ module.exports = class StreamController
annotationUI.setSortKey('Newest') annotationUI.setSortKey('Newest')
$scope.isStream = true $scope.isStream = true
$scope.rootThread = ->
return rootThread.thread()
$scope.loadMore = fetch $scope.loadMore = fetch
...@@ -36,7 +36,7 @@ describe('AnnotationViewerController', function () { ...@@ -36,7 +36,7 @@ describe('AnnotationViewerController', function () {
$scope: opts.$scope || { $scope: opts.$scope || {
search: {}, search: {},
}, },
annotationUI: {}, annotationUI: {subscribe: sinon.stub()},
rootThread: new FakeRootThread(), rootThread: new FakeRootThread(),
streamer: opts.streamer || { setConfig: function () {} }, streamer: opts.streamer || { setConfig: function () {} },
store: opts.store || { store: opts.store || {
......
...@@ -52,26 +52,26 @@ describe('annotation threading', function () { ...@@ -52,26 +52,26 @@ describe('annotation threading', function () {
it('should display newly loaded annotations', function () { it('should display newly loaded annotations', function () {
annotationUI.addAnnotations(fixtures.annotations); annotationUI.addAnnotations(fixtures.annotations);
assert.equal(rootThread.thread().children.length, 2); assert.equal(rootThread.thread(annotationUI.getState()).children.length, 2);
}); });
it('should not display unloaded annotations', function () { it('should not display unloaded annotations', function () {
annotationUI.addAnnotations(fixtures.annotations); annotationUI.addAnnotations(fixtures.annotations);
annotationUI.removeAnnotations(fixtures.annotations); annotationUI.removeAnnotations(fixtures.annotations);
assert.equal(rootThread.thread().children.length, 0); assert.equal(rootThread.thread(annotationUI.getState()).children.length, 0);
}); });
it('should filter annotations when a search is set', function () { it('should filter annotations when a search is set', function () {
annotationUI.addAnnotations(fixtures.annotations); annotationUI.addAnnotations(fixtures.annotations);
annotationUI.setFilterQuery('second'); annotationUI.setFilterQuery('second');
assert.equal(rootThread.thread().children.length, 1); assert.equal(rootThread.thread(annotationUI.getState()).children.length, 1);
assert.equal(rootThread.thread().children[0].id, '2'); assert.equal(rootThread.thread(annotationUI.getState()).children[0].id, '2');
}); });
unroll('should sort annotations by #mode', function (testCase) { unroll('should sort annotations by #mode', function (testCase) {
annotationUI.addAnnotations(fixtures.annotations); annotationUI.addAnnotations(fixtures.annotations);
annotationUI.setSortKey(testCase.sortKey); annotationUI.setSortKey(testCase.sortKey);
var actualOrder = rootThread.thread().children.map(function (thread) { var actualOrder = rootThread.thread(annotationUI.getState()).children.map(function (thread) {
return thread.annotation.id; return thread.annotation.id;
}); });
assert.deepEqual(actualOrder, testCase.expectedOrder); assert.deepEqual(actualOrder, testCase.expectedOrder);
......
...@@ -77,26 +77,9 @@ describe('rootThread', function () { ...@@ -77,26 +77,9 @@ describe('rootThread', function () {
}); });
}); });
describe('initialization', function () { describe('#thread', function () {
it('builds a thread from the current set of annotations', function () { it('returns the result of buildThread()', function() {
assert.equal(rootThread.thread(), fixtures.emptyThread); assert.equal(rootThread.thread(fakeAnnotationUI.state), fixtures.emptyThread);
});
});
function assertRebuildsThread(fn) {
fakeBuildThread.reset();
var thread = Object.assign({}, fixtures.emptyThread);
fakeBuildThread.returns(thread);
fn();
assert.called(fakeBuildThread);
assert.equal(rootThread.thread(), thread);
}
describe('#rebuild', function () {
it('rebuilds the thread', function () {
assertRebuildsThread(function () {
rootThread.rebuild();
});
}); });
it('passes loaded annotations to buildThread()', function () { it('passes loaded annotations to buildThread()', function () {
...@@ -104,7 +87,7 @@ describe('rootThread', function () { ...@@ -104,7 +87,7 @@ describe('rootThread', function () {
fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, { fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, {
annotations: [annotation], annotations: [annotation],
}); });
rootThread.rebuild(); rootThread.thread(fakeAnnotationUI.state);
assert.calledWith(fakeBuildThread, sinon.match([annotation])); assert.calledWith(fakeBuildThread, sinon.match([annotation]));
}); });
...@@ -112,7 +95,7 @@ describe('rootThread', function () { ...@@ -112,7 +95,7 @@ describe('rootThread', function () {
fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, { fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, {
selectedAnnotationMap: {id1: true, id2: true}, selectedAnnotationMap: {id1: true, id2: true},
}); });
rootThread.rebuild(); rootThread.thread(fakeAnnotationUI.state);
assert.calledWith(fakeBuildThread, [], sinon.match({ assert.calledWith(fakeBuildThread, [], sinon.match({
selected: ['id1', 'id2'], selected: ['id1', 'id2'],
})); }));
...@@ -122,7 +105,7 @@ describe('rootThread', function () { ...@@ -122,7 +105,7 @@ describe('rootThread', function () {
fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, { fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, {
expanded: {id1: true, id2: true}, expanded: {id1: true, id2: true},
}); });
rootThread.rebuild(); rootThread.thread(fakeAnnotationUI.state);
assert.calledWith(fakeBuildThread, [], sinon.match({ assert.calledWith(fakeBuildThread, [], sinon.match({
expanded: {id1: true, id2: true}, expanded: {id1: true, id2: true},
})); }));
...@@ -132,22 +115,13 @@ describe('rootThread', function () { ...@@ -132,22 +115,13 @@ describe('rootThread', function () {
fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, { fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, {
forceVisible: {id1: true, id2: true}, forceVisible: {id1: true, id2: true},
}); });
rootThread.rebuild(); rootThread.thread(fakeAnnotationUI.state);
assert.calledWith(fakeBuildThread, [], sinon.match({ assert.calledWith(fakeBuildThread, [], sinon.match({
forceVisible: ['id1', 'id2'], forceVisible: ['id1', 'id2'],
})); }));
}); });
}); });
context('when the annotationUI state changes', function () {
it('rebuilds the root thread', function () {
assertRebuildsThread(function () {
var subscriber = fakeAnnotationUI.subscribe.args[0][0];
subscriber();
});
});
});
describe('when the sort order changes', function () { describe('when the sort order changes', function () {
function sortBy(annotations, sortCompareFn) { function sortBy(annotations, sortCompareFn) {
return annotations.slice().sort(function (a,b) { return annotations.slice().sort(function (a,b) {
...@@ -181,7 +155,7 @@ describe('rootThread', function () { ...@@ -181,7 +155,7 @@ describe('rootThread', function () {
sortKey: testCase.order, sortKey: testCase.order,
sortKeysAvailable: [testCase.order], sortKeysAvailable: [testCase.order],
}); });
rootThread.rebuild(); rootThread.thread(fakeAnnotationUI.state);
var sortCompareFn = fakeBuildThread.args[0][1].sortCompareFn; var sortCompareFn = fakeBuildThread.args[0][1].sortCompareFn;
var actualOrder = sortBy(annotations, sortCompareFn).map(function (annot) { var actualOrder = sortBy(annotations, sortCompareFn).map(function (annot) {
return annotations.indexOf(annot); return annotations.indexOf(annot);
...@@ -202,7 +176,7 @@ describe('rootThread', function () { ...@@ -202,7 +176,7 @@ describe('rootThread', function () {
fakeSearchFilter.generateFacetedFilter.returns(filters); fakeSearchFilter.generateFacetedFilter.returns(filters);
fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state,
{filterQuery: 'queryterm'}); {filterQuery: 'queryterm'});
rootThread.rebuild(); rootThread.thread(fakeAnnotationUI.state);
var filterFn = fakeBuildThread.args[0][1].filterFn; var filterFn = fakeBuildThread.args[0][1].filterFn;
fakeViewFilter.filter.returns([annotation]); fakeViewFilter.filter.returns([annotation]);
......
...@@ -43,6 +43,7 @@ describe 'StreamController', -> ...@@ -43,6 +43,7 @@ describe 'StreamController', ->
setCollapsed: sandbox.spy() setCollapsed: sandbox.spy()
setForceVisible: sandbox.spy() setForceVisible: sandbox.spy()
setSortKey: sandbox.spy() setSortKey: sandbox.spy()
subscribe: sandbox.spy()
} }
fakeParams = {id: 'test'} fakeParams = {id: 'test'}
......
...@@ -63,6 +63,9 @@ VirtualThreadList.prototype.detach = function () { ...@@ -63,6 +63,9 @@ VirtualThreadList.prototype.detach = function () {
* matching annotations changes. * matching annotations changes.
*/ */
VirtualThreadList.prototype.setRootThread = function (thread) { VirtualThreadList.prototype.setRootThread = function (thread) {
if (thread === this._rootThread) {
return;
}
this._rootThread = thread; this._rootThread = thread;
this._updateVisibleThreads(); this._updateVisibleThreads();
}; };
......
...@@ -61,11 +61,19 @@ module.exports = function WidgetController( ...@@ -61,11 +61,19 @@ module.exports = function WidgetController(
return elementHeight + marginHeight; return elementHeight + marginHeight;
} }
function thread() {
return rootThread.thread(annotationUI.getState());
}
// `visibleThreads` keeps track of the subset of all threads matching the // `visibleThreads` keeps track of the subset of all threads matching the
// current filters which are in or near the viewport and the view then renders // current filters which are in or near the viewport and the view then renders
// only those threads, using placeholders above and below the visible threads // only those threads, using placeholders above and below the visible threads
// to reserve space for threads which are not actually rendered. // to reserve space for threads which are not actually rendered.
var visibleThreads = new VirtualThreadList($scope, window, rootThread.thread()); var visibleThreads = new VirtualThreadList($scope, window, thread());
annotationUI.subscribe(function () {
visibleThreads.setRootThread(thread());
});
visibleThreads.on('changed', function (state) { visibleThreads.on('changed', function (state) {
$scope.virtualThreadList = { $scope.virtualThreadList = {
visibleThreads: state.visibleThreads, visibleThreads: state.visibleThreads,
...@@ -83,9 +91,7 @@ module.exports = function WidgetController( ...@@ -83,9 +91,7 @@ module.exports = function WidgetController(
}); });
}, 50); }, 50);
}); });
rootThread.on('changed', function (thread) {
visibleThreads.setRootThread(thread);
});
$scope.$on('$destroy', function () { $scope.$on('$destroy', function () {
visibleThreads.detach(); visibleThreads.detach();
}); });
...@@ -264,10 +270,6 @@ module.exports = function WidgetController( ...@@ -264,10 +270,6 @@ module.exports = function WidgetController(
annotationUI.setFilterQuery(query); annotationUI.setFilterQuery(query);
}); });
$scope.rootThread = function () {
return rootThread.thread();
};
$scope.setCollapsed = function (id, collapsed) { $scope.setCollapsed = function (id, collapsed) {
annotationUI.setCollapsed(id, collapsed); annotationUI.setCollapsed(id, collapsed);
}; };
...@@ -334,11 +336,11 @@ module.exports = function WidgetController( ...@@ -334,11 +336,11 @@ module.exports = function WidgetController(
}); });
$scope.visibleCount = function () { $scope.visibleCount = function () {
return visibleCount(rootThread.thread()); return visibleCount(thread());
}; };
$scope.topLevelThreadCount = function () { $scope.topLevelThreadCount = function () {
return rootThread.thread().totalChildren; return thread().totalChildren;
}; };
/** /**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment