Permalink
Browse files

fix(compose): await composition/activation

before applying new changes close #299, close #240
  • Loading branch information...
StrahilKazlachev committed Aug 2, 2017
1 parent 7cc90cf commit 685344ed07b849449e06722e3c8ab045b9cc4d7f
Showing with 178 additions and 69 deletions.
  1. +83 −58 src/compose.js
  2. +95 −11 test/compose.spec.js
View
@@ -1,11 +1,15 @@
import {Container, inject} from 'aurelia-dependency-injection';
import * as LogManager from 'aurelia-logging';
import {TaskQueue} from 'aurelia-task-queue';
import {
CompositionEngine, ViewSlot, ViewResources,
customElement, bindable, noView, View
CompositionEngine, CompositionContext,
ViewSlot, ViewResources, customElement,
bindable, noView, View
} from 'aurelia-templating';
import {DOM} from 'aurelia-pal';
const logger = LogManager.getLogger('templating-resources');
/**
* Used to compose a new view / view-model template or bind to an existing instance.
*/
@@ -19,21 +23,21 @@ export class Compose {
* @property model
* @type {CustomElement}
*/
@bindable model
@bindable model;
/**
* View to bind the custom element to.
*
* @property view
* @type {HtmlElement}
*/
@bindable view
@bindable view;
/**
* View-model to bind the custom element's template to.
*
* @property viewModel
* @type {Class}
*/
@bindable viewModel
@bindable viewModel;
/**
* SwapOrder to control the swapping order of the custom element's view.
@@ -61,6 +65,7 @@ export class Compose {
this.taskQueue = taskQueue;
this.currentController = null;
this.currentViewModel = null;
this.changes = Object.create(null);
}
/**
@@ -81,17 +86,18 @@ export class Compose {
bind(bindingContext, overrideContext) {
this.bindingContext = bindingContext;
this.overrideContext = overrideContext;
processInstruction(this, createInstruction(this, {
view: this.view,
viewModel: this.viewModel,
model: this.model
}));
this.changes['view'] = this.view;
this.changes['viewModel'] = this.viewModel;
this.changes['model'] = this.model;
processChanges(this);
}
/**
* Unbinds the Compose.
*/
unbind(bindingContext, overrideContext) {
unbind() {
this.changes = Object.create(null);
this.pendingTask = null;
this.bindingContext = null;
this.overrideContext = null;
let returnToCache = true;
@@ -105,23 +111,8 @@ export class Compose {
* @param oldValue The old value.
*/
modelChanged(newValue, oldValue) {
if (this.currentInstruction) {
this.currentInstruction.model = newValue;
return;
}
this.taskQueue.queueMicroTask(() => {
if (this.currentInstruction) {
this.currentInstruction.model = newValue;
return;
}
let vm = this.currentViewModel;
if (vm && typeof vm.activate === 'function') {
vm.activate(newValue);
}
});
this.changes['model'] = newValue;
requestUpdate(this);
}
/**
@@ -130,19 +121,8 @@ export class Compose {
* @param oldValue The old value.
*/
viewChanged(newValue, oldValue) {
let instruction = createInstruction(this, {
view: newValue,
viewModel: this.currentViewModel || this.viewModel,
model: this.model
});
if (this.currentInstruction) {
this.currentInstruction = instruction;
return;
}
this.currentInstruction = instruction;
this.taskQueue.queueMicroTask(() => processInstruction(this, this.currentInstruction));
this.changes['view'] = newValue;
requestUpdate(this);
}
/**
@@ -151,23 +131,25 @@ export class Compose {
* @param oldValue The old value.
*/
viewModelChanged(newValue, oldValue) {
let instruction = createInstruction(this, {
viewModel: newValue,
view: this.view,
model: this.model
});
this.changes['viewModel'] = newValue;
requestUpdate(this);
}
}
if (this.currentInstruction) {
this.currentInstruction = instruction;
return;
}
function isEmpty(obj) {
for (const key in obj) {
return false;
}
return true;
}
this.currentInstruction = instruction;
this.taskQueue.queueMicroTask(() => processInstruction(this, this.currentInstruction));
function tryActivateViewModel(vm, model) {
if (vm && typeof vm.activate === 'function') {
return Promise.resolve(vm.activate(model));
}
}
function createInstruction(composer, instruction) {
function createInstruction(composer: Compose, instruction: CompositionContext): CompositionContext {
return Object.assign(instruction, {
bindingContext: composer.bindingContext,
overrideContext: composer.overrideContext,
@@ -181,10 +163,53 @@ function createInstruction(composer, instruction) {
});
}
function processInstruction(composer, instruction) {
composer.currentInstruction = null;
composer.compositionEngine.compose(instruction).then(controller => {
composer.currentController = controller;
composer.currentViewModel = controller ? controller.viewModel : null;
function processChanges(composer: Compose) {
const changes = composer.changes;
composer.changes = Object.create(null);
if (!('view' in changes) && !('viewModel' in changes) && ('model' in changes)) {
// just try to activate the current view model
composer.pendingTask = tryActivateViewModel(composer.currentViewModel, changes['model']);
if (!composer.pendingTask) { return; }
} else {
// init context
let instruction = {
view: composer.view,
viewModel: composer.currentViewModel || composer.viewModel,
model: composer.model
};
// apply changes
instruction = Object.assign(instruction, changes);
// create context
instruction = createInstruction(composer, instruction);
composer.pendingTask = composer.compositionEngine.compose(instruction).then(controller => {
composer.currentController = controller;
composer.currentViewModel = controller ? controller.viewModel : null;
});
}
composer.pendingTask = composer.pendingTask.catch(e => {
logger.error(e);
}).then(() => {
if (!composer.pendingTask) {
// the element has been unbound
return;
}
composer.pendingTask = null;
if (!isEmpty(composer.changes)) {
processChanges(composer);
}
});
}
function requestUpdate(composer: Compose) {
if (composer.pendingTask || composer.updateRequested) { return; }
composer.updateRequested = true;
composer.taskQueue.queueMicroTask(() => {
composer.updateRequested = false;
processChanges(composer);
});
}
View
@@ -1,6 +1,8 @@
import './setup';
import {TaskQueue} from 'aurelia-task-queue';
import {Compose} from '../src/compose';
import * as LogManager from 'aurelia-logging';
const logger = LogManager.getLogger('templating-resources');
describe('Compose', () => {
let elementMock;
@@ -44,7 +46,7 @@ describe('Compose', () => {
});
describe('when bound', () => {
it('caches the binding and overridex contexts', () => {
it('caches the binding and override contexts', () => {
const bindingContext = {};
const overrideContext = {};
sut.bind(bindingContext, overrideContext);
@@ -55,9 +57,8 @@ describe('Compose', () => {
describe('when unbound', () => {
it('clears the cached binding and override contexts', () => {
const bindingContext = {};
const overrideContext = {};
sut.bind(bindingContext, overrideContext);
const bindingContext = sut.bindingContext = {};
const overrideContext = sut.overrideContext = {};
sut.unbind();
expect(sut.bindingContext).not.toBe(bindingContext);
expect(sut.overrideContext).not.toBe(overrideContext);
@@ -235,34 +236,117 @@ describe('Compose', () => {
});
});
it('awaits the current composition/activation before applying next set of changes', done => {
compositionEngineMock.compose.and.stub();
compositionEngineMock.compose.and.callFake(() => new Promise(resolve => setTimeout(resolve, 600)));
updateBindable('viewModel', './some-vm');
taskQueue.queueMicroTask(() => setTimeout(() => {
expect(compositionEngineMock.compose).toHaveBeenCalledTimes(1);
const setOne = {
model: 2,
view: './view.html'
};
const setTwo = {
model: 42,
viewModel: './truth'
};
const endSet = Object.assign({}, setOne, setTwo);
sut.pendingTask.then(() => {
expect(Object.keys(sut.changes).length).toBe(0);
expect(compositionEngineMock.compose).toHaveBeenCalledTimes(2);
expect(compositionEngineMock.compose).toHaveBeenCalledWith(jasmine.objectContaining(endSet));
done();
});
updateBindable('model', setOne.model);
updateBindable('view', setOne.view);
setTimeout(() => {
expect(sut.changes).toEqual(jasmine.objectContaining(setOne));
expect(compositionEngineMock.compose).toHaveBeenCalledTimes(1);
updateBindable('model', setTwo.model);
updateBindable('viewModel', setTwo.viewModel);
}, 100);
setTimeout(() => {
expect(sut.changes).toEqual(jasmine.objectContaining(endSet));
expect(compositionEngineMock.compose).toHaveBeenCalledTimes(1);
}, 300);
}, 0));
});
describe('after successul composition', () => {
const controller = {
viewModel: createMock()
};
let result;
beforeEach(done => {
compositionEngineMock.compose.and.stub;
compositionEngineMock.compose.and.callFake(() => {
result = Promise.resolve(controller);
return result;
});
compositionEngineMock.compose.and.callFake(() => new Promise(resolve => setTimeout(() => resolve(controller), 20)));
updateBindable('viewModel', './some-vm');
taskQueue.queueMicroTask(done);
});
it('sets the current controller', done => {
result.then(() => {
sut.pendingTask.then(() => {
expect(sut.currentController).toBe(controller);
done()
}, done.fail);
});
it('sets the current active view model', done => {
result.then(() => {
sut.pendingTask.then(() => {
expect(sut.currentViewModel).toBe(controller.viewModel);
done()
}, done.fail);
});
it('processes pending changes', done => {
expect(sut.pendingTask).toBeTruthy();
expect(sut.changes['viewModel']).not.toBeDefined();
setTimeout(() => {
const vm = './some-other-vm';
updateBindable('viewModel', vm);
expect(sut.changes['viewModel']).toBeDefined();
sut.pendingTask.then(() => {
expect(sut.changes['viewModel']).not.toBeDefined();
return sut.pendingTask;
}).then(done).catch(done.fail);
}, 0);
});
it('clears pending composition', done => {
sut.pendingTask.then(() => {
expect(sut.pendingTask).not.toBeTruthy();
done();
}).catch(done.fail);
});
});
describe('after failing a composition', () => {
let error;
beforeEach(done => {
spyOn(logger, 'error');
compositionEngineMock.compose.and.stub;
compositionEngineMock.compose.and.callFake(() => Promise.reject(error = new Error('".compose" test error')));
updateBindable('viewModel', './some-vm');
taskQueue.queueMicroTask(done);
});
it('logs the error', done => {
sut.pendingTask.then(() => {
expect(logger.error).toHaveBeenCalledWith(error);
done();
}).catch(done.fail);
});
it('clears pending composition', done => {
sut.pendingTask.then(() => {
expect(sut.pendingTask).not.toBeTruthy();
done();
}).catch(done.fail);
});
});
});

0 comments on commit 685344e

Please sign in to comment.