Bug 684958 - DOM Templater should include async functionality via promises; r=rcampbell
authorJoe Walker <jwalker@mozilla.com>
Mon, 03 Oct 2011 12:09:51 -0300
changeset 78709 4a06aceb7922954c5a946965e4d07eb6604df361
parent 78708 e249ca88cad727082474b6a239c66997e549f09f
child 78710 f425a8109714005827b6a365415c192c82225440
push id506
push userclegnitto@mozilla.com
push dateWed, 09 Nov 2011 02:03:18 +0000
treeherdermozilla-aurora@63587fc7bb93 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrcampbell
bugs684958
milestone10.0a1
Bug 684958 - DOM Templater should include async functionality via promises; r=rcampbell
browser/devtools/shared/Templater.jsm
old mode 100644
new mode 100755
--- a/browser/devtools/shared/Templater.jsm
+++ b/browser/devtools/shared/Templater.jsm
@@ -33,238 +33,268 @@
  * use your version of this file under the terms of the MPL, indicate your
  * decision by deleting the provisions above and replace them with the notice
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
-// WARNING: do not 'use_strict' without reading the notes in envEval;
 
 var EXPORTED_SYMBOLS = ["Templater"];
 
 const Ci = Components.interfaces;
 const Cu = Components.utils;
 
 Cu.import("resource://gre/modules/Services.jsm");
 
-var Node = Ci.nsIDOMNode;
+const Node = Ci.nsIDOMNode;
+
+// WARNING: do not 'use_strict' without reading the notes in _envEval();
 
 /**
  * A templater that allows one to quickly template DOM nodes.
  */
 function Templater() {
-  this.scope = [];
+  this.stack = [];
 }
 
 /**
  * Recursive function to walk the tree processing the attributes as it goes.
  * @param node the node to process. If you pass a string in instead of a DOM
  * element, it is assumed to be an id for use with document.getElementById()
  * @param data the data to use for node processing.
  */
 Templater.prototype.processNode = function(node, data) {
   if (typeof node === 'string') {
     node = document.getElementById(node);
   }
-  if (data === null || data === undefined) {
+  if (data == null) {
     data = {};
   }
-  this.scope.push(node.nodeName + (node.id ? '#' + node.id : ''));
+  this.stack.push(node.nodeName + (node.id ? '#' + node.id : ''));
   try {
     // Process attributes
     if (node.attributes && node.attributes.length) {
       // We need to handle 'foreach' and 'if' first because they might stop
       // some types of processing from happening, and foreach must come first
       // because it defines new data on which 'if' might depend.
       if (node.hasAttribute('foreach')) {
-        this.processForEach(node, data);
+        this._processForEach(node, data);
         return;
       }
       if (node.hasAttribute('if')) {
-        if (!this.processIf(node, data)) {
+        if (!this._processIf(node, data)) {
           return;
         }
       }
       // Only make the node available once we know it's not going away
       data.__element = node;
       // It's good to clean up the attributes when we've processed them,
       // but if we do it straight away, we mess up the array index
       var attrs = Array.prototype.slice.call(node.attributes);
       for (var i = 0; i < attrs.length; i++) {
         var value = attrs[i].value;
         var name = attrs[i].name;
-        this.scope.push(name);
+        this.stack.push(name);
         try {
           if (name === 'save') {
             // Save attributes are a setter using the node
-            value = this.stripBraces(value);
-            this.property(value, data, node);
+            value = this._stripBraces(value);
+            this._property(value, data, node);
             node.removeAttribute('save');
           } else if (name.substring(0, 2) === 'on') {
             // Event registration relies on property doing a bind
-            value = this.stripBraces(value);
-            var func = this.property(value, data);
+            value = this._stripBraces(value);
+            var func = this._property(value, data);
             if (typeof func !== 'function') {
-              this.handleError('Expected ' + value +
+              this._handleError('Expected ' + value +
                 ' to resolve to a function, but got ' + typeof func);
             }
             node.removeAttribute(name);
             var capture = node.hasAttribute('capture' + name.substring(2));
             node.addEventListener(name.substring(2), func, capture);
             if (capture) {
               node.removeAttribute('capture' + name.substring(2));
             }
           } else {
             // Replace references in all other attributes
             var newValue = value.replace(/\$\{[^}]*\}/g, function(path) {
-              return this.envEval(path.slice(2, -1), data, value);
+              return this._envEval(path.slice(2, -1), data, value);
             }.bind(this));
             // Remove '_' prefix of attribute names so the DOM won't try
             // to use them before we've processed the template
             if (name.charAt(0) === '_') {
               node.removeAttribute(name);
               node.setAttribute(name.substring(1), newValue);
             } else if (value !== newValue) {
               attrs[i].value = newValue;
             }
           }
         } finally {
-          this.scope.pop();
+          this.stack.pop();
         }
       }
     }
 
     // Loop through our children calling processNode. First clone them, so the
     // set of nodes that we visit will be unaffected by additions or removals.
     var childNodes = Array.prototype.slice.call(node.childNodes);
     for (var j = 0; j < childNodes.length; j++) {
       this.processNode(childNodes[j], data);
     }
 
     if (node.nodeType === Node.TEXT_NODE) {
-      this.processTextNode(node, data);
+      this._processTextNode(node, data);
     }
   } finally {
-    this.scope.pop();
+    delete data.__element;
+    this.stack.pop();
   }
 };
 
 /**
  * Handle <x if="${...}">
  * @param node An element with an 'if' attribute
- * @param data The data to use with envEval
+ * @param data The data to use with _envEval()
  * @returns true if processing should continue, false otherwise
  */
-Templater.prototype.processIf = function(node, data) {
-  this.scope.push('if');
+Templater.prototype._processIf = function(node, data) {
+  this.stack.push('if');
   try {
     var originalValue = node.getAttribute('if');
-    var value = this.stripBraces(originalValue);
+    var value = this._stripBraces(originalValue);
     var recurse = true;
     try {
-      var reply = this.envEval(value, data, originalValue);
+      var reply = this._envEval(value, data, originalValue);
       recurse = !!reply;
     } catch (ex) {
-      this.handleError('Error with \'' + value + '\'', ex);
+      this._handleError('Error with \'' + value + '\'', ex);
       recurse = false;
     }
     if (!recurse) {
       node.parentNode.removeChild(node);
     }
     node.removeAttribute('if');
     return recurse;
   } finally {
-    this.scope.pop();
+    this.stack.pop();
   }
 };
 
 /**
  * Handle <x foreach="param in ${array}"> and the special case of
- * <loop foreach="param in ${array}">
+ * <loop foreach="param in ${array}">.
+ * This function is responsible for extracting what it has to do from the
+ * attributes, and getting the data to work on (including resolving promises
+ * in getting the array). It delegates to _processForEachLoop to actually
+ * unroll the data.
  * @param node An element with a 'foreach' attribute
- * @param data The data to use with envEval
+ * @param data The data to use with _envEval()
  */
-Templater.prototype.processForEach = function(node, data) {
-  this.scope.push('foreach');
+Templater.prototype._processForEach = function(node, data) {
+  this.stack.push('foreach');
   try {
     var originalValue = node.getAttribute('foreach');
     var value = originalValue;
 
     var paramName = 'param';
     if (value.charAt(0) === '$') {
       // No custom loop variable name. Use the default: 'param'
-      value = this.stripBraces(value);
+      value = this._stripBraces(value);
     } else {
       // Extract the loop variable name from 'NAME in ${ARRAY}'
       var nameArr = value.split(' in ');
       paramName = nameArr[0].trim();
-      value = this.stripBraces(nameArr[1].trim());
+      value = this._stripBraces(nameArr[1].trim());
     }
     node.removeAttribute('foreach');
     try {
-      var self = this;
-      // Process a single iteration of a loop
-      var processSingle = function(member, clone, ref) {
-        ref.parentNode.insertBefore(clone, ref);
-        data[paramName] = member;
-        self.processNode(clone, data);
-        delete data[paramName];
-      };
-
-      // processSingle is no good for <loop> nodes where we want to work on
-      // the childNodes rather than the node itself
-      var processAll = function(scope, member) {
-        self.scope.push(scope);
-        try {
-          if (node.nodeName.toLowerCase() === 'loop') {
-            for (var i = 0; i < node.childNodes.length; i++) {
-              var clone = node.childNodes[i].cloneNode(true);
-              processSingle(member, clone, node);
-            }
-          } else {
-            var clone = node.cloneNode(true);
-            clone.removeAttribute('foreach');
-            processSingle(member, clone, node);
-          }
-        } finally {
-          self.scope.pop();
-        }
-      };
-
-      var reply = this.envEval(value, data, originalValue);
-      if (Array.isArray(reply)) {
-        reply.forEach(function(data, i) {
-          processAll('' + i, data);
-        }, this);
-      } else {
-        for (var param in reply) {
-          if (reply.hasOwnProperty(param)) {
-            processAll(param, param);
-          }
-        }
-      }
+      var evaled = this._envEval(value, data, originalValue);
+      this._handleAsync(evaled, node, function(reply, siblingNode) {
+        this._processForEachLoop(reply, node, siblingNode, data, paramName);
+      }.bind(this));
       node.parentNode.removeChild(node);
     } catch (ex) {
-      this.handleError('Error with \'' + value + '\'', ex);
+      this._handleError('Error with \'' + value + '\'', ex);
     }
   } finally {
-    this.scope.pop();
+    this.stack.pop();
+  }
+};
+
+/**
+ * Called by _processForEach to handle looping over the data in a foreach loop.
+ * This works with both arrays and objects.
+ * Calls _processForEachMember() for each member of 'set'
+ * @param set The object containing the data to loop over
+ * @param template The node to copy for each set member
+ * @param sibling The sibling node to which we add things
+ * @param data the data to use for node processing
+ * @param paramName foreach loops have a name for the parameter currently being
+ * processed. The default is 'param'. e.g. <loop foreach="param in ${x}">...
+ */
+Templater.prototype._processForEachLoop = function(set, template, sibling, data, paramName) {
+  if (Array.isArray(set)) {
+    set.forEach(function(member, i) {
+      this._processForEachMember(member, template, sibling, data, paramName, '' + i);
+    }, this);
+  } else {
+    for (var member in set) {
+      if (set.hasOwnProperty(member)) {
+        this._processForEachMember(member, template, sibling, data, paramName, member);
+      }
+    }
+  }
+};
+
+/**
+ * Called by _processForEachLoop() to resolve any promises in the array (the
+ * array itself can also be a promise, but that is resolved by
+ * _processForEach()). Handle <LOOP> elements (which are taken out of the DOM),
+ * clone the template, and pass the processing on to processNode().
+ * @param member The data item to use in templating
+ * @param template The node to copy for each set member
+ * @param siblingNode The parent node to which we add things
+ * @param data the data to use for node processing
+ * @param paramName The name given to 'member' by the foreach attribute
+ * @param frame A name to push on the stack for debugging
+ */
+Templater.prototype._processForEachMember = function(member, template, siblingNode, data, paramName, frame) {
+  this.stack.push(frame);
+  try {
+    this._handleAsync(member, siblingNode, function(reply, node) {
+      data[paramName] = reply;
+      if (node.nodeName.toLowerCase() === 'loop') {
+        for (var i = 0; i < node.childNodes.length; i++) {
+          var clone = node.childNodes[i].cloneNode(true);
+          node.parentNode.insertBefore(clone, node);
+          this.processNode(clone, data);
+        }
+      } else {
+        var clone = template.cloneNode(true);
+        clone.removeAttribute('foreach');
+        node.parentNode.insertBefore(clone, node);
+        this.processNode(clone, data);
+      }
+      delete data[paramName];
+    }.bind(this));
+  } finally {
+    this.stack.pop();
   }
 };
 
 /**
  * Take a text node and replace it with another text node with the ${...}
  * sections parsed out. We replace the node by altering node.parentNode but
  * we could probably use a DOM Text API to achieve the same thing.
  * @param node The Text node to work on
- * @param data The data to use in calls to envEval
+ * @param data The data to use in calls to _envEval()
  */
-Templater.prototype.processTextNode = function(node, data) {
+Templater.prototype._processTextNode = function(node, data) {
   // Replace references in other attributes
   var value = node.data;
   // We can't use the string.replace() with function trick (see generic
   // attribute processing in processNode()) because we need to support
   // functions that return DOM nodes, so we can't have the conversion to a
   // string.
   // Instead we process the string as an array of parts. In order to split
   // the string up, we first replace '${' with '\uF001$' and '}' with '\uF002'
@@ -274,135 +304,170 @@ Templater.prototype.processTextNode = fu
   value = value.replace(/\$\{([^}]*)\}/g, '\uF001$$$1\uF002');
   var parts = value.split(/\uF001|\uF002/);
   if (parts.length > 1) {
     parts.forEach(function(part) {
       if (part === null || part === undefined || part === '') {
         return;
       }
       if (part.charAt(0) === '$') {
-        part = this.envEval(part.slice(1), data, node.data);
-      }
-      // It looks like this was done a few lines above but see envEval
-      if (part === null) {
-        part = "null";
+        part = this._envEval(part.slice(1), data, node.data);
       }
-      if (part === undefined) {
-        part = "undefined";
-      }
-      // if (isDOMElement(part)) { ... }
-      if (typeof part.cloneNode !== 'function') {
-        part = node.ownerDocument.createTextNode(part.toString());
-      }
-      node.parentNode.insertBefore(part, node);
+      this._handleAsync(part, node, function(reply, siblingNode) {
+        reply = this._toNode(reply, siblingNode.ownerDocument);
+        siblingNode.parentNode.insertBefore(reply, siblingNode);
+      }.bind(this));
     }, this);
     node.parentNode.removeChild(node);
   }
 };
 
 /**
+ * Helper to convert a 'thing' to a DOM Node.
+ * This is (obviously) a no-op for DOM Elements (which are detected using
+ * 'typeof thing.cloneNode !== "function"' (is there a better way that will
+ * work in all environments, including a .jsm?)
+ * Non DOM elements are converted to a string and wrapped in a TextNode.
+ */
+Templater.prototype._toNode = function(thing, document) {
+  if (thing == null) {
+    thing = '' + thing;
+  }
+  // if (isDOMElement(reply)) { ... }
+  if (typeof thing.cloneNode !== 'function') {
+    thing = document.createTextNode(thing.toString());
+  }
+  return thing;
+};
+
+/**
+ * A function to handle the fact that some nodes can be promises, so we check
+ * and resolve if needed using a marker node to keep our place before calling
+ * an inserter function.
+ * @param thing The object which could be real data or a promise of real data
+ * we use it directly if it's not a promise, or resolve it if it is.
+ * @param siblingNode The element before which we insert new elements.
+ * @param inserter The function to to the insertion. If thing is not a promise
+ * then _handleAsync() is just 'inserter(thing, siblingNode)'
+ */
+Templater.prototype._handleAsync = function(thing, siblingNode, inserter) {
+  if (typeof thing.then === 'function') {
+    // Placeholder element to be replaced once we have the real data
+    var tempNode = siblingNode.ownerDocument.createElement('span');
+    siblingNode.parentNode.insertBefore(tempNode, siblingNode);
+    thing.then(function(delayed) {
+      inserter(delayed, tempNode);
+      tempNode.parentNode.removeChild(tempNode);
+    }.bind(this));
+  }
+  else {
+    inserter(thing, siblingNode);
+  }
+};
+
+/**
  * Warn of string does not begin '${' and end '}'
  * @param str the string to check.
  * @return The string stripped of ${ and }, or untouched if it does not match
  */
-Templater.prototype.stripBraces = function(str) {
+Templater.prototype._stripBraces = function(str) {
   if (!str.match(/\$\{.*\}/g)) {
-    this.handleError('Expected ' + str + ' to match ${...}');
+    this._handleError('Expected ' + str + ' to match ${...}');
     return str;
   }
   return str.slice(2, -1);
 };
 
 /**
  * Combined getter and setter that works with a path through some data set.
  * For example:
  * <ul>
- * <li>property('a.b', { a: { b: 99 }}); // returns 99
- * <li>property('a', { a: { b: 99 }}); // returns { b: 99 }
- * <li>property('a', { a: { b: 99 }}, 42); // returns 99 and alters the
+ * <li>_property('a.b', { a: { b: 99 }}); // returns 99
+ * <li>_property('a', { a: { b: 99 }}); // returns { b: 99 }
+ * <li>_property('a', { a: { b: 99 }}, 42); // returns 99 and alters the
  * input data to be { a: { b: 42 }}
  * </ul>
  * @param path An array of strings indicating the path through the data, or
  * a string to be cut into an array using <tt>split('.')</tt>
  * @param data An object to look in for the <tt>path</tt> argument
  * @param newValue (optional) If defined, this value will replace the
  * original value for the data at the path specified.
  * @return The value pointed to by <tt>path</tt> before any
  * <tt>newValue</tt> is applied.
  */
-Templater.prototype.property = function(path, data, newValue) {
-  this.scope.push(path);
+Templater.prototype._property = function(path, data, newValue) {
+  this.stack.push(path);
   try {
     if (typeof path === 'string') {
       path = path.split('.');
     }
     var value = data[path[0]];
     if (path.length === 1) {
       if (newValue !== undefined) {
         data[path[0]] = newValue;
       }
       if (typeof value === 'function') {
         return value.bind(data);
       }
       return value;
     }
     if (!value) {
-      this.handleError('Can\'t find path=' + path);
+      this._handleError('Can\'t find path=' + path);
       return null;
     }
-    return this.property(path.slice(1), value, newValue);
+    return this._property(path.slice(1), value, newValue);
   } finally {
-    this.scope.pop();
+    this.stack.pop();
   }
 };
 
 /**
  * Like eval, but that creates a context of the variables in <tt>env</tt> in
  * which the script is evaluated.
  * WARNING: This script uses 'with' which is generally regarded to be evil.
  * The alternative is to create a Function at runtime that takes X parameters
  * according to the X keys in the env object, and then call that function using
  * the values in the env object. This is likely to be slow, but workable.
  * @param script The string to be evaluated.
- * @param env The environment in which to eval the script.
- * @param context Optional debugging string in case of failure
+ * @param data The environment in which to eval the script.
+ * @param frame Optional debugging string in case of failure.
  * @return The return value of the script, or the error message if the script
  * execution failed.
  */
-Templater.prototype.envEval = function(script, env, context) {
-  with (env) {
+Templater.prototype._envEval = function(script, data, frame) {
+  with (data) {
     try {
-      this.scope.push(context);
+      this.stack.push(frame);
       return eval(script);
     } catch (ex) {
-      this.handleError('Template error evaluating \'' + script + '\'' +
-          ' environment=' + Object.keys(env).join(', '), ex);
+      this._handleError('Template error evaluating \'' + script + '\'' +
+          ' environment=' + Object.keys(data).join(', '), ex);
       return script;
     } finally {
-      this.scope.pop();
+      this.stack.pop();
     }
   }
 };
 
 /**
  * A generic way of reporting errors, for easy overloading in different
  * environments.
  * @param message the error message to report.
  * @param ex optional associated exception.
  */
-Templater.prototype.handleError = function(message, ex) {
-  this.logError(message);
-  this.logError('In: ' + this.scope.join(' > '));
+Templater.prototype._handleError = function(message, ex) {
+  this._logError(message);
+  this._logError('In: ' + this.stack.join(' > '));
   if (ex) {
-    this.logError(ex);
+    this._logError(ex);
   }
 };
 
 
 /**
  * A generic way of reporting errors, for easy overloading in different
  * environments.
  * @param message the error message to report.
  */
-Templater.prototype.logError = function(message) {
+Templater.prototype._logError = function(message) {
   Services.console.logStringMessage(message);
 };