Skip to content

Commit

Permalink
Component afterRender that is triggered when all child components hav…
Browse files Browse the repository at this point in the history
…e rendered, no matter how long it takes. This is option 2 in #1533 (comment)
  • Loading branch information
mbest committed Nov 23, 2016
1 parent 241c26c commit 14d147a
Show file tree
Hide file tree
Showing 2 changed files with 137 additions and 2 deletions.
109 changes: 107 additions & 2 deletions spec/components/componentBindingBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,70 @@ describe('Components: Component binding', function() {
expect(testNode.childNodes[0]).toContainText('In child context 123, inside component with property 456. Now in sub-component with property 789.', /* ignoreSpaces */ true); // Ignore spaces because old-IE is inconsistent
});

it('Calls an afterRender function on the component viewmodel after the component is rendered', function() {
var renderedCount = 0;
ko.components.register(testComponentName, {
template: '<div data-bind="text: myvalue"></div>',
viewModel: function() {
this.myvalue = 123;
this.afterRender = function () {
renderedCount++;
};
}
});

ko.applyBindings(outerViewModel, testNode);
expect(renderedCount).toBe(0);

jasmine.Clock.tick(1);
expect(renderedCount).toBe(1);
});

it('Calls all inner components\' afterRender functions and then an outer component\'s', function() {
this.after(function() {
ko.components.unregister('sub-component');
});

var renderedComponents = [];
ko.components.register(testComponentName, {
template: '<div data-bind="component: { name: \'sub-component\', params: 1 }"></div><div data-bind="component: { name: \'sub-component\', params: 2 }"></div>',
viewModel: function() { this.afterRender = function () { renderedComponents.push(testComponentName); }; }
});

ko.components.register('sub-component', {
template: '<span></span>',
viewModel: function(params) { this.afterRender = function () { renderedComponents.push('sub-component' + params); }; }
});

ko.applyBindings(outerViewModel, testNode);
expect(renderedComponents).toEqual([]);

jasmine.Clock.tick(1);
expect(renderedComponents).toEqual([ 'sub-component1', 'sub-component2', 'test-component' ]);
});

it('When components are rendered synchronously, calls all inner components\' afterRender functions and then an outer component\'s', function() {
this.after(function() {
ko.components.unregister('sub-component');
});

var renderedComponents = [];
ko.components.register(testComponentName, {
synchronous: true,
template: '<div data-bind="component: { name: \'sub-component\', params: 1 }"></div><div data-bind="component: { name: \'sub-component\', params: 2 }"></div>',
viewModel: function() { this.afterRender = function () { renderedComponents.push(testComponentName); }; }
});

ko.components.register('sub-component', {
synchronous: true,
template: '<span></span>',
viewModel: function(params) { this.afterRender = function () { renderedComponents.push('sub-component' + params); }; }
});

ko.applyBindings(outerViewModel, testNode);
expect(renderedComponents).toEqual([ 'sub-component1', 'sub-component2', 'test-component' ]);
});

it('Passes nonobservable params to the component', function() {
// Set up a component that logs its constructor params
var receivedParams = [];
Expand Down Expand Up @@ -263,8 +327,9 @@ describe('Components: Component binding', function() {
ko.components.unregister('component-beta');
});

function alphaViewModel(params) { this.alphaValue = params.suppliedValue; }
function betaViewModel(params) { this.betaValue = params.suppliedValue; }
var renderedComponents = [];
function alphaViewModel(params) { this.alphaValue = params.suppliedValue; this.afterRender = function () { renderedComponents.push('alpha'); }; }
function betaViewModel(params) { this.betaValue = params.suppliedValue; this.afterRender = function () { renderedComponents.push('beta'); }; }

alphaViewModel.prototype.dispose = function() {
expect(arguments.length).toBe(0);
Expand Down Expand Up @@ -301,6 +366,7 @@ describe('Components: Component binding', function() {
expect(testComponentBindingValue.name.getSubscriptionsCount()).toBe(1);
expect(testComponentParams.suppliedValue.getSubscriptionsCount()).toBe(1);
expect(alphaViewModelInstance.alphaWasDisposed).not.toBe(true);
expect(renderedComponents).toEqual(['alpha']);

// Store some data on a DOM node so we can check it was cleaned later
ko.utils.domData.set(firstAlphaTemplateNode, 'TestValue', 'Hello');
Expand All @@ -313,19 +379,23 @@ describe('Components: Component binding', function() {
expect(testNode.firstChild.firstChild).toBe(firstAlphaTemplateNode); // Same node
expect(ko.utils.domData.get(firstAlphaTemplateNode, 'TestValue')).toBe('Hello'); // Not cleaned
expect(alphaViewModelInstance.alphaWasDisposed).not.toBe(true);
expect(renderedComponents).toEqual(['alpha']);

// Can switch to the other component by observably changing the component name,
// but it happens asynchronously (because the component has to be loaded)
testComponentBindingValue.name('component-beta');
expect(testNode).toContainText('Alpha value is 234.');
expect(renderedComponents).toEqual(['alpha']);
jasmine.Clock.tick(1);
expect(testNode).toContainText('Beta value is 234.');
expect(renderedComponents).toEqual(['alpha', 'beta']);

// Cleans up by disposing obsolete subscriptions, viewmodels, and cleans DOM nodes
expect(testComponentBindingValue.name.getSubscriptionsCount()).toBe(1);
expect(testComponentParams.suppliedValue.getSubscriptionsCount()).toBe(1);
expect(ko.utils.domData.get(firstAlphaTemplateNode, 'TestValue')).toBe(undefined); // Got cleaned
expect(alphaViewModelInstance.alphaWasDisposed).toBe(true);
expect(renderedComponents).toEqual(['alpha', 'beta']);
});

it('Supports binding to an observable that contains name/params, rebuilding the component if that observable changes, disposing the old viewmodel and nodes', function() {
Expand Down Expand Up @@ -406,6 +476,10 @@ describe('Components: Component binding', function() {
testViewModel.prototype.dispose = function() {
this.wasDisposed = true;
}
var renderedCount = 0;
testViewModel.prototype.afterRender = function () {
renderedCount++;
}

ko.components.register(testComponentName, {
viewModel: testViewModel,
Expand All @@ -422,6 +496,7 @@ describe('Components: Component binding', function() {
firstViewModelInstance = ko.dataFor(firstTemplateNode);
expect(firstViewModelInstance instanceof testViewModel).toBe(true);
expect(testNode).toContainText('Value is First.');
expect(renderedCount).toBe(1);
expect(firstViewModelInstance.wasDisposed).not.toBe(true);
ko.utils.domData.set(firstTemplateNode, 'TestValue', 'Hello');

Expand All @@ -432,6 +507,7 @@ describe('Components: Component binding', function() {
expect(ko.utils.domData.get(firstTemplateNode, 'TestValue')).toBe('Hello');
jasmine.Clock.tick(1);
expect(testNode).toContainText('Value is Second.');
expect(renderedCount).toBe(2);
expect(firstViewModelInstance.wasDisposed).toBe(true);
expect(ko.utils.domData.get(firstTemplateNode, 'TestValue')).toBe(undefined);

Expand Down Expand Up @@ -651,6 +727,35 @@ describe('Components: Component binding', function() {
expect(testNode).toContainText('Hello! Your param is 456 Goodbye.');
});

it('Does not call outer component\'s afterRender function if inner component is re-rendered', function() {
this.after(function() {
ko.components.unregister('sub-component');
});

var renderedComponents = [];
var observable = ko.observable(1);
ko.components.register(testComponentName, {
template: '<div data-bind="component: { name: \'sub-component\', params: $root.observable }"></div>',
viewModel: function() { this.afterRender = function () { renderedComponents.push(testComponentName); }; }
});

ko.components.register('sub-component', {
template: '<span></span>',
viewModel: function(params) { this.afterRender = function () { renderedComponents.push('sub-component' + params); }; }
});

outerViewModel.observable = observable;
ko.applyBindings(outerViewModel, testNode);
expect(renderedComponents).toEqual([]);

jasmine.Clock.tick(1);
expect(renderedComponents).toEqual([ 'sub-component1', 'test-component' ]);

observable(2);
jasmine.Clock.tick(1);
expect(renderedComponents).toEqual([ 'sub-component1', 'test-component', 'sub-component2' ]);
});

describe('Does not automatically subscribe to any observables you evaluate during createViewModel or a viewmodel constructor', function() {
// This clarifies that, if a developer wants to react when some observable parameter
// changes, then it's their responsibility to subscribe to it or use a computed.
Expand Down
30 changes: 30 additions & 0 deletions src/components/componentBinding.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
'init': function(element, valueAccessor, ignored1, ignored2, bindingContext) {
var currentViewModel,
currentLoadingOperationId,
currentAfterRenderWatcher,
disposeAssociatedComponentViewModel = function () {
var currentViewModelDispose = currentViewModel && currentViewModel['dispose'];
if (typeof currentViewModelDispose === 'function') {
currentViewModelDispose.call(currentViewModel);
}
if (currentAfterRenderWatcher) {
currentAfterRenderWatcher.dispose();
}
currentAfterRenderWatcher = null;
currentViewModel = null;
// Any in-flight loading operation is no longer relevant, so make sure we ignore its completion
currentLoadingOperationId = null;
Expand All @@ -34,6 +39,10 @@
throw new Error('No component name specified');
}

if (ko.isObservable(bindingContext._componentActiveChildrenCount)) {
bindingContext._componentActiveChildrenCount(bindingContext._componentActiveChildrenCount.peek() + 1);
}

var loadingOperationId = currentLoadingOperationId = ++componentLoadingOperationUniqueId;
ko.components.get(componentName, function(componentDefinition) {
// If this is not the current load operation for this element, ignore it.
Expand All @@ -55,7 +64,28 @@
ctx['$componentTemplateNodes'] = originalChildNodes;
});
currentViewModel = componentViewModel;
childBindingContext._componentActiveChildrenCount = ko.observable(0);
ko.applyBindingsToDescendants(childBindingContext, element);

var callAfterRenderWhenChildrenDone = function(activeChildren) {
if (!activeChildren) {
if (currentAfterRenderWatcher) {
currentAfterRenderWatcher.dispose();
}
var currentViewModelAfterRender = currentViewModel && currentViewModel['afterRender'];
if (typeof currentViewModelAfterRender === 'function') {
ko.dependencyDetection.ignore(currentViewModelAfterRender, currentViewModel);
}
if (ko.isObservable(bindingContext._componentActiveChildrenCount)) {
bindingContext._componentActiveChildrenCount(bindingContext._componentActiveChildrenCount.peek() - 1);
}
}
};
if (childBindingContext._componentActiveChildrenCount.peek() === 0) {
callAfterRenderWhenChildrenDone();
} else {
currentAfterRenderWatcher = childBindingContext._componentActiveChildrenCount.subscribe(callAfterRenderWhenChildrenDone);
}
});
}, null, { disposeWhenNodeIsRemoved: element });

Expand Down

0 comments on commit 14d147a

Please sign in to comment.