Skip to content

Instantly share code, notes, and snippets.

@justinbmeyer
Last active October 30, 2018 14:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save justinbmeyer/7acdca82e886b4090adaccdbfcd99953 to your computer and use it in GitHub Desktop.
Save justinbmeyer/7acdca82e886b4090adaccdbfcd99953 to your computer and use it in GitHub Desktop.
can-bind.js
"use strict";
var canReflect = require("can-reflect");
var canSymbol = require("can-symbol");
var namespace = require("can-namespace");
var queues = require("can-queues");
var canAssign = require("can-assign");
//!steal-remove-start
if(process.env.NODE_ENV !== 'production') {
var canLog = require("can-log/dev/dev");
var canReflectDeps = require("can-reflect-dependencies");
}
//!steal-remove-end
// Symbols
var getChangesSymbol = canSymbol.for("can.getChangesDependencyRecord");
var getValueSymbol = canSymbol.for("can.getValue");
var onValueSymbol = canSymbol.for("can.onValue");
var setValueSymbol = canSymbol.for("can.setValue");
// Default implementations for setting the child and parent values
function defaultSetValue(newValue, observable) {
canReflect.setValue(observable, newValue);
}
// Given an observable, stop listening to it and tear down the mutation dependencies
function turnOffListeningAndUpdate(listenToObservable, updateObservable, updateFunction, queue) {
if (listenToObservable[onValueSymbol]) {
canReflect.offValue(listenToObservable, updateFunction, queue);
//!steal-remove-start
if(process.env.NODE_ENV !== 'production') {
// The updateObservable is no longer mutated by listenToObservable
canReflectDeps.deleteMutatedBy(updateObservable, listenToObservable);
// The updateFunction no longer mutates anything
updateFunction[getChangesSymbol] = function getChangesDependencyRecord() {
};
}
//!steal-remove-end
}
}
// Given an observable, start listening to it and set up the mutation dependencies
function turnOnListeningAndUpdate(listenToObservable, updateObservable, updateFunction, queue) {
if (listenToObservable[onValueSymbol]) {
canReflect.onValue(listenToObservable, updateFunction, queue);
//!steal-remove-start
if(process.env.NODE_ENV !== 'production') {
// The updateObservable is mutated by listenToObservable
canReflectDeps.addMutatedBy(updateObservable, listenToObservable);
// The updateFunction mutates updateObservable
updateFunction[getChangesSymbol] = function getChangesDependencyRecord() {
var s = new Set();
s.add(updateObservable);
return {
valueDependencies: s
};
};
}
//!steal-remove-end
}
}
// Semaphores are used to keep track of updates to the child & parent
// For debugging purposes, Semaphore and Bind are highly coupled.
function Semaphore(binding, type) {
this.value = 0;
this._binding = binding;
this._type = type;
}
canAssign(Semaphore.prototype, {
decrement: function() {
this.value -= 1;
//!steal-remove-start
if(process.env.NODE_ENV !== 'production') {
var semaphoreData = {
type: this._type,
action: "decrement"
};
// handle older versions of can-queues
if(queues.lastTask !== undefined) {
semaphoreData.lastTask = queues.lastTask();
} else {
semaphoreData.stack = queues.stack();
}
this._binding._debugSemaphores.push(semaphoreData);
if(this.value === 0) {
this._binding._debugSemaphores = [];
}
}
//!steal-remove-end
},
increment: function(args) {
this._incremented = true;
this.value += 1;
//!steal-remove-start
if(process.env.NODE_ENV !== 'production') {
if(this.value === 1) {
this._binding._debugSemaphores = [];
}
var semaphoreData = {
type: this._type,
action: "increment",
observable: args.observable,
newValue: args.newValue,
value: this.value
};
// handle older versions of can-queues
if(queues.lastTask !== undefined) {
semaphoreData.lastTask = queues.lastTask();
} else {
semaphoreData.stack = queues.stack();
}
this._binding._debugSemaphores.push(semaphoreData);
}
//!steal-remove-end
}
});
function Bind(options) {
this._options = options;
// These parameters must be supplied
//!steal-remove-start
if(process.env.NODE_ENV !== 'production') {
if (options.child === undefined) {
throw new TypeError("You must supply a child");
}
if (options.parent === undefined) {
throw new TypeError("You must supply a parent");
}
if (options.queue && ["notify", "derive", "domUI"].indexOf(options.queue) === -1) {
throw new RangeError("Invalid queue; must be one of notify, derive, or domUI");
}
}
//!steal-remove-end
// queue; by default, domUI
if (options.queue === undefined) {
options.queue = "domUI";
}
// cycles: when an observable is set in a two-way binding, it can update the
// other bound observable, which can then update the original observable the
// “cycles” number of times. For example, a child is set and updates the parent;
// with cycles: 0, the parent could not update the child;
// with cycles: 1, the parent could update the child, which can update the parent
// with cycles: 2, the parent can update the child again, and so on and so forth…
if (options.cycles > 0 === false) {
options.cycles = 0;
}
// onInitDoNotUpdateChild is false by default
options.onInitDoNotUpdateChild =
typeof options.onInitDoNotUpdateChild === "boolean" ?
options.onInitDoNotUpdateChild
: false;
// onInitSetUndefinedParentIfChildIsDefined is true by default
options.onInitSetUndefinedParentIfChildIsDefined =
typeof options.onInitSetUndefinedParentIfChildIsDefined === "boolean" ?
options.onInitSetUndefinedParentIfChildIsDefined
: true;
// The way the cycles are tracked is through semaphores; currently, when
// either the child or parent is updated, we increase their respective
// semaphore so that if it’s two-way binding, then the “other” observable
// will only update if the total count for both semaphores is less than or
// equal to twice the number of cycles (because a cycle means two updates).
var childSemaphore = new Semaphore(this,"child");
var parentSemaphore = new Semaphore(this,"parent");
// Determine if this is a one-way or two-way binding; by default, accept
// whatever options are passed in, but if they’re not defined, then check for
// the getValue and setValue symbols on the child and parent values.
var childToParent = true;
if (typeof options.childToParent === "boolean") {
// Always let the option override any checks
childToParent = options.childToParent;
} else if (options.child[getValueSymbol] == null) {
// Child to parent won’t work if we can’t get the child’s value
childToParent = false;
} else if (options.setParent === undefined && options.parent[setValueSymbol] == null) {
// Child to parent won’t work if we can’t set the parent’s value
childToParent = false;
}
var parentToChild = true;
if (typeof options.parentToChild === "boolean") {
// Always let the option override any checks
parentToChild = options.parentToChild;
} else if (options.parent[getValueSymbol] == null) {
// Parent to child won’t work if we can’t get the parent’s value
parentToChild = false;
} else if (options.setChild === undefined && options.child[setValueSymbol] == null) {
// Parent to child won’t work if we can’t set the child’s value
parentToChild = false;
}
if (childToParent === false && parentToChild === false) {
throw new Error("Neither the child nor parent will be updated; this is a no-way binding");
}
this._childToParent = childToParent;
this._parentToChild = parentToChild;
// Custom child & parent setters can be supplied; if they aren’t provided,
// then create our own.
if (options.setChild === undefined) {
options.setChild = defaultSetValue;
}
if (options.setParent === undefined) {
options.setParent = defaultSetValue;
}
// Set the observables’ priority
if (options.priority !== undefined) {
canReflect.setPriority(options.child, options.priority);
canReflect.setPriority(options.parent, options.priority);
}
// These variables keep track of how many updates are allowed in a cycle.
// cycles is multipled by two because one update is allowed for each side of
// the binding, child and parent. One more update is allowed depending on the
// sticky option; if it’s sticky, then one more update needs to be allowed.
var allowedUpdates = options.cycles * 2;
var allowedChildUpdates = allowedUpdates + (options.sticky === "childSticksToParent" ? 1 : 0);
var allowedParentUpdates = allowedUpdates + (options.sticky === "parentSticksToChild" ? 1 : 0);
// This keeps track of whether we’re bound to the child and/or parent; this
// allows startParent() to be called first and on() can be called later to
// finish setting up the child binding. This is also checked when updating
// values; if stop() has been called but updateValue() is called, then we
// ignore the update.
this._bindingState = {
child: false,
parent: false
};
// This is the listener that’s called when the parent changes
this._updateChild = function(newValue) {
updateValue.call(this, {
bindingState: this._bindingState,
newValue: newValue,
// Some options used for debugging
debugObservableName: "child",
debugPartnerName: "parent",
// Main observable values
observable: options.child,
setValue: options.setChild,
semaphore: childSemaphore,
// If the sum of the semaphores is less than or equal to this number, then
// it’s ok to update the child with the new value.
allowedUpdates: allowedChildUpdates,
// If options.sticky === "parentSticksToChild", then after the parent sets
// the child, check to see if the child matches the parent; if not, then
// set the parent to the child’s value. This is used in cases where the
// child modifies its own value and the parent should be kept in sync with
// the child.
sticky: options.sticky === "parentSticksToChild",
// Partner observable values
partner: options.parent,
setPartner: options.setParent,
partnerSemaphore: parentSemaphore
});
}.bind(this);
// This is the listener that’s called when the child changes
this._updateParent = function(newValue) {
updateValue.call(this, {
bindingState: this._bindingState,
newValue: newValue,
// Some options used for debugging
debugObservableName: "parent",
debugPartnerName: "child",
// Main observable values
observable: options.parent,
setValue: options.setParent,
semaphore: parentSemaphore,
// If the sum of the semaphores is less than or equal to this number, then
// it’s ok to update the parent with the new value.
allowedUpdates: allowedParentUpdates,
// If options.sticky === "childSticksToParent", then after the child sets
// the parent, check to see if the parent matches the child; if not, then
// set the child to the parent’s value. This is used in cases where the
// parent modifies its own value and the child should be kept in sync with
// the parent.
sticky: options.sticky === "childSticksToParent",
// Partner observable values
partner: options.child,
setPartner: options.setChild,
partnerSemaphore: childSemaphore
});
}.bind(this);
//!steal-remove-start
if(process.env.NODE_ENV !== 'production') {
Object.defineProperty(this._updateChild, "name", {
value: options.updateChildName ? options.updateChildName : "update "+canReflect.getName(options.child),
configurable: true
});
Object.defineProperty(this._updateParent, "name", {
value: options.updateParentName ? options.updateParentName : "update "+canReflect.getName(options.parent),
configurable: true
});
}
//!steal-remove-end
}
Object.defineProperty(Bind.prototype, "parentValue", {
get: function() {
return canReflect.getValue(this._options.parent);
}
});
canAssign(Bind.prototype, {
// Turn on any bindings that haven’t already been enabled;
// also update the child or parent if need be.
start: function() {
var childValue;
var options = this._options;
var parentValue;
// The tests don’t show that it matters which is bound first, but we’ll
// bind to the parent first to stay consistent with how
// can-stache-bindings did things.
this.startParent();
this.startChild();
// Initialize the child & parent values
if (this._childToParent === true && this._parentToChild === true) {
// Two-way binding
parentValue = canReflect.getValue(options.parent);
if (parentValue === undefined) {
childValue = canReflect.getValue(options.child);
if (childValue === undefined) {
// Check if updating the child is allowed
if (options.onInitDoNotUpdateChild === false) {
this._updateChild(parentValue);
}
} else if (options.onInitSetUndefinedParentIfChildIsDefined === true) {
this._updateParent(childValue);
}
} else {
// Check if updating the child is allowed
if (options.onInitDoNotUpdateChild === false) {
this._updateChild(parentValue);
}
}
} else if (this._childToParent === true) {
// One-way child -> parent, so update the parent
childValue = canReflect.getValue(options.child);
this._updateParent(childValue);
} else if (this._parentToChild === true) {
// One-way parent -> child, so update the child
// Check if updating the child is allowed
if (options.onInitDoNotUpdateChild === false) {
parentValue = canReflect.getValue(options.parent);
this._updateChild(parentValue);
}
}
},
// Listen for changes to the child observable and update the parent
startChild: function() {
if (this._bindingState.child === false && this._childToParent === true) {
var options = this._options;
this._bindingState.child = true;
turnOnListeningAndUpdate(options.child, options.parent, this._updateParent, options.queue);
}
},
// Listen for changes to the parent observable and update the child
startParent: function() {
if (this._bindingState.parent === false && this._parentToChild === true) {
var options = this._options;
this._bindingState.parent = true;
turnOnListeningAndUpdate(options.parent, options.child, this._updateChild, options.queue);
}
},
// Turn off all the bindings
stop: function() {
var bindingState = this._bindingState;
var options = this._options;
// Turn off the parent listener
if (bindingState.parent === true && this._parentToChild === true) {
bindingState.parent = false;
turnOffListeningAndUpdate(options.parent, options.child, this._updateChild, options.queue);
}
// Turn off the child listener
if (bindingState.child === true && this._childToParent === true) {
bindingState.child = false;
turnOffListeningAndUpdate(options.child, options.parent, this._updateParent, options.queue);
}
}
});
// updateValue is a helper function that’s used by updateChild and updateParent
function updateValue(args) {
/* jshint validthis: true */
// Check to see whether the binding is active; ignore updates if it isn’t active
var bindingState = args.bindingState;
if (bindingState.child === false && bindingState.parent === false) {
// We don’t warn the user about this because it’s a common occurrence in
// can-stache-bindings, e.g. {{#if value}}<input value:bind="value"/>{{/if}}
return;
}
// Now check the semaphore; if this change is happening because the partner
// observable was just updated, we only want to update this observable again
// if the total count for both semaphores is less than or equal to the number
// of allowed updates.
var semaphore = args.semaphore;
if ((semaphore.value + args.partnerSemaphore.value) <= args.allowedUpdates) {
queues.batch.start();
// Increase the semaphore so that when the batch ends, if an update to the
// partner observable’s value is made, then it won’t update this observable
// again unless cycles are allowed.
semaphore.increment(args);
// Update the observable’s value; this uses either a custom function passed
// in when the binding was initialized or canReflect.setValue.
args.setValue(args.newValue, args.observable);
// Decrease the semaphore after all other updates have occurred
queues.mutateQueue.enqueue(semaphore.decrement, semaphore, []);
queues.batch.stop();
// Stickiness is used in cases where the call to args.setValue above might
// have resulted in the observable being set to a different value than what
// was passed into this function (args.newValue). If sticky:true, then set
// the partner observable’s value so they’re kept in sync.
if (args.sticky) {
var observableValue = canReflect.getValue(args.observable);
if (observableValue !== canReflect.getValue(args.partner)) {
args.setPartner(observableValue, args.partner);
}
}
} else {
// It’s natural for this “else” block to be hit in two-way bindings; as an
// example, if a parent gets set and the child gets updated, the child’s
// listener to update the parent will be called, but it’ll be ignored if we
// don’t want cycles. HOWEVER, if this gets called and the parent is not the
// same value as the child, then their values are going to be out of sync,
// probably unintentionally. This is worth pointing out to developers
// because it can cause unexpected behavior… some people call those bugs. :)
//!steal-remove-start
if(process.env.NODE_ENV !== 'production'){
var currentValue = canReflect.getValue(args.observable);
if (currentValue !== args.newValue) {
var warningParts = [
"can-bind: attempting to update " + args.debugObservableName + " " + canReflect.getName(args.observable) + " to new value: %o",
"…but the " + args.debugObservableName + " semaphore is at " + semaphore.value + " and the " + args.debugPartnerName + " semaphore is at " + args.partnerSemaphore.value + ". The number of allowed updates is " + args.allowedUpdates + ".",
"The " + args.debugObservableName + " value will remain unchanged; it’s currently: %o. ",
"Read https://canjs.com/doc/can-bind.html#Warnings for more information. Printing mutation history:"
];
canLog.warn(warningParts.join("\n"), args.newValue, currentValue);
if(console.groupCollapsed) {
// stores the last stack we've seen so we only need to show what's happened since the
// last increment.
var lastStack = [];
var getFromLastStack = function(stack){
if(lastStack.length) {
// walk backwards
for(var i = lastStack.length - 1; i >= 0 ; i--) {
var index = stack.indexOf(lastStack[i]);
if(index !== - 1) {
return stack.slice(i+1);
}
}
}
return stack;
};
var getStackFromMutationData = function(mutationData){
return mutationData.stack ? mutationData.stack :
queues.stack(mutationData.lastTask)
};
// Loop through all the debug information
// And print out what caused increments.
this._debugSemaphores.forEach(function(semaphoreMutation){
if(semaphoreMutation.action === "increment") {
console.groupCollapsed(semaphoreMutation.type+" "+canReflect.getName(semaphoreMutation.observable)+" set.");
var stack = getFromLastStack(getStackFromMutationData(semaphoreMutation.stack));
lastStack = semaphoreMutation.stack;
// This steals how `logStack` logs information.
queues.logStack.call({
stack: function(){
return stack;
}
});
console.log(semaphoreMutation.type+ " semaphore incremented to "+semaphoreMutation.value+".");
console.log(canReflect.getName(semaphoreMutation.observable),semaphoreMutation.observable,"set to ", semaphoreMutation.newValue);
console.groupEnd();
}
});
console.groupCollapsed(args.debugObservableName+" "+canReflect.getName(args.observable)+" NOT set.");
var stack = getFromLastStack(queues.stack());
queues.logStack.call({
stack: function(){
return stack;
}
});
console.log(args.debugObservableName+" semaphore ("+semaphore.value+
") + "+args.debugPartnerName+" semaphore ("+args.partnerSemaphore.value+ ") IS NOT <= allowed updates ("+
args.allowedUpdates+")");
console.log("Prevented from setting "+canReflect.getName(args.observable), args.observable, "to", args.newValue);
console.groupEnd();
}
}
}
//!steal-remove-end
}
}
module.exports = namespace.Bind = Bind;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment