');
+
+ // Even though the #if was stable, a dirty child node is updated
+ object.value = 'goodbye world';
+ var textRenderNode = result.root.childNodes[0].childNodes[0];
+ textRenderNode.isDirty = true;
+ result.revalidate();
+ equalTokens(result.fragment, '
goodbye world
');
+
+ // Should not update since render node is not marked as dirty
+ object.condition = false;
+ result.revalidate();
+ equalTokens(result.fragment, '
');
+});
+
+test("block helpers whose template has a morph at the edge", function() {
+ registerHelper('id', function(params, hash, options) {
+ return options.template.yield();
+ });
+
+ var template = compile("{{#id}}{{value}}{{/id}}");
+ var object = { value: "hello world" };
+ var result = template.render(object, env);
+
+ equalTokens(result.fragment, 'hello world');
+ var firstNode = result.root.firstNode;
+ equal(firstNode.nodeType, 3, "first node of the parent template");
+ equal(firstNode.textContent, "", "its content should be empty");
+
+ var secondNode = firstNode.nextSibling;
+ equal(secondNode.nodeType, 3, "first node of the helper template should be a text node");
+ equal(secondNode.textContent, "", "its content should be empty");
+
+ var textContent = secondNode.nextSibling;
+ equal(textContent.nodeType, 3, "second node of the helper should be a text node");
+ equal(textContent.textContent, "hello world", "its content should be hello world");
+
+ var fourthNode = textContent.nextSibling;
+ equal(fourthNode.nodeType, 3, "last node of the helper should be a text node");
+ equal(fourthNode.textContent, "", "its content should be empty");
+
+ var lastNode = fourthNode.nextSibling;
+ equal(lastNode.nodeType, 3, "last node of the parent template should be a text node");
+ equal(lastNode.textContent, "", "its content should be empty");
+
+ strictEqual(lastNode.nextSibling, null, "there should only be five nodes");
+});
+
+test("clean content doesn't get blown away", function() {
+ var template = compile("
{{value}}
");
+ var object = { value: "hello" };
+ var result = template.render(object, env);
+
+ var textNode = result.fragment.firstChild.firstChild;
+ equal(textNode.textContent, "hello");
+
+ object.value = "goodbye";
+ result.revalidate(); // without setting the node to dirty
+
+ equalTokens(result.fragment, '
hello
');
+
+ var textRenderNode = result.root.childNodes[0];
+
+ textRenderNode.setContent = function() {
+ ok(false, "Should not get called");
+ };
+
+ object.value = "hello";
+ result.dirty();
+ result.revalidate();
+});
+
+test("helper calls follow the normal dirtying rules", function() {
+ registerHelper('capitalize', function(params) {
+ return params[0].toUpperCase();
+ });
+
+ var template = compile("
{{capitalize value}}
");
+ var object = { value: "hello" };
+ var result = template.render(object, env);
+
+ var textNode = result.fragment.firstChild.firstChild;
+ equal(textNode.textContent, "HELLO");
+
+ object.value = "goodbye";
+ result.revalidate(); // without setting the node to dirty
+
+ equalTokens(result.fragment, '
");
+
+ attrRenderNode.setContent = function() {
+ ok(false, "Should not get called");
+ };
+
+ object.value = "universe";
+ result.dirty();
+ result.revalidate();
+});
diff --git a/packages/htmlbars-compiler/tests/html-compiler-test.js b/packages/htmlbars-compiler/tests/html-compiler-test.js
index a104be3c..541440be 100644
--- a/packages/htmlbars-compiler/tests/html-compiler-test.js
+++ b/packages/htmlbars-compiler/tests/html-compiler-test.js
@@ -1,11 +1,9 @@
import { compile } from "../htmlbars-compiler/compiler";
import { forEach } from "../htmlbars-util/array-utils";
-import { tokenize } from "../simple-html-tokenizer";
import defaultHooks from "../htmlbars-runtime/hooks";
-import defaultHelpers from "../htmlbars-runtime/helpers";
import { merge } from "../htmlbars-util/object-utils";
import DOMHelper from "../dom-helper";
-import { createObject, normalizeInnerHTML, getTextContent } from "../htmlbars-test-helpers";
+import { normalizeInnerHTML, getTextContent, equalTokens } from "../htmlbars-test-helpers";
var xhtmlNamespace = "http://www.w3.org/1999/xhtml",
svgNamespace = "http://www.w3.org/2000/svg";
@@ -26,13 +24,6 @@ var innerHTMLHandlesNewlines = (function() {
return div.innerHTML.length === 8;
})();
-// IE8 removes comments and does other unspeakable things with innerHTML
-var ie8GenerateTokensNeeded = (function() {
- var div = document.createElement("div");
- div.innerHTML = "";
- return div.innerHTML === "";
-})();
-
function registerHelper(name, callback) {
helpers[name] = callback;
}
@@ -43,55 +34,15 @@ function registerPartial(name, html) {
function compilesTo(html, expected, context) {
var template = compile(html);
- var fragment = template.render(context, env, document.body);
+ var fragment = template.render(context, env, { contextualElement: document.body }).fragment;
equalTokens(fragment, expected === undefined ? html : expected);
return fragment;
}
-function generateTokens(fragmentOrHtml) {
- var div = document.createElement("div");
- if (typeof fragmentOrHtml === 'string') {
- div.innerHTML = fragmentOrHtml;
- } else {
- div.appendChild(fragmentOrHtml.cloneNode(true));
- }
- if (ie8GenerateTokensNeeded) {
- // IE8 drops comments and does other unspeakable things on `innerHTML`.
- // So in that case we do it to both the expected and actual so that they match.
- var div2 = document.createElement("div");
- div2.innerHTML = div.innerHTML;
- div.innerHTML = div2.innerHTML;
- }
- return tokenize(div.innerHTML);
-}
-
-function equalTokens(fragment, html) {
- var fragTokens = generateTokens(fragment);
- var htmlTokens = generateTokens(html);
-
- function normalizeTokens(token) {
- if (token.type === 'StartTag') {
- token.attributes = token.attributes.sort(function(a,b){
- if (a.name > b.name) {
- return 1;
- }
- if (a.name < b.name) {
- return -1;
- }
- return 0;
- });
- }
- }
-
- forEach(fragTokens, normalizeTokens);
- forEach(htmlTokens, normalizeTokens);
-
- deepEqual(fragTokens, htmlTokens);
-}
function commonSetup() {
hooks = merge({}, defaultHooks);
- helpers = merge({}, defaultHelpers);
+ helpers = {};
partials = {};
env = {
@@ -109,56 +60,69 @@ QUnit.module("HTML-based compiler (output)", {
test("Simple content produces a document fragment", function() {
var template = compile("content");
- var fragment = template.render({}, env);
+ var fragment = template.render({}, env).fragment;
equalTokens(fragment, "content");
});
test("Simple elements are created", function() {
var template = compile("
hello!
content
");
- var fragment = template.render({}, env);
+ var fragment = template.render({}, env).fragment;
equalTokens(fragment, "
hello!
content
");
});
+test("Simple elements can be re-rendered", function() {
+ var template = compile("
hello!
content
");
+ var result = template.render({}, env);
+ var fragment = result.fragment;
+
+ var oldFirstChild = fragment.firstChild;
+
+ result.revalidate();
+
+ strictEqual(fragment.firstChild, oldFirstChild);
+ equalTokens(fragment, "
hello!
content
");
+});
+
test("Simple elements can have attributes", function() {
var template = compile("
content
");
- var fragment = template.render({}, env);
+ var fragment = template.render({}, env).fragment;
equalTokens(fragment, '
content
');
});
test("Simple elements can have an empty attribute", function() {
var template = compile("
content
");
- var fragment = template.render({}, env);
+ var fragment = template.render({}, env).fragment;
equalTokens(fragment, '
content
');
});
test("presence of `disabled` attribute without value marks as disabled", function() {
var template = compile('');
- var inputNode = template.render({}, env).firstChild;
+ var inputNode = template.render({}, env).fragment.firstChild;
ok(inputNode.disabled, 'disabled without value set as property is true');
});
test("Null quoted attribute value calls toString on the value", function() {
var template = compile('');
- var inputNode = template.render({isDisabled: null}, env).firstChild;
+ var inputNode = template.render({isDisabled: null}, env).fragment.firstChild;
ok(inputNode.disabled, 'string of "null" set as property is true');
});
test("Null unquoted attribute value removes that attribute", function() {
var template = compile('');
- var inputNode = template.render({isDisabled: null}, env).firstChild;
+ var inputNode = template.render({isDisabled: null}, env).fragment.firstChild;
equalTokens(inputNode, '');
});
test("unquoted attribute string is just that", function() {
var template = compile('');
- var inputNode = template.render({}, env).firstChild;
+ var inputNode = template.render({}, env).fragment.firstChild;
equal(inputNode.tagName, 'INPUT', 'input tag');
equal(inputNode.value, 'funstuff', 'value is set as property');
@@ -166,7 +130,7 @@ test("unquoted attribute string is just that", function() {
test("unquoted attribute expression is string", function() {
var template = compile('');
- var inputNode = template.render({funstuff: "oh my"}, env).firstChild;
+ var inputNode = template.render({funstuff: "oh my"}, env).fragment.firstChild;
equal(inputNode.tagName, 'INPUT', 'input tag');
equal(inputNode.value, 'oh my', 'string is set to property');
@@ -174,7 +138,7 @@ test("unquoted attribute expression is string", function() {
test("unquoted attribute expression works when followed by another attribute", function() {
var template = compile('');
- var divNode = template.render({funstuff: "oh my"}, env).firstChild;
+ var divNode = template.render({funstuff: "oh my"}, env).fragment.firstChild;
equalTokens(divNode, '');
});
@@ -194,13 +158,13 @@ test("Unquoted attribute value with multiple nodes throws an exception", functio
test("Simple elements can have arbitrary attributes", function() {
var template = compile("
content
");
- var divNode = template.render({}, env).firstChild;
+ var divNode = template.render({}, env).fragment.firstChild;
equalTokens(divNode, '
content
');
});
test("checked attribute and checked property are present after clone and hydrate", function() {
var template = compile("");
- var inputNode = template.render({}, env).firstChild;
+ var inputNode = template.render({}, env).fragment.firstChild;
equal(inputNode.tagName, 'INPUT', 'input tag');
equal(inputNode.checked, true, 'input tag is checked');
});
@@ -209,7 +173,7 @@ test("checked attribute and checked property are present after clone and hydrate
function shouldBeVoid(tagName) {
var html = "<" + tagName + " data-foo='bar'>
hello
";
var template = compile(html);
- var fragment = template.render({}, env);
+ var fragment = template.render({}, env).fragment;
var div = document.createElement("div");
@@ -234,7 +198,7 @@ test("Void elements are self-closing", function() {
test("The compiler can handle nesting", function() {
var html = '
hi!
More content';
var template = compile(html);
- var fragment = template.render({}, env);
+ var fragment = template.render({}, env).fragment;
equalTokens(fragment, html);
});
@@ -309,7 +273,7 @@ test("The compiler can handle top-level unescaped HTML", function() {
test("The compiler can handle top-level unescaped tr", function() {
var template = compile('{{{html}}}');
var context = { html: '
Yo
' };
- var fragment = template.render(context, env, document.createElement('table'));
+ var fragment = template.render(context, env, { contextualElement: document.createElement('table') }).fragment;
equal(
fragment.firstChild.nextSibling.tagName, 'TR',
@@ -319,7 +283,7 @@ test("The compiler can handle top-level unescaped tr", function() {
test("The compiler can handle top-level unescaped td inside tr contextualElement", function() {
var template = compile('{{{html}}}');
var context = { html: '
Yo
' };
- var fragment = template.render(context, env, document.createElement('tr'));
+ var fragment = template.render(context, env, { contextualElement: document.createElement('tr') }).fragment;
equal(
fragment.firstChild.nextSibling.tagName, 'TD',
@@ -327,13 +291,13 @@ test("The compiler can handle top-level unescaped td inside tr contextualElement
});
test("The compiler can handle unescaped tr in top of content", function() {
- registerHelper('test', function(params, hash, options, env) {
- return options.template.render(this, env, options.morph.contextualElement);
+ registerHelper('test', function() {
+ return this.yield();
});
var template = compile('{{#test}}{{{html}}}{{/test}}');
var context = { html: '
Yo
' };
- var fragment = template.render(context, env, document.createElement('table'));
+ var fragment = template.render(context, env, { contextualElement: document.createElement('table') }).fragment;
equal(
fragment.firstChild.nextSibling.nextSibling.tagName, 'TR',
@@ -341,13 +305,13 @@ test("The compiler can handle unescaped tr in top of content", function() {
});
test("The compiler can handle unescaped tr inside fragment table", function() {
- registerHelper('test', function(params, hash, options, env) {
- return options.template.render(this, env, options.morph.contextualElement);
+ registerHelper('test', function() {
+ return this.yield();
});
var template = compile('
{{#test}}{{{html}}}{{/test}}
');
var context = { html: '
Yo
' };
- var fragment = template.render(context, env, document.createElement('div'));
+ var fragment = template.render(context, env, { contextualElement: document.createElement('div') }).fragment;
var tableNode = fragment.firstChild;
equal(
@@ -363,6 +327,23 @@ test("The compiler can handle simple helpers", function() {
compilesTo('
');
+ var result = template.render(object, env);
+
+ var fragment = result.fragment;
+
+ equalTokens(fragment, '
hello
to the world
');
+
+ object.title = '
goodbye
to the';
+
+ var oldFirstChild = fragment.firstChild;
+
+ result.revalidate(object);
+
+ strictEqual(fragment.firstChild, oldFirstChild, "Static nodes in the fragment should have stable identity");
+ equalTokens(fragment, '
goodbye
to the world
');
+
+ object.title = '
brown cow
to the';
+
+ result.revalidate(object);
+
+ strictEqual(fragment.firstChild, oldFirstChild, "Static nodes in the fragment should have stable identity");
+ equalTokens(fragment, '
+ {{/if}}
+ ```
+
+ In this case, the lexical environment at the top-level of the
+ template does not change inside of the `if` block. This is
+ achieved via an implementation of `if` that looks like this:
+
+ ```js
+ registerHelper('if', function(params) {
+ if (!!params[0]) {
+ return this.yield();
+ }
+ });
+ ```
+
+ A call to `this.yield` invokes the child template using the
+ current lexical environment.
+
+ ## Block Arguments
+
+ It is possible for nested blocks to introduce new local
+ variables:
+
+ ```hbs
+ {{#count-calls as |i|}}
+
{{title}}
+
Called {{i}} times
+ {{/count}}
+ ```
+
+ In this example, the child block inherits its surrounding
+ lexical environment, but augments it with a single new
+ variable binding.
+
+ The implementation of `count-calls` supplies the value of
+ `i`, but does not otherwise alter the environment:
+
+ ```js
+ var count = 0;
+ registerHelper('count-calls', function() {
+ return this.yield([ ++count ]);
+ });
+ ```
+*/
+
+export function wrap(template) {
+ if (template === null) { return null; }
+
+ return {
+ isHTMLBars: true,
+ blockParams: template.blockParams,
+ render: function(self, env, options, blockArguments) {
+ var scope = env.hooks.createScope(null, template.blockParams);
+ scope.self = self;
+ return render(template, env, scope, options, blockArguments);
+ }
+ };
+}
+
+export function wrapForHelper(template, env, originalScope, options) {
+ if (template === null) { return null; }
+
+ return {
+ isHTMLBars: true,
+ blockParams: template.blockParams,
+
+ yield: function(blockArguments) {
+ var scope = originalScope;
+
+ if (blockArguments !== undefined) {
+ scope = env.hooks.createScope(originalScope, template.blockParams);
+ }
+
+ return render(template, env, scope, options, blockArguments);
+ },
+
+ render: function(newSelf, blockArguments) {
+ var scope = originalScope;
+ if (newSelf !== originalScope.self || blockArguments !== undefined) {
+ scope = env.hooks.createScope(originalScope, template.blockParams);
+ scope.self = newSelf;
+ }
+
+ return render(template, env, scope, options, blockArguments);
+ }
+ };
+}
+
+function optionsFor(morph, env, scope, template, inverse) {
var options = {
- morph: morph,
- template: template,
- inverse: inverse
+ renderNode: morph,
+ env: env,
+ template: null,
+ inverse: null
};
- var helper = lookupHelper(env, context, path);
- var value = helper.call(context, params, hash, options, env);
+ options.template = wrapForHelper(template, env, scope, options);
+ options.inverse = wrapForHelper(inverse, env, scope, options);
- morph.setContent(value);
+ return options;
}
-export function inline(env, morph, context, path, params, hash) {
- var helper = lookupHelper(env, context, path);
- var value = helper.call(context, params, hash, { morph: morph }, env);
+function thisFor(options) {
+ return { yield: options.template.yield };
+}
+
+/**
+ Host Hook: createScope
+
+ @param {Scope?} parentScope
+ @param {Array} localVariables
+ @return Scope
+
+ Corresponds to entering a new HTMLBars block.
+
+ This hook is invoked when a block is entered with
+ a new `self` or additional local variables.
+
+ When invoked for a top-level template, the
+ `parentScope` is `null`, and this hook should return
+ a fresh Scope.
+
+ When invoked for a child template, the `parentScope`
+ is the scope for the parent environment, and
+ `localVariables` is an array of names of new variable
+ bindings that should be created for this scope.
+
+ Note that the `Scope` is an opaque value that is
+ passed to other host hooks. For example, the `get`
+ hook uses the scope to retrieve a value for a given
+ scope and variable name.
+*/
+export function createScope(parentScope, localVariables) {
+ var scope;
+
+ if (parentScope) {
+ scope = createObject(parentScope);
+ scope.locals = createObject(parentScope.locals);
+ } else {
+ scope = { self: null, locals: {} };
+ }
- morph.setContent(value);
+ for (var i=0, l=localVariables.length; i
+ ```
+
+ This hook is responsible for invoking a helper that
+ modifies an element.
+
+ Its purpose is largely legacy support for awkward
+ idioms that became common when using the string-based
+ Handlebars engine.
+
+ Most of the uses of the `element` hook are expected
+ to be superseded by component syntax and the
+ `attribute` hook.
+*/
+export function element(morph, env, scope, path, params, hash) {
+ if (morph.isDirty) {
+ var helper = lookupHelper(env, scope, path);
+ if (helper) {
+ helper(params, hash, { element: morph.element });
+ }
+
+ morph.isDirty = false;
}
}
-export function attribute(env, attrMorph, domElement, name, value) {
- attrMorph.setContent(value);
+/**
+ Host hook: attribute
+
+ @param {RenderNode} renderNode
+ @param {Environment} env
+ @param {String} name
+ @param {any} value
+
+ Corresponds to:
+
+ ```hbs
+
+ ```
+
+ This hook is responsible for updating a render node
+ that represents an element's attribute with a value.
+
+ It receives the name of the attribute as well as an
+ already-resolved value, and should update the render
+ node with the value if appropriate.
+*/
+export function attribute(morph, env, name, value) {
+ if (morph.isDirty) {
+ var state = morph.state;
+
+ if (state.lastValue !== value) {
+ morph.setContent(value);
+ }
+
+ state.lastValue = value;
+ morph.isDirty = false;
+ }
}
-export function subexpr(env, context, helperName, params, hash) {
- var helper = lookupHelper(env, context, helperName);
+export function subexpr(morph, env, scope, helperName, params, hash) {
+ if (!morph.isDirty) { return; }
+
+ var helper = lookupHelper(env, scope, helperName);
if (helper) {
- return helper.call(context, params, hash, {}, env);
+ return helper(params, hash, {});
} else {
- return get(env, context, helperName);
+ return env.hooks.get(morph, env, scope, helperName);
}
}
-export function get(env, context, path) {
+/**
+ Host Hook: get
+
+ @param {RenderNode} renderNode
+ @param {Environment} env
+ @param {Scope} scope
+ @param {String} path
+
+ Corresponds to:
+
+ ```hbs
+ {{foo.bar}}
+ ^
+
+ {{helper foo.bar key=value}}
+ ^ ^
+ ```
+
+ This hook is the "leaf" hook of the system. It is used to
+ resolve a path relative to the current scope.
+
+ NOTE: This should be refactored into three hooks: splitting
+ the path into parts, looking up the first part on the scope,
+ and resolving the remainder a piece at a time. It would also
+ be useful to have a "classification" hook that handles
+ classifying a name as either a helper or value.
+*/
+export function get(morph, env, scope, path) {
+ if (!morph.isDirty) { return; }
+
if (path === '') {
- return context;
+ return scope.self;
}
var keys = path.split('.');
- var value = context;
+ var value = (keys[0] in scope.locals) ? scope.locals : scope.self;
+
for (var i = 0; i < keys.length; i++) {
if (value) {
value = value[keys[i]];
@@ -68,29 +508,27 @@ export function get(env, context, path) {
return value;
}
-export function set(env, context, name, value) {
- context[name] = value;
+export function bindLocal(env, scope, name, value) {
+ scope.locals[name] = value;
}
-export function component(env, morph, context, tagName, attrs, template) {
- var helper = lookupHelper(env, context, tagName);
-
- var value;
- if (helper) {
- var options = {
- morph: morph,
- template: template
- };
+export function component(morph, env, scope, tagName, attrs, template) {
+ if (morph.isDirty) {
+ var helper = lookupHelper(env, scope, tagName);
+ if (helper) {
+ var options = optionsFor(morph, env, scope, template, null);
+ helper.call(thisFor(options), [], attrs, options);
+ } else {
+ componentFallback(morph, env, scope, tagName, attrs, template);
+ }
- value = helper.call(context, [], attrs, options, env);
- } else {
- value = componentFallback(env, morph, context, tagName, attrs, template);
+ morph.isDirty = false;
}
-
- morph.setContent(value);
}
-export function concat(env, params) {
+export function concat(morph, env, params) {
+ if (!morph.isDirty) { return; }
+
var value = "";
for (var i = 0, l = params.length; i < l; i++) {
value += params[i];
@@ -98,28 +536,43 @@ export function concat(env, params) {
return value;
}
-function componentFallback(env, morph, context, tagName, attrs, template) {
+function componentFallback(morph, env, scope, tagName, attrs, template) {
var element = env.dom.createElement(tagName);
for (var name in attrs) {
element.setAttribute(name, attrs[name]);
}
- element.appendChild(template.render(context, env, morph.contextualElement));
- return element;
+ var fragment = render(template, env, scope, {}).fragment;
+ element.appendChild(fragment);
+ morph.setNode(element);
}
-function lookupHelper(env, context, helperName) {
+function lookupHelper(env, scope, helperName) {
return env.helpers[helperName];
}
+// IE8 does not have Object.create, so use a polyfill if needed.
+// Polyfill based on Mozilla's (MDN)
+export function createObject(obj) {
+ if (typeof Object.create === 'function') {
+ return Object.create(obj);
+ } else {
+ var Temp = function() {};
+ Temp.prototype = obj;
+ return new Temp();
+ }
+}
+
export default {
+ createScope: createScope,
content: content,
block: block,
inline: inline,
+ partial: partial,
component: component,
element: element,
attribute: attribute,
subexpr: subexpr,
concat: concat,
get: get,
- set: set
+ bindLocal: bindLocal
};
diff --git a/packages/htmlbars-runtime/lib/main.js b/packages/htmlbars-runtime/lib/main.js
index d8d144e1..06e18696 100644
--- a/packages/htmlbars-runtime/lib/main.js
+++ b/packages/htmlbars-runtime/lib/main.js
@@ -1,7 +1,7 @@
import hooks from 'htmlbars-runtime/hooks';
-import helpers from 'htmlbars-runtime/helpers';
+import render from 'htmlbars-runtime/render';
export {
hooks,
- helpers
+ render
};
diff --git a/packages/htmlbars-runtime/lib/render.js b/packages/htmlbars-runtime/lib/render.js
new file mode 100644
index 00000000..dd805053
--- /dev/null
+++ b/packages/htmlbars-runtime/lib/render.js
@@ -0,0 +1,104 @@
+import { forEach } from "../htmlbars-util/array-utils";
+import ExpressionVisitor from "./expression-visitor";
+
+export default function render(template, env, scope, options, blockArguments) {
+ var dom = env.dom;
+ var contextualElement;
+
+ if (options && options.renderNode) {
+ contextualElement = options.renderNode.contextualElement;
+ } else if (options && options.contextualElement) {
+ contextualElement = options.contextualElement;
+ }
+
+ dom.detectNamespace(contextualElement);
+
+ var fragment = getCachedFragment(template, env);
+ var nodes = template.buildRenderNodes(dom, fragment, contextualElement);
+
+ var rootNode, ownerNode;
+
+ if (options && options.renderNode) {
+ rootNode = options.renderNode;
+ ownerNode = rootNode.ownerNode;
+ } else {
+ rootNode = dom.createMorph(null, fragment.firstChild, fragment.lastChild, contextualElement);
+ ownerNode = rootNode;
+ initializeNode(rootNode, ownerNode);
+ }
+
+ // TODO Invoke disposal hook recursively on old rootNode.childNodes
+
+ rootNode.childNodes = nodes;
+
+ forEach(nodes, function(node) {
+ initializeNode(node, ownerNode);
+ });
+
+ var statements = template.statements;
+ var locals = template.locals;
+
+ populateNodes(scope, blockArguments);
+
+ if (options && options.renderNode) {
+ rootNode.setContent(fragment);
+ }
+
+ return {
+ root: rootNode,
+ fragment: fragment,
+ dirty: function() {
+ var nodes = [rootNode];
+
+ while (nodes.length) {
+ var node = nodes.pop();
+ node.isDirty = true;
+ nodes.push.apply(nodes, node.childNodes);
+ }
+ },
+ revalidate: function(newScope, newBlockArguments) {
+ if (newScope !== undefined) { scope.self = newScope; }
+ populateNodes(scope, newBlockArguments || blockArguments);
+ }
+ };
+
+ function populateNodes(scope, blockArguments) {
+ var i, l;
+
+ for (i=0, l=locals.length; i b.name) {
+ return 1;
+ }
+ if (a.name < b.name) {
+ return -1;
+ }
+ return 0;
+ });
+ }
+ }
+
+ forEach(fragTokens.tokens, normalizeTokens);
+ forEach(htmlTokens.tokens, normalizeTokens);
+
+ deepEqual(fragTokens.tokens, htmlTokens.tokens, "Expected: " + html + "; Actual: " + fragTokens.html);
+}
+
// detect weird IE8 html strings
var ie8InnerHTMLTestElement = document.createElement('div');
ie8InnerHTMLTestElement.setAttribute('id', 'womp');
@@ -103,4 +157,4 @@ export function createObject(obj) {
Temp.prototype = obj;
return new Temp();
}
-}
\ No newline at end of file
+}
diff --git a/packages/morph-attr/lib/main.js b/packages/morph-attr/lib/main.js
index 33da989e..9cc5a6b6 100644
--- a/packages/morph-attr/lib/main.js
+++ b/packages/morph-attr/lib/main.js
@@ -27,6 +27,8 @@ function AttrMorph(element, attrName, domHelper, namespace) {
this.element = element;
this.domHelper = domHelper;
this.namespace = namespace !== undefined ? namespace : getAttrNamespace(attrName);
+ this.state = {};
+ this.isDirty = true;
this.escaped = true;
var normalizedAttrName = normalizeProperty(this.element, attrName);