diff --git a/spec/bindingDependencyBehaviors.js b/spec/bindingDependencyBehaviors.js index 7fcf13c8e..0aa4742b1 100644 --- a/spec/bindingDependencyBehaviors.js +++ b/spec/bindingDependencyBehaviors.js @@ -149,10 +149,9 @@ describe('Binding dependencies', function() { expect(lastBoundValueUpdate).toEqual("third value"); // update view model with brand-new property - /* TODO: fix observable view models vm({ myProp: function() {return "fourth value"; }}); expect(lastBoundValueInit).toEqual("fourth value"); - expect(lastBoundValueUpdate).toEqual("fourth value");*/ + expect(lastBoundValueUpdate).toEqual("fourth value"); }); it('Should not update sibling bindings if a binding is updated', function() { @@ -222,6 +221,131 @@ describe('Binding dependencies', function() { expect(latestValue).toEqual(2); }); + describe('Observable view models', function() { + it('Should update bindings (including callbacks)', function() { + var vm = ko.observable(), clickedVM; + function checkVM(data) { + clickedVM = data; + } + testNode.innerHTML = "
"; + vm({ someProp: 'My prop value', checkVM: checkVM }); + ko.applyBindings(vm, testNode); + expect(vm.getSubscriptionsCount()).toEqual(1); + + expect(testNode.childNodes[0].childNodes[0].value).toEqual("My prop value"); + + // a change to the input value should be written to the model + testNode.childNodes[0].childNodes[0].value = "some user-entered value"; + ko.utils.triggerEvent(testNode.childNodes[0].childNodes[0], "change"); + expect(vm().someProp).toEqual("some user-entered value"); + // a click should use correct view model + ko.utils.triggerEvent(testNode.childNodes[0].childNodes[1], "click"); + expect(clickedVM).toEqual(vm()); + + // set the view-model to a new object + vm({ someProp: ko.observable('My new prop value'), checkVM: checkVM }); + expect(testNode.childNodes[0].childNodes[0].value).toEqual("My new prop value"); + + // a change to the input value should be written to the new model + testNode.childNodes[0].childNodes[0].value = "some new user-entered value"; + ko.utils.triggerEvent(testNode.childNodes[0].childNodes[0], "change"); + expect(vm().someProp()).toEqual("some new user-entered value"); + // a click should use correct view model + ko.utils.triggerEvent(testNode.childNodes[0].childNodes[1], "click"); + expect(clickedVM).toEqual(vm()); + + // clear the element and the view-model (shouldn't be any errors) and the subscription should be cleared + ko.removeNode(testNode); + vm(null); + expect(vm.getSubscriptionsCount()).toEqual(0); + }); + + it('Should update all child contexts (including values copied from the parent)', function() { + ko.bindingHandlers.setChildContext = { + init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { + ko.applyBindingsToDescendants( + bindingContext.createChildContext(function() { return ko.utils.unwrapObservable(valueAccessor()) }), + element); + return { controlsDescendantBindings : true }; + } + }; + + testNode.innerHTML = "
"; + var vm = ko.observable({obj1: {prop1: "First "}, prop2: "view model"}); + ko.applyBindings(vm, testNode); + expect(testNode).toContainText("First view model"); + + // change view model to new object + vm({obj1: {prop1: "Second view "}, prop2: "model"}); + expect(testNode).toContainText("Second view model"); + + // change it again + vm({obj1: {prop1: "Third view model"}, prop2: ""}); + expect(testNode).toContainText("Third view model"); + + // clear the element and the view-model (shouldn't be any errors) and the subscription should be cleared + ko.removeNode(testNode); + vm(null); + expect(vm.getSubscriptionsCount()).toEqual(0); + }); + + it('Should update all extended contexts (including values copied from the parent)', function() { + ko.bindingHandlers.withProperties = { + init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { + var innerBindingContext = bindingContext.extend(valueAccessor); + ko.applyBindingsToDescendants(innerBindingContext, element); + return { controlsDescendantBindings : true }; + } + }; + + testNode.innerHTML = "
"; + var vm = ko.observable({obj1: {prop1: "First "}, prop2: "view model"}); + ko.applyBindings(vm, testNode); + expect(testNode).toContainText("First view model"); + + // ch ange view model to new object + vm({obj1: {prop1: "Second view "}, prop2: "model"}); + expect(testNode).toContainText("Second view model"); + + // change it again + vm({obj1: {prop1: "Third view model"}, prop2: ""}); + expect(testNode).toContainText("Third view model"); + + // clear the element and the view-model (shouldn't be any errors) and the subscription should be cleared + ko.removeNode(testNode); + vm(null); + expect(vm.getSubscriptionsCount()).toEqual(0); + }); + + it('Should update an extended child context', function() { + ko.bindingHandlers.withProperties = { + init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { + var childBindingContext = bindingContext.createChildContext(null).extend(valueAccessor); + ko.applyBindingsToDescendants(childBindingContext, element); + return { controlsDescendantBindings: true }; + } + }; + + testNode.innerHTML = "
"; + var vm = ko.observable({obj1: {prop1: "First "}, prop2: "view model"}); + ko.applyBindings(vm, testNode); + expect(testNode).toContainText("First view model"); + + // ch ange view model to new object + vm({obj1: {prop1: "Second view "}, prop2: "model"}); + expect(testNode).toContainText("Second view model"); + + // change it again + vm({obj1: {prop1: "Third view model"}, prop2: ""}); + expect(testNode).toContainText("Third view model"); + + // clear the element and the view-model (shouldn't be any errors) and the subscription should be cleared + ko.removeNode(testNode); + vm(null); + expect(vm.getSubscriptionsCount()).toEqual(0); + }); + }); + describe('Order', function() { var bindingOrder; beforeEach(function() { diff --git a/src/binding/bindingAttributeSyntax.js b/src/binding/bindingAttributeSyntax.js index bc2458b00..8b89578e1 100755 --- a/src/binding/bindingAttributeSyntax.js +++ b/src/binding/bindingAttributeSyntax.js @@ -1,4 +1,4 @@ -(function () { + (function () { ko.bindingHandlers = {}; // Use an overridable method for retrieving binding handlers so that a plugins may support dynamically created handlers @@ -6,31 +6,78 @@ return ko.bindingHandlers[bindingKey]; }; - ko.bindingContext = function(dataItem, parentBindingContext, dataItemAlias) { - if (parentBindingContext) { - ko.utils.extend(this, parentBindingContext); // Inherit $root and any custom properties - this['$parentContext'] = parentBindingContext; - this['$parent'] = parentBindingContext['$data']; - this['$parents'] = (parentBindingContext['$parents'] || []).slice(0); - this['$parents'].unshift(this['$parent']); + ko.bindingContext = function(dataItemOrAccessor, parentContext, dataItemAlias, extendCallback) { + function updateContext() { + var dataItem = isFunc ? dataItemOrAccessor() : dataItemOrAccessor; + if (parentContext) { + // Register a dependency on the parent context + if (parentContext._subscribable) + parentContext._subscribable(); + // Inherit $root and any custom properties + ko.utils.extend(self, parentContext); + // Update our properties + if (subscribable) { + self['$dataFn'] = self._subscribable = subscribable; + } + } else { + self['$parents'] = []; + self['$root'] = dataItem; + // Export 'ko' in the binding context so it will be available in bindings and templates + // even if 'ko' isn't exported as a global, such as when using an AMD loader. + // See https://github.com/SteveSanderson/knockout/issues/490 + self['ko'] = ko; + } + self['$data'] = dataItem; + if (dataItemAlias) + self[dataItemAlias] = dataItem; + if (extendCallback) + extendCallback(self, parentContext, dataItem); + return self['$data']; + } + function disposeWhen() { + return !ko.utils.anyDomNodeIsAttachedToDocument(nodes); + } + + var self = this, + isFunc = typeof(dataItemOrAccessor) == "function", + nodes = [], + subscribable = ko.dependentObservable(updateContext, null, { disposeWhen: disposeWhen }); + + if (subscribable.isActive()) { + self['$dataFn'] = self._subscribable = subscribable; + + // Add properties to *subscribable* instead of *self* because any properties added to *self* may be overwritten on updates + subscribable._addNode = function(node) { + nodes.push(node); + ko.utils.domNodeDisposal.addDisposeCallback(node, function(node) { + ko.utils.arrayRemoveItem(nodes, node); + if (!nodes.length) { + subscribable.dispose(); + self._subscribable = subscribable = undefined; + } + }); + // Make sure that our parent context is watching at least one node + if (parentContext && parentContext._subscribable && !parentContext._subscribable._nodes.length) { + parentContext._subscribable._addNode(node); + } + }; + subscribable._nodes = nodes; } else { - this['$parents'] = []; - this['$root'] = dataItem; - // Export 'ko' in the binding context so it will be available in bindings and templates - // even if 'ko' isn't exported as a global, such as when using an AMD loader. - // See https://github.com/SteveSanderson/knockout/issues/490 - this['ko'] = ko; + self['$dataFn'] = function() { return self['$data']; } } - this['$data'] = dataItem; - if (dataItemAlias) - this[dataItemAlias] = dataItem; } - ko.bindingContext.prototype['createChildContext'] = function (dataItem, dataItemAlias) { - return new ko.bindingContext(dataItem, this, dataItemAlias); + ko.bindingContext.prototype['createChildContext'] = function (dataItemOrAccessor, dataItemAlias) { + return new ko.bindingContext(dataItemOrAccessor, this, dataItemAlias, function(self, parentContext) { + self['$parentContext'] = parentContext; + self['$parent'] = parentContext['$data']; + self['$parents'] = (parentContext['$parents'] || []).slice(0); + self['$parents'].unshift(self['$parent']); + }); }; ko.bindingContext.prototype['extend'] = function(properties) { - var clone = ko.utils.extend(new ko.bindingContext(), this); - return ko.utils.extend(clone, properties); + return new ko.bindingContext(this['$dataFn'], this, null, function(self) { + ko.utils.extend(self, typeof(properties) == "function" ? properties() : properties); + }); }; // Returns the valueAccesor function for a binding value @@ -170,18 +217,36 @@ if (!bindings) { var provider = ko.bindingProvider['instance'], getBindings = provider['getBindingAccessors'] || getBindingsAndMakeAccessors; - bindings = ko.dependencyDetection.ignore(getBindings, provider, [node, bindingContext]); + + var bindingsUpdater = ko.dependentObservable( + function() { + return (bindings = getBindings.call(provider, node, bindingContext)); + }, + null, { disposeWhenNodeIsRemoved: node } + ); + + if (!bindings || !bindingsUpdater.isActive()) + bindingsUpdater = null; } var bindingHandlerThatControlsDescendantBindings; if (bindings) { + var getValueAccessor = bindingsUpdater + ? function(bindingKey) { + return function() { + return evaluateValueAccessor(bindingsUpdater()[bindingKey]); + }; + } : function(bindingKey) { + return bindings[bindingKey]; + }; + // Use of allBindings as a function is maintained for backwards compatibility, but its use is deprecated function allBindings() { - return ko.utils.objectMap(bindings, evaluateValueAccessor); + return ko.utils.objectMap(bindingsUpdater ? bindingsUpdater() : bindings, evaluateValueAccessor); } // The following is the 3.x allBindings API allBindings['get'] = function(key) { - return bindings[key] && evaluateValueAccessor(bindings[key]); + return bindings[key] && evaluateValueAccessor(getValueAccessor(key)); }; allBindings['has'] = function(key) { return key in bindings; @@ -202,7 +267,7 @@ ko.dependencyDetection.ignore(function() { var handlerInitFn = bindingHandler["init"]; if (typeof handlerInitFn == "function") { - var initResult = handlerInitFn(node, bindings[bindingKey], allBindings, bindingContext['$data'], bindingContext); + var initResult = handlerInitFn(node, getValueAccessor(bindingKey), allBindings, bindingContext['$data'], bindingContext); // If this binding handler claims to control descendant bindings, make a note of this if (initResult && initResult['controlsDescendantBindings']) { @@ -218,7 +283,7 @@ function() { var handlerUpdateFn = bindingHandler["update"]; if (typeof handlerUpdateFn == "function") { - handlerUpdateFn(node, bindings[bindingKey], allBindings, bindingContext['$data'], bindingContext); + handlerUpdateFn(node, getValueAccessor(bindingKey), allBindings, bindingContext['$data'], bindingContext); } }, null, @@ -234,16 +299,19 @@ var storedBindingContextDomDataKey = "__ko_bindingContext__"; ko.storedBindingContextForNode = function (node, bindingContext) { - if (arguments.length == 2) + if (arguments.length == 2) { ko.utils.domData.set(node, storedBindingContextDomDataKey, bindingContext); - else + if (bindingContext._subscribable) + bindingContext._subscribable._addNode(node); + } else { return ko.utils.domData.get(node, storedBindingContextDomDataKey); + } } - function getBindingContext(viewModelOrBindingContext, options) { + function getBindingContext(viewModelOrBindingContext) { return viewModelOrBindingContext && (viewModelOrBindingContext instanceof ko.bindingContext) ? viewModelOrBindingContext - : new ko.bindingContext(ko.utils.peekObservable(viewModelOrBindingContext)); + : new ko.bindingContext(viewModelOrBindingContext); } ko.applyBindingAccessorsToNode = function (node, bindings, viewModelOrBindingContext) { diff --git a/src/binding/bindingProvider.js b/src/binding/bindingProvider.js index faa48499a..d20a2a42d 100644 --- a/src/binding/bindingProvider.js +++ b/src/binding/bindingProvider.js @@ -60,7 +60,7 @@ // For each scope variable, add an extra level of "with" nesting // Example result: with(sc1) { with(sc0) { return (expression) } } var rewrittenBindings = ko.expressionRewriting.preProcessBindings(bindingsString, options), - functionBody = "with($context){with($data||{}){return{" + rewrittenBindings + "}}}"; + functionBody = "with($context){with($dataFn()||{}){return{" + rewrittenBindings + "}}}"; return new Function("$context", "$element", functionBody); } })(); diff --git a/src/binding/defaultBindings/event.js b/src/binding/defaultBindings/event.js index d62ad69d5..d2fef6b56 100755 --- a/src/binding/defaultBindings/event.js +++ b/src/binding/defaultBindings/event.js @@ -2,19 +2,19 @@ // e.g. click:handler instead of the usual full-length event:{click:handler} function makeEventHandlerShortcut(eventName) { ko.bindingHandlers[eventName] = { - 'init': function(element, valueAccessor, allBindings, viewModel) { + 'init': function(element, valueAccessor, allBindings, viewModel, bindingContext) { var newValueAccessor = function () { var result = {}; result[eventName] = valueAccessor(); return result; }; - return ko.bindingHandlers['event']['init'].call(this, element, newValueAccessor, allBindings, viewModel); + return ko.bindingHandlers['event']['init'].call(this, element, newValueAccessor, allBindings, viewModel, bindingContext); } } } ko.bindingHandlers['event'] = { - 'init' : function (element, valueAccessor, allBindings, viewModel) { + 'init' : function (element, valueAccessor, allBindings, viewModel, bindingContext) { var eventsToHandle = valueAccessor() || {}; ko.utils.objectForEach(eventsToHandle, function(eventName) { if (typeof eventName == "string") { @@ -27,6 +27,7 @@ ko.bindingHandlers['event'] = { try { // Take all the event args, and prefix with the viewmodel var argsForHandler = ko.utils.makeArray(arguments); + viewModel = bindingContext['$data']; argsForHandler.unshift(viewModel); handlerReturnValue = handlerFunction.apply(viewModel, argsForHandler); } finally { diff --git a/src/binding/defaultBindings/submit.js b/src/binding/defaultBindings/submit.js index d18991cdf..e3415926b 100755 --- a/src/binding/defaultBindings/submit.js +++ b/src/binding/defaultBindings/submit.js @@ -1,11 +1,11 @@ ko.bindingHandlers['submit'] = { - 'init': function (element, valueAccessor, allBindings, viewModel) { + 'init': function (element, valueAccessor, allBindings, viewModel, bindingContext) { if (typeof valueAccessor() != "function") throw new Error("The value for a submit binding must be a function"); ko.utils.registerEventHandler(element, "submit", function (event) { var handlerReturnValue; var value = valueAccessor(); - try { handlerReturnValue = value.call(viewModel, element); } + try { handlerReturnValue = value.call(bindingContext['$data'], element); } finally { if (handlerReturnValue !== true) { // Normally we want to prevent default action. Developer can override this be explicitly returning true. if (event.preventDefault)