Skip to content

Commit

Permalink
Expand binding event to handle descendant completion for components
Browse files Browse the repository at this point in the history
  • Loading branch information
mbest committed Nov 14, 2017
1 parent fc2ff32 commit 01975ae
Show file tree
Hide file tree
Showing 7 changed files with 132 additions and 79 deletions.
7 changes: 4 additions & 3 deletions spec/bindingAttributeBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ describe('Binding attribute syntax', function() {
var allowedProperties = ['$parents', '$root', 'ko', '$rawData', '$data', '$parentContext', '$parent'];
if (ko.utils.createSymbolOrString('') === '') {
allowedProperties.push('_subscribable');
allowedProperties.push('_ancestorAsyncContext');
}
ko.utils.objectForEach(ko.contextFor(testNode.childNodes[0].childNodes[0]), function (prop) {
expect(allowedProperties).toContain(prop);
Expand Down Expand Up @@ -584,7 +585,7 @@ describe('Binding attribute syntax', function() {
});
});

it('Should call a childrenComplete callback function after descendent elements are bound', function () {
it('Should call a childrenComplete callback function after descendant elements are bound', function () {
var callbacks = 0,
callback = function (nodes, data) {
expect(nodes.length).toEqual(1);
Expand Down Expand Up @@ -627,11 +628,11 @@ describe('Binding attribute syntax', function() {
ko.applyBindings({}, testNode);
});

it('Should call childrenComplete callback registered with ko.subscribeToBindingEvent', function () {
it('Should call childrenComplete callback registered with ko.bindingEvent.subscribe', function () {
var callbacks = 0,
vm = {};

ko.subscribeToBindingEvent(testNode, "childrenComplete", function (node) {
ko.bindingEvent.subscribe(testNode, "childrenComplete", function (node) {
callbacks++;
expect(node).toEqual(testNode);
expect(ko.dataFor(node)).toEqual(vm);
Expand Down
26 changes: 26 additions & 0 deletions spec/components/componentBindingBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,32 @@ describe('Components: Component binding', function() {
expect(renderedComponents).toEqual([ 'sub-component1', 'sub-component2', 'test-component' ]);
});

it('afterRender waits for inner component to complete even if it is several layers down', function() {
this.after(function() {
ko.components.unregister('sub-component');
});

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

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

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

it('afterRender waits for inner components that are not yet loaded', function() {
this.restoreAfter(window, 'require');
this.after(function() {
Expand Down
4 changes: 2 additions & 2 deletions spec/observableArrayChangeTrackingBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ describe('Observable Array change tracking', function() {
};
var list = ko.observableArray([]);

// This adds all descendent nodes to the list when a node is added
// This adds all descendant nodes to the list when a node is added
list.subscribe(function (events) {
events = events.slice(0);
for (var i = 0; i < events.length; i++) {
Expand All @@ -388,7 +388,7 @@ describe('Observable Array change tracking', function() {

// Add the top-level node
list.push(toAdd);
// See that descendent nodes are also added
// See that descendant nodes are also added
expect(list()).toEqual([ toAdd, toAdd.nodes[0], toAdd.nodes[1], toAdd.nodes[2], toAdd.nodes[0].nodes[0] ]);
});

Expand Down
108 changes: 84 additions & 24 deletions src/binding/bindingAttributeSyntax.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
(function () {
// Hide or don't minify context properties, see https://github.com/knockout/knockout/issues/2294
var contextSubscribable = ko.utils.createSymbolOrString('_subscribable');
var contextAncestorAsyncContext = ko.utils.createSymbolOrString('_ancestorAsyncContext');

ko.bindingHandlers = {};

Expand Down Expand Up @@ -47,6 +48,11 @@
// Copy $root and any custom properties from the parent context
ko.utils.extend(self, parentContext);

// Copy Symbol properties
if (contextAncestorAsyncContext in parentContext) {
self[contextAncestorAsyncContext] = parentContext[contextAncestorAsyncContext];
}

// Because the above copy overwrites our own properties, we need to reset them.
self[contextSubscribable] = subscribable;
} else {
Expand Down Expand Up @@ -158,26 +164,83 @@
return this['createChildContext'](dataItemOrAccessor, dataItemAlias, null, { "exportDependencies": true });
};

var bindingEventsDomDataKey = ko.utils.domData.nextKey();
var boundElementDomDataKey = ko.utils.domData.nextKey();

function AsyncCompleteContext(node, bindingInfo, parentContext) {
this.node = node;
this.bindingInfo = bindingInfo;
this.asyncDescendants = [];
this.childrenComplete = false;

ko.subscribeToBindingEvent = function (node, event, callback) {
var eventSubscribable = ko.utils.domData.get(node, bindingEventsDomDataKey);
if (!eventSubscribable) {
ko.utils.domData.set(node, bindingEventsDomDataKey, eventSubscribable = new ko.subscribable);
function dispose() {
if (bindingInfo.asyncContext && bindingInfo.asyncContext.parentContext) {
bindingInfo.asyncContext.parentContext.descendantComplete(node);
}
bindingInfo.asyncContext = undefined;
};
ko.utils.domNodeDisposal.addDisposeCallback(node, dispose);
this.disposalCallback = dispose; // so we can remove the disposal callback later

if (parentContext) {
parentContext.asyncDescendants.push(node);
this.parentContext = parentContext;
}
}
AsyncCompleteContext.prototype.descendantComplete = function (node) {
ko.utils.arrayRemoveItem(this.asyncDescendants, node);
if (!this.asyncDescendants.length && this.childrenComplete) {
this.completeChildren();
}
return eventSubscribable.subscribe(callback, null, event);
};

ko.notifyBindingEvent = function (node, event) {
var eventSubscribable = ko.utils.domData.get(node, bindingEventsDomDataKey);
if (eventSubscribable) {
eventSubscribable['notifySubscribers'](node, event);
AsyncCompleteContext.prototype.completeChildren = function () {
this.childrenComplete = true;
if (this.bindingInfo.asyncContext && !this.asyncDescendants.length) {
this.bindingInfo.asyncContext = undefined;
ko.utils.domNodeDisposal.removeDisposeCallback(this.node, this.disposalCallback);
ko.bindingEvent.notify(this.node, ko.bindingEvent.descendantsComplete);
if (this.parentContext) {
this.parentContext.descendantComplete(this.node);
}
}
};
AsyncCompleteContext.prototype.createChildContext = function (dataItemOrAccessor, dataItemAlias, extendCallback, options) {
var self = this;
return this.bindingInfo.context['createChildContext'](dataItemOrAccessor, dataItemAlias, function (ctx) {
extendCallback(ctx);
ctx[contextAncestorAsyncContext] = self;
}, options);
};

ko.bindingEvent = {
childrenComplete: "childrenComplete",
descendentsComplete : "descendentsComplete"
descendantsComplete : "descendantsComplete",

subscribe: function (node, event, callback, context) {
var bindingInfo = ko.utils.domData.getOrSet(node, boundElementDomDataKey, {});
if (!bindingInfo.eventSubscribable) {
bindingInfo.eventSubscribable = new ko.subscribable;
}
return bindingInfo.eventSubscribable.subscribe(callback, context, event);
},

notify: function (node, event) {
var bindingInfo = ko.utils.domData.get(node, boundElementDomDataKey);
if (bindingInfo) {
if (bindingInfo.eventSubscribable) {
bindingInfo.eventSubscribable['notifySubscribers'](node, event);
}
if (event == ko.bindingEvent.childrenComplete && bindingInfo.asyncContext) {
bindingInfo.asyncContext.completeChildren();
}
}
},

startPossiblyAsyncContentBinding: function (node) {
var bindingInfo = ko.utils.domData.get(node, boundElementDomDataKey);
if (bindingInfo) {
return bindingInfo.asyncContext || (bindingInfo.asyncContext = new AsyncCompleteContext(node, bindingInfo, bindingInfo.context[contextAncestorAsyncContext]));
}
}
};

// Returns the valueAccessor function for a binding value
Expand Down Expand Up @@ -255,9 +318,7 @@
bindingApplied = true;
}

if (bindingApplied) {
ko.notifyBindingEvent(elementOrVirtualElement, ko.bindingEvent.childrenComplete);
}
ko.bindingEvent.notify(elementOrVirtualElement, ko.bindingEvent.childrenComplete);
}
}

Expand All @@ -280,8 +341,6 @@
}
}

var boundElementDomDataKey = ko.utils.domData.nextKey();

function topologicalSortBindings(bindings) {
// Depth-first sort
var result = [], // The list of key/handler pairs that we will return
Expand Down Expand Up @@ -317,15 +376,16 @@

function applyBindingsToNodeInternal(node, sourceBindings, bindingContext) {
// Prevent multiple applyBindings calls for the same node, except when a binding value is specified
var bindingInfo = ko.utils.domData.get(node, boundElementDomDataKey);
if (!sourceBindings) {
if (bindingInfo) {
var bindingInfo = ko.utils.domData.getOrSet(node, boundElementDomDataKey, {});
if (bindingInfo.context) {
throw Error("You cannot apply bindings multiple times to the same element.");
}

ko.utils.domData.set(node, boundElementDomDataKey, {context: bindingContext});
if (bindingContext[contextSubscribable])
bindingInfo.context = bindingContext;
if (bindingContext[contextSubscribable]) {
bindingContext[contextSubscribable]._addNode(node);
}
}

// Use bindings if given, otherwise fall back on asking the bindings provider to give us some bindings
Expand Down Expand Up @@ -380,7 +440,7 @@
};

if (ko.bindingEvent.childrenComplete in bindings) {
ko.subscribeToBindingEvent(node, ko.bindingEvent.childrenComplete, function () {
ko.bindingEvent.subscribe(node, ko.bindingEvent.childrenComplete, function () {
var callback = evaluateValueAccessor(bindings[ko.bindingEvent.childrenComplete]);
if (callback) {
var nodes = ko.virtualElements.childNodes(node);
Expand Down Expand Up @@ -503,8 +563,8 @@
};

ko.exportSymbol('bindingHandlers', ko.bindingHandlers);
ko.exportSymbol('subscribeToBindingEvent', ko.subscribeToBindingEvent);
ko.exportSymbol('notifyBindingEvent', ko.notifyBindingEvent);
ko.exportSymbol('bindingEvent', ko.bindingEvent);
ko.exportSymbol('bindingEvent.subscribe', ko.bindingEvent.subscribe);
ko.exportSymbol('applyBindings', ko.applyBindings);
ko.exportSymbol('applyBindingsToDescendants', ko.applyBindingsToDescendants);
ko.exportSymbol('applyBindingAccessorsToNode', ko.applyBindingAccessorsToNode);
Expand Down
58 changes: 10 additions & 48 deletions src/components/componentBinding.js
Original file line number Diff line number Diff line change
@@ -1,63 +1,28 @@
(function(undefined) {
var componentLoadingOperationUniqueId = 0;

function ComponentDisplayDeferred(element, parentComponentDeferred, replacedDeferred) {
var subscribable = new ko.subscribable();
this.subscribable = subscribable;

this._componentsToComplete = 1;

this.componentComplete = function () {
if (subscribable && !--this._componentsToComplete) {
subscribable['notifySubscribers'](element);
subscribable = undefined;
if (parentComponentDeferred) {
parentComponentDeferred.componentComplete();
}
}
};
this.dispose = function (shouldReject) {
if (subscribable) {
this._componentsToComplete = 0;
subscribable = undefined;
if (parentComponentDeferred) {
parentComponentDeferred.componentComplete();
}
}
};

if (parentComponentDeferred) {
++parentComponentDeferred._componentsToComplete;
}

if (replacedDeferred) {
replacedDeferred.dispose();
}
}

ko.bindingHandlers['component'] = {
'init': function(element, valueAccessor, ignored1, ignored2, bindingContext) {
var currentViewModel,
currentLoadingOperationId,
displayedDeferred,
afterRenderSub,
disposeAssociatedComponentViewModel = function () {
var currentViewModelDispose = currentViewModel && currentViewModel['dispose'];
if (typeof currentViewModelDispose === 'function') {
currentViewModelDispose.call(currentViewModel);
}
if (afterRenderSub) {
afterRenderSub.dispose();
}
afterRenderSub = null;
currentViewModel = null;
// Any in-flight loading operation is no longer relevant, so make sure we ignore its completion
currentLoadingOperationId = null;
},
originalChildNodes = ko.utils.makeArray(ko.virtualElements.childNodes(element));

ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
disposeAssociatedComponentViewModel();
if (displayedDeferred) {
displayedDeferred.dispose();
displayedDeferred = null;
}
});
ko.virtualElements.emptyNode(element);
ko.utils.domNodeDisposal.addDisposeCallback(element, disposeAssociatedComponentViewModel);

ko.computed(function () {
var value = ko.utils.unwrapObservable(valueAccessor()),
Expand All @@ -74,7 +39,7 @@
throw new Error('No component name specified');
}

displayedDeferred = new ComponentDisplayDeferred(element, bindingContext._componentDisplayDeferred, displayedDeferred);
var asyncContext = ko.bindingEvent.startPossiblyAsyncContentBinding(element);

var loadingOperationId = currentLoadingOperationId = ++componentLoadingOperationUniqueId;
ko.components.get(componentName, function(componentDefinition) {
Expand All @@ -98,20 +63,17 @@
};

var componentViewModel = createViewModel(componentDefinition, componentParams, componentInfo),
childBindingContext = bindingContext['createChildContext'](componentViewModel, /* dataItemAlias */ undefined, function(ctx) {
childBindingContext = asyncContext.createChildContext(componentViewModel, /* dataItemAlias */ undefined, function(ctx) {
ctx['$component'] = componentViewModel;
ctx['$componentTemplateNodes'] = originalChildNodes;
ctx._componentDisplayDeferred = displayedDeferred;
});

if (componentViewModel && componentViewModel['afterRender']) {
displayedDeferred.subscribable.subscribe(componentViewModel['afterRender']);
afterRenderSub = ko.bindingEvent.subscribe(element, "descendantsComplete", componentViewModel['afterRender'], componentViewModel);
}

currentViewModel = componentViewModel;
ko.applyBindingsToDescendants(childBindingContext, element);

displayedDeferred.componentComplete();
});
}, null, { disposeWhenNodeIsRemoved: element });

Expand Down
4 changes: 2 additions & 2 deletions src/templating/templating.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
ko.dependencyDetection.ignore(options['afterRender'], null, [renderedNodesArray, bindingContext['$data']]);
}
if (renderMode == "replaceChildren") {
ko.notifyBindingEvent(targetNodeOrNodeArray, ko.bindingEvent.childrenComplete);
ko.bindingEvent.notify(targetNodeOrNodeArray, ko.bindingEvent.childrenComplete);
}
}

Expand Down Expand Up @@ -209,7 +209,7 @@
// Call setDomNodeChildrenFromArrayMapping, ignoring any observables unwrapped within (most likely from a callback function).
// If the array items are observables, though, they will be unwrapped in executeTemplateForArrayItem and managed within setDomNodeChildrenFromArrayMapping.
ko.dependencyDetection.ignore(ko.utils.setDomNodeChildrenFromArrayMapping, null, [targetNode, filteredArray, executeTemplateForArrayItem, options, activateBindingsCallback]);
ko.notifyBindingEvent(targetNode, ko.bindingEvent.childrenComplete);
ko.bindingEvent.notify(targetNode, ko.bindingEvent.childrenComplete);
}, null, { disposeWhenNodeIsRemoved: targetNode });
};

Expand Down
4 changes: 4 additions & 0 deletions src/utils.domData.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ ko.utils.domData = new (function () {
var dataForNode = getDataForNode(node, value !== undefined /* createIfNotFound */);
dataForNode && (dataForNode[key] = value);
},
getOrSet: function (node, key, value) {
var dataForNode = getDataForNode(node, true /* createIfNotFound */);
return dataForNode[key] || (dataForNode[key] = value);
},
clear: clear,

nextKey: function () {
Expand Down

0 comments on commit 01975ae

Please sign in to comment.