diff --git a/LIFECYCLE.md b/LIFECYCLE.md
new file mode 100644
index 00000000..b7bbf6fd
--- /dev/null
+++ b/LIFECYCLE.md
@@ -0,0 +1,294 @@
+An HTMLBars runtime environment implements a series of hooks (and
+keywords) that are responsible for guaranteeing the most important
+property of an HTMLBars template: idempotence.
+
+This means that a template that is re-rendered with the same dynamic
+environment will result in the same DOM nodes (with the same identity)
+as the first render.
+
+HTMLBars comes with support for idempotent helpers. This means that a
+helper implemented using the HTMLBars API is guaranteed to fulfill the
+idempotence requirement. That is because an HTMLBars template is a "pure
+function"; it takes in data parameters and returns data values.
+
+> Block helpers also have access to `this.yield()`, which allows them to
+> render the block passed to the block helper, but they do not have
+> access to the block itself, nor the ability to directly insert the
+> block into the DOM. As long as `this.yield()` is invoked in two
+> successive renders, HTMLBars guarantees that the second call
+> effectively becomes a no-op and does not tear down the template.
+
+HTMLBars environments are expected to implement an idempotent component
+implementation. What this means is that they are responsible for
+exposing a public API that ensures that users can write components with
+stable elements even when their attributes change. Ember.js has an
+implementation, but it's fairly involved.
+
+## Hooks
+
+An HTMLBars environment exposes a series of hooks that a runtime
+environment can use to define the behavior of templates. These hooks
+are defined on the `env` passed into an HTMLBars `render` function,
+and are invoked by HTMLBars as the template's dynamic portions are
+reached.
+
+### The Scope Hooks
+
+Scope management:
+
+* `createFreshScope`: create a new, top-level scope. The default
+ implementation of this hook creates a new scope with a `self` slot
+ for the dynamic context and `locals`, a dictionary of local
+ variables.
+* `createShadowScope`: create a new scope for a template that is
+ being rendered in the middle of the render tree with a new,
+ top-level scope (a "shadow root").
+* `createChildScope`: create a new scope that inherits from the parent
+ scope. The child scope must reflect updates to `self` or `locals` on
+ the parent scope automatically, so the default implementation of this
+ hook uses `Object.create` on both the scope object and the locals.
+* `bindSelf`: a fresh `self` value has been provided for the scope
+* `bindLocal`: a specific local variable has been provided for
+ the scope (through block arguments).
+
+Scope lookup:
+
+* `getRoot`: get the reference for the first identifier in a path. By
+ default, this first looks in `locals`, and then looks in `self`.
+* `getChild`: gets the reference for subsequent identifiers in a path.
+* `getValue`: get the JavaScript value from the reference provided
+ by the final call to `getChild`. Ember.js uses this series of
+ hooks to create stable streams for each reference that remain
+ stable across renders.
+
+> All hooks other than `getValue` operate in terms of "references",
+> which are internal values that can be evaluated in order to get a
+> value that is suitable for use in user hooks. The default
+> implementation simply uses JavaScript values, making the
+> "references" simple pass-throughs. Ember.js uses stable "stream"
+> objects for references, and evaluates them on an as-needed basis.
+
+### The Helper Hooks
+
+* `hasHelper`: does a helper exist for this name?
+* `lookupHelper`: provide a helper function for a given name
+
+### The Expression Hooks
+
+* `concat`: takes an array of references and returns a reference
+ representing the result of concatenating them.
+* `subexpr`: takes a helper name, a list of positional parameters
+ and a hash of named parameters (as references), and returns a
+ reference that, when evaluated, produces the result of invoking the
+ helper with those *evaluated* positional and named parameters.
+
+User helpers simply take positional and named parameters and return the
+result of doing some computation. They are intended to be "pure"
+functions, and are not provided with any other environment information,
+nor the DOM being built. As a result, they satisfy the idempotence
+requirement.
+
+Simple example:
+
+```hbs
+
{{upcase (format-person person)}}
+```
+
+```js
+helpers.upcase = function(params) {
+ return params[0].toUpperCase();
+};
+
+helpers['format-person'] = function(params) {
+ return person.salutation + '. ' + person.first + ' ' + person.last;
+};
+```
+
+The first time this template is rendered, the `subexpr` hook is invoked
+once for the `format-person` helper, and its result is provided to the
+`upcase` helper. The result of the `upcase` helper is then inserted into
+the DOM.
+
+The second time the template is rendered, the same hooks are called.
+HTMLBars compares the result value with the last value inserted into the
+DOM, and if they are the same, does nothing.
+
+Because HTMLBars is responsible for updating the DOM, and simply
+delegates to "pure helpers" to calculate the values to insert, it can
+guarantee idempotence.
+
+## Keywords
+
+HTMLBars allows a host environment to define *keywords*, which receive
+the full set of environment information (such as the current scope and a
+reference to the runtime) as well as all parameters as unevaluated
+references.
+
+Keywords can be used to implement low-level behaviors that control the
+DOM being built, but with great power comes with great responsibility.
+Since a keyword has the ability to influence the ambient environment and
+the DOM, it must maintain the idempotence invariant.
+
+To repeat, the idempotence requirement says that if a given template is
+executed multiple times with the same dynamic environment, it produces
+the same DOM. This means the exact same DOM nodes, with the same
+internal state.
+
+This is also true for all child templates. Consider this template:
+
+```hbs
+
{{title}}
+
+{{#if subtitle}}
+
{{subtitle}}
+{{/if}}
+
+
{{{body}}}
+```
+
+If this template is rendered first with a `self` that has a title,
+subtitle and body, and then rendered again with the same title and body
+but no subtitle, the second render will produce the same `
` and same
+`
`, even though a part of the environment changes.
+
+The general goal is that for a given keyword, if all of the inputs to
+the keyword have stayed the same, the produced DOM will stay the same.
+
+## Lifecycle Example
+
+To implement an idempotent keyword, you need to understand the basic
+lifecycle of a render node.
+
+Consider this template:
+
+```js
+{{#if subtitle}}
+
{{subtitle}}
+{{/if}}
+```
+
+The first time this template is rendered, the `{{#if}}` block receives a
+fresh, empty render node.
+
+It evaluates `subtitle`, and if the value is truthy, yields to the
+block. HTMLBars creates the static parts of the template (the `
`)
+and inserts them into the DOM).
+
+When it descends into the block, it creates a fresh, empty render node
+and evaluates `subtitle`. It then sets the value of the render node to
+the evaluated value.
+
+The second time the template is rendered, the `{{#if}}` block receives
+the same render node again.
+
+It evaluates `subtitle`, and if the value is truthy, yields to the
+block. HTMLBars sees that the same block as last time was yielded, and
+**does not** replace the static portions of the block.
+
+(If the value is falsy, it does not yield to the block. HTMLBars sees
+that the block was not yielded to, and prunes the DOM produced last
+time, and does not descend.)
+
+It descends into the previous block, and repeats the process. It fetches
+the previous render node, instead of creating a fresh one, and evaluates
+`subtitle`.
+
+If the value of `subtitle` is the same as the last value of `subtitle`,
+nothing happens. If the value of `subtitle` has changed, the render node
+is updated with the new value.
+
+This example shows how HTMLBars itself guarantees idempotence. The
+easiest way for a keyword to satisfy these requirements are to implement
+a series of functions, as the next section will describe.
+
+## Lifecycle More Precisely
+
+```js
+export default {
+ willRender: function(node, env) {
+ // This function is always invoked before any other hooks,
+ // giving the keyword an opportunity to coordinate with
+ // the external environment regardless of whether this is
+ // the first or subsequent render, and regardless of
+ // stability.
+ },
+
+ setupState: function(state, env, scope, params, hash) {
+ // This function is invoked before `isStable` so that it can update any
+ // internal state based on external changes.
+ },
+
+ isEmpty: function(state, env, scope, params, hash) {
+ // If `isStable` returns false, or this is the first render,
+ // this function can return true to indicate that the morph
+ // should be empty (and `render` should not be called).
+ }
+
+ isPaused: function(state, env, scope, params, hash) {
+ // This function is invoked on renders after the first render; if
+ // it returns true, the entire subtree is assumed valid, and dirty
+ // checking does not continue. This is useful during animations,
+ // and in some cases, as a performance optimization.
+ },
+
+ isStable: function(state, env, scope, params, hash) {
+ // This function is invoked after the first render; it checks to see
+ // whether the node is "stable". If the node is unstable, its
+ // existing content will be removed and the `render` function is
+ // called again to produce new values.
+ },
+
+ rerender: function(morph, env, scope, params, hash, template, inverse
+visitor) {
+ // This function is invoked if the `isStable` check returns true.
+ // Occasionally, you may have a bit of work to do when a node is
+ // stable even though you aren't tearing it down.
+ },
+
+ render: function(node, env, scope, params, hash, template, inverse, visitor) {
+ // This function is invoked on the first render, and any time the
+ // isStable function returns false.
+ }
+}
+```
+
+For any given render, a keyword can end up in one of these states:
+
+* **initial**: this is the first render for a given render node
+* **stable**: the DOM subtree represented by the render node do not
+ need to change; continue revalidating child nodes
+* **unstable**: the DOM subtree represented by the render node is no
+ longer valid; do a new initial render and replace the subtree
+* **prune**: remove the DOM subtree represented by the render node
+* **paused**: do not make any changes to this node or the DOM subtree
+
+It is the keyword's responsibility to ensure that a node whose direct
+inputs have not changed remains **stable**. This does not mean that no
+descendant node will not be replaced, but only the precise nodes that
+have changed will be updated.
+
+Note that these details should generally **not** be exposed to the user
+code that interacts with the keyword. Instead, the user code should
+generally take in inputs and produce outputs, and the keyword should use
+those outputs to determine whether the associated render node is stable
+or not.
+
+Ember `{{outlet}}`s are a good example of this. The internal
+implementation of `{{outlet}}` is careful to avoid replacing any nodes
+if the current route has not changed, but the user thinks in terms of
+transitioning to a new route and rendering anew.
+
+If the transition was to the same page (with a different model, say),
+the `{{outlet}}` keyword will make sure to consider the render node
+stable.
+
+From the user's perspective, the transition always results in a complete
+re-render, but the keyword is responsible for maintaining the
+idempotence invariant when appropriate.
+
+This also means that it's possible to precisely describe what
+idempotence guarantees exist. HTMLBars defines the guarantees for
+built-in constructs (including invoked user helpers), and each keyword
+defines the guarantees for the keyword. Since those are the only
+constructs that can directly manipulate the lexical environment or the
+DOM, that's all you need to know!
diff --git a/demos/compile-and-run.html b/demos/compile-and-run.html
index 3daac5e5..dbbd08c3 100644
--- a/demos/compile-and-run.html
+++ b/demos/compile-and-run.html
@@ -33,10 +33,12 @@
var compiler = requireModule('htmlbars-compiler'),
DOMHelper = requireModule('dom-helper').default,
hooks = requireModule('htmlbars-runtime').hooks,
- helpers = requireModule('htmlbars-runtime').helpers;
+ helpers = requireModule('htmlbars-runtime').helpers,
+ render = requireModule('htmlbars-runtime').render;
var templateSource = localStorage.getItem('templateSource');
var data = localStorage.getItem('templateData');
+ var shouldRender = localStorage.getItem('shouldRender');
if (templateSource) {
textarea.value = templateSource;
@@ -46,13 +48,19 @@
dataarea.value = data;
}
+ if (shouldRender === "false") {
+ skipRender.checked = true;
+ }
+
button.addEventListener('click', function() {
var source = textarea.value,
data = dataarea.value,
+ shouldRender = !skipRender.checked,
compileOptions;
localStorage.setItem('templateSource', source);
localStorage.setItem('templateData', data);
+ localStorage.setItem('shouldRender', shouldRender);
try {
data = JSON.parse(data);
@@ -70,10 +78,10 @@
var templateSpec = compiler.compileSpec(source, compileOptions);
output.innerHTML = '
' + templateSpec + '
';
- if (!skipRender.checked) {
+ if (shouldRender) {
var env = { dom: new DOMHelper(), hooks: hooks, helpers: helpers };
- var template = compiler.compile(source, compileOptions);
- var dom = template.render(data, env, output);
+ var template = compiler.template(templateSpec);
+ var dom = render(template, data, env, { contextualElement: output }).fragment;
output.innerHTML += '
', "After dirtying but not updating");
+ strictEqual(result.fragment.firstChild.firstChild.firstChild, valueNode, "The text node was not blown away");
+
+ // Even though the #if was stable, a dirty child node is updated
+ object.value = 'goodbye world';
+ result.rerender();
+ equalTokens(result.fragment, '
goodbye world
', "After updating and dirtying");
+ strictEqual(result.fragment.firstChild.firstChild.firstChild, valueNode, "The text node was not blown away");
+
+ // Should not update since render node is not marked as dirty
+ object.condition = false;
+ result.revalidate();
+ equalTokens(result.fragment, '
goodbye world
', "After flipping the condition but not dirtying");
+ strictEqual(result.fragment.firstChild.firstChild.firstChild, valueNode, "The text node was not blown away");
+
+ result.rerender();
+ equalTokens(result.fragment, '
Nothing
', "And then dirtying");
+ QUnit.notStrictEqual(result.fragment.firstChild.firstChild.firstChild, valueNode, "The text node was not blown away");
+});
+
+test("a simple implementation of a dirtying rerender without inverse", function() {
+ var object = { condition: true, value: 'hello world' };
+ var template = compile('
{{#if condition}}
{{value}}
{{/if}}
');
+ var result = template.render(object, env);
+
+ equalTokens(result.fragment, '
hello world
', "Initial render");
+
+ // Should not update since render node is not marked as dirty
+ object.condition = false;
+
+ result.rerender();
+ equalTokens(result.fragment, '', "If the condition is false, the morph becomes empty");
+
+ object.condition = true;
+
+ result.rerender();
+ equalTokens(result.fragment, '
hello world
', "If the condition is false, the morph becomes empty");
+});
+
+test("a dirtying rerender using `yieldIn`", function() {
+ var component = compile("
{{yield}}
");
+ var template = compile("
{{title}}
");
+
+ registerHelper("simple-component", function() {
+ return this.yieldIn(component);
+ });
+
+ var object = { title: "Hello world" };
+ var result = template.render(object, env);
+
+ var valueNode = getValueNode();
+ equalTokens(result.fragment, '
');
+ assertStableNodes();
+
+ function rerender() {
+ result.rerender();
+ }
+
+ function assertStableNodes() {
+ strictEqual(getNameNode(), nameNode);
+ strictEqual(getTitleNode(), titleNode);
+ }
+
+ function getNameNode() {
+ return result.fragment.firstChild.firstChild.firstChild.firstChild;
+ }
+
+ function getTitleNode() {
+ return result.fragment.firstChild.firstChild.firstChild.nextSibling;
+ }
+});
+
+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.nodeValue, "", "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.nodeValue, "", "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.nodeValue, "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.nodeValue, "", "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.nodeValue, "", "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.nodeValue, "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.rerender();
+});
+
+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.nodeValue, "HELLO");
+
+ object.value = "goodbye";
+ result.revalidate(); // without setting the node to dirty
+
+ equalTokens(result.fragment, '
", "After removing from the back");
+
+ object = { list: [] };
+
+ rerender(object);
+ strictEqual(result.fragment.firstChild.firstChild.nodeType, 8, "there are no li's after removing the remaining entry");
+ equalTokens(result.fragment, "
", "After removing the remaining entries");
+
+ function rerender(context) {
+ result.rerender(env, context);
+ }
+
+ function assertStableNodes(className, message) {
+ strictEqual(getItemNode(className), itemNode, "The item node has not changed " + message);
+ strictEqual(getNameNode(className), nameNode, "The name node has not changed " + message);
+ }
+
+ function getItemNode(className) {
+ //
+ var itemNode = result.fragment.firstChild.firstChild;
+
+ while (itemNode) {
+ if (itemNode.getAttribute('class') === className) { break; }
+ itemNode = itemNode.nextSibling;
+ }
+
+ ok(itemNode, "Expected node with class='" + className + "'");
+ return itemNode;
+ }
+
+ function getNameNode(className) {
+ // {{item.name}}
+ var itemNode = getItemNode(className);
+ ok(itemNode, "Expected child node of node with class='" + className + "', but no parent node found");
+
+ var childNode = itemNode && itemNode.firstChild;
+ ok(childNode, "Expected child node of node with class='" + className + "', but not child node found");
+
+ return childNode;
+ }
+ });
+}
+
+test("Returning true from `linkRenderNodes` makes the value itself stable across renders", function() {
+ var streams = { hello: { value: "hello" }, world: { value: "world" } };
+
+ hooks.linkRenderNode = function() {
+ return true;
+ };
+
+ hooks.getValue = function(stream) {
+ return stream();
+ };
+
+ var concatCalled = 0;
+ hooks.concat = function(env, params) {
+ ok(++concatCalled === 1, "The concat hook is only invoked one time (invoked " + concatCalled + " times)");
+ return function() {
+ return params[0].value + params[1] + params[2].value;
+ };
+ };
+
+ var template = compile("");
+ var result = template.render(streams, env);
+
+ equalTokens(result.fragment, "");
+
+ streams.hello.value = "goodbye";
+
+ result.rerender();
+
+ equalTokens(result.fragment, "");
+});
+
+var destroyedRenderNodeCount;
+var destroyedRenderNode;
+
+QUnit.module("HTML-based compiler (dirtying) - pruning", {
+ beforeEach: function() {
+ commonSetup();
+ destroyedRenderNodeCount = 0;
+ destroyedRenderNode = null;
+
+ hooks.destroyRenderNode = function(renderNode) {
+ destroyedRenderNode = renderNode;
+ destroyedRenderNodeCount++;
+ };
+ }
+});
+
+test("Pruned render nodes invoke a cleanup hook when replaced", function() {
+ var object = { condition: true, value: 'hello world', falsy: "Nothing" };
+ var template = compile('
{{#if condition}}
{{value}}
{{else}}
{{falsy}}
{{/if}}
');
+
+ var result = template.render(object, env);
+
+ equalTokens(result.fragment, "
hello world
");
+
+ object.condition = false;
+ result.rerender();
+
+ strictEqual(destroyedRenderNodeCount, 1, "cleanup hook was invoked once");
+ strictEqual(destroyedRenderNode.lastValue, 'hello world', "The correct render node is passed in");
+
+ object.condition = true;
+ result.rerender();
+
+ strictEqual(destroyedRenderNodeCount, 2, "cleanup hook was invoked again");
+ strictEqual(destroyedRenderNode.lastValue, 'Nothing', "The correct render node is passed in");
+});
+
+test("Pruned render nodes invoke a cleanup hook when cleared", function() {
+ var object = { condition: true, value: 'hello world' };
+ var template = compile('
{{#if condition}}
{{value}}
{{/if}}
');
+
+ var result = template.render(object, env);
+
+ equalTokens(result.fragment, "
hello world
");
+
+ object.condition = false;
+ result.rerender();
+
+ strictEqual(destroyedRenderNodeCount, 1, "cleanup hook was invoked once");
+ strictEqual(destroyedRenderNode.lastValue, 'hello world', "The correct render node is passed in");
+
+ object.condition = true;
+ result.rerender();
+
+ strictEqual(destroyedRenderNodeCount, 1, "cleanup hook was not invoked again");
+});
+
+test("Pruned lists invoke a cleanup hook when removing elements", function() {
+ var object = { list: [{ key: "1", word: "hello" }, { key: "2", word: "world" }] };
+ var template = compile('
{{#each list as |item|}}
{{item.word}}
{{/each}}
');
+
+ var result = template.render(object, env);
+
+ equalTokens(result.fragment, "
hello
world
");
+
+ object.list.pop();
+ result.rerender();
+
+ strictEqual(destroyedRenderNodeCount, 2, "cleanup hook was invoked once for the wrapper morph and once for the {{item.word}}");
+ strictEqual(destroyedRenderNode.lastValue, "world", "The correct render node is passed in");
+
+ object.list.pop();
+ result.rerender();
+
+ strictEqual(destroyedRenderNodeCount, 4, "cleanup hook was invoked once for the wrapper morph and once for the {{item.word}}");
+ strictEqual(destroyedRenderNode.lastValue, "hello", "The correct render node is passed in");
+});
+
+test("Pruned lists invoke a cleanup hook on their subtrees when removing elements", function() {
+ var object = { list: [{ key: "1", word: "hello" }, { key: "2", word: "world" }] };
+ var template = compile('
{{#each list as |item|}}
{{#if item.word}}{{item.word}}{{/if}}
{{/each}}
');
+
+ var result = template.render(object, env);
+
+ equalTokens(result.fragment, "
hello
world
");
+
+ object.list.pop();
+ result.rerender();
+
+ strictEqual(destroyedRenderNodeCount, 3, "cleanup hook was invoked once for the wrapper morph and once for the {{item.word}}");
+ strictEqual(destroyedRenderNode.lastValue, "world", "The correct render node is passed in");
+
+ object.list.pop();
+ result.rerender();
+
+ strictEqual(destroyedRenderNodeCount, 6, "cleanup hook was invoked once for the wrapper morph and once for the {{item.word}}");
+ strictEqual(destroyedRenderNode.lastValue, "hello", "The correct render node is passed in");
+});
+
+QUnit.module("Manual elements", {
+ beforeEach: commonSetup
+});
+
+test("Setting up a manual element renders and revalidates", function() {
+ hooks.keywords['manual-element'] = {
+ render: function(morph, env, scope, params, hash, template, inverse, visitor) {
+ var attributes = {
+ title: "Tom Dale",
+ href: ['concat', ['http://tomdale.', ['get', 'tld']]],
+ 'data-bar': ['get', 'bar']
+ };
+
+ var layout = manualElement('span', attributes);
+
+ hostBlock(morph, env, scope, template, inverse, null, visitor, function(options) {
+ options.templates.template.yieldIn({ raw: layout }, hash);
+ });
+
+ manualElement(env, scope, 'span', attributes, morph);
+ },
+
+ isStable: function() { return true; }
+ };
+
+ var template = compile("{{#manual-element bar='baz' tld='net'}}Hello {{world}}!{{/manual-element}}");
+ var result = template.render({ world: "world" }, env);
+
+ equalTokens(result.fragment, "Hello world!");
+});
+
+test("It is possible to nest multiple templates into a manual element", function() {
+ hooks.keywords['manual-element'] = {
+ render: function(morph, env, scope, params, hash, template, inverse, visitor) {
+ var attributes = {
+ title: "Tom Dale",
+ href: ['concat', ['http://tomdale.', ['get', 'tld']]],
+ 'data-bar': ['get', 'bar']
+ };
+
+ var elementTemplate = manualElement('span', attributes);
+
+ var contentBlock = blockFor(render, template, { scope: scope });
+
+ var layoutBlock = blockFor(render, layout.raw, {
+ yieldTo: contentBlock,
+ self: { attrs: hash },
+ });
+
+ var elementBlock = blockFor(render, elementTemplate, {
+ yieldTo: layoutBlock,
+ self: hash
+ });
+
+ elementBlock(env, null, morph, null, visitor);
+ },
+
+ isStable: function() { return true; }
+ };
+
+ var layout = compile("{{attrs.foo}}. {{yield}}");
+ var template = compile("{{#manual-element foo='foo' bar='baz' tld='net'}}Hello {{world}}!{{/manual-element}}");
+ var result = template.render({ world: "world" }, env);
+
+ equalTokens(result.fragment, "foo. Hello world!");
+});
+
diff --git a/packages/htmlbars-compiler/tests/hooks-test.js b/packages/htmlbars-compiler/tests/hooks-test.js
new file mode 100644
index 00000000..f18be1c1
--- /dev/null
+++ b/packages/htmlbars-compiler/tests/hooks-test.js
@@ -0,0 +1,60 @@
+import { compile } from "../htmlbars-compiler/compiler";
+import defaultHooks from "../htmlbars-runtime/hooks";
+import { merge } from "../htmlbars-util/object-utils";
+import DOMHelper from "../dom-helper";
+import { equalTokens } from "../htmlbars-test-helpers";
+
+var hooks, helpers, partials, env;
+
+function registerHelper(name, callback) {
+ helpers[name] = callback;
+}
+
+function commonSetup() {
+ hooks = merge({}, defaultHooks);
+ hooks.keywords = merge({}, defaultHooks.keywords);
+ helpers = {};
+ partials = {};
+
+ env = {
+ dom: new DOMHelper(),
+ hooks: hooks,
+ helpers: helpers,
+ partials: partials,
+ useFragmentCache: true
+ };
+}
+
+QUnit.module("HTML-based compiler (dirtying)", {
+ beforeEach: commonSetup
+});
+
+test("the invokeHelper hook gets invoked to call helpers", function() {
+ hooks.getRoot = function(scope, key) {
+ return [{ value: scope.self[key] }];
+ };
+
+ var invoked = false;
+ hooks.invokeHelper = function(morph, env, scope, visitor, params, hash, helper, templates, context) {
+ invoked = true;
+
+ deepEqual(params, [{ value: "hello world" }]);
+ ok(templates.template.yieldIn, "templates are passed");
+ ok(scope.self, "the scope was passed");
+ ok(morph.state, "the morph was passed");
+
+ return { value: helper.call(context, [params[0].value], hash, templates) };
+ };
+
+ registerHelper('print', function(params) {
+ return params.join('');
+ });
+
+ var object = { val: 'hello world' };
+ var template = compile('
{{print val}}
');
+ var result = template.render(object, env);
+
+ equalTokens(result.fragment, '
hello world
');
+
+ ok(invoked, "The invokeHelper hook was invoked");
+});
diff --git a/packages/htmlbars-compiler/tests/html-compiler-test.js b/packages/htmlbars-compiler/tests/html-compiler-test.js
index a104be3c..e1e1778c 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 compiler = new TemplateCompiler();
- var program = compiler.compile(ast);
- var template = new Function("return " + program)();
-
- dom = new DOMHelper();
- hooks = merge({}, defaultHooks);
- helpers = merge({}, defaultHelpers);
-
- var env = {
- dom: dom,
- hooks: hooks,
- helpers: helpers
- };
-
- env.helpers['if'] = function(params, hash, options) {
- if (params[0]) {
- return options.template.render(context, env, options.morph.contextualElement);
- }
- };
-
- var context = {
- working: true,
- firstName: 'Kris',
- lastName: 'Selden'
- };
- var frag = template.render(context, env, document.body);
- equalHTML(frag, '
Hello Kris Selden!
');
-});
-
test("it omits unnecessary namespace changes", function () {
equal(countNamespaceChanges(''), 0); // sanity check
equal(countNamespaceChanges(''), 1);
diff --git a/packages/htmlbars-runtime/lib/expression-visitor.js b/packages/htmlbars-runtime/lib/expression-visitor.js
new file mode 100644
index 00000000..9f66561e
--- /dev/null
+++ b/packages/htmlbars-runtime/lib/expression-visitor.js
@@ -0,0 +1,244 @@
+import { merge, createObject } from "../htmlbars-util/object-utils";
+import { validateChildMorphs, linkParams } from "../htmlbars-util/morph-utils";
+
+/**
+ Node classification:
+
+ # Primary Statement Nodes:
+
+ These nodes are responsible for a render node that represents a morph-range.
+
+ * block
+ * inline
+ * content
+ * element
+ * component
+
+ # Leaf Statement Nodes:
+
+ This node is responsible for a render node that represents a morph-attr.
+
+ * attribute
+
+ # Expression Nodes:
+
+ These nodes are not directly responsible for any part of the DOM, but are
+ eventually passed to a Statement Node.
+
+ * get
+ * subexpr
+ * concat
+*/
+
+var base = {
+ acceptExpression: function(node, morph, env, scope) {
+ var ret = { value: null };
+
+ // Primitive literals are unambiguously non-array representations of
+ // themselves.
+ if (typeof node !== 'object') {
+ ret.value = node;
+ return ret;
+ }
+
+ switch(node[0]) {
+ // can be used by manualElement
+ case 'value': ret.value = node[1]; break;
+ case 'get': ret.value = this.get(node, morph, env, scope); break;
+ case 'subexpr': ret.value = this.subexpr(node, morph, env, scope); break;
+ case 'concat': ret.value = this.concat(node, morph, env, scope); break;
+ }
+
+ return ret;
+ },
+
+ acceptParamsAndHash: function(env, scope, morph, path, params, hash) {
+ params = params && this.acceptParams(params, morph, env, scope);
+ hash = hash && this.acceptHash(hash, morph, env, scope);
+
+ linkParams(env, scope, morph, path, params, hash);
+ return [params, hash];
+ },
+
+ acceptParams: function(nodes, morph, env, scope) {
+ if (morph.linkedParams) {
+ return morph.linkedParams.params;
+ }
+
+ var arr = new Array(nodes.length);
+
+ for (var i=0, l=nodes.length; i{{title}}
+
+ {{#if author}}
+
{{author}}
+ {{/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,
+ arity: template.arity,
+ revision: template.revision,
+ raw: template,
+ render: function(self, env, options, blockArguments) {
+ var scope = env.hooks.createFreshScope();
+
+ options = options || {};
+ options.self = self;
+ options.blockArguments = blockArguments;
+
+ return render(template, env, scope, options);
+ }
+ };
+}
+
+export function wrapForHelper(template, env, scope, morph, renderState, visitor) {
+ if (template === null) {
+ return {
+ yieldIn: yieldInShadowTemplate(null, env, scope, morph, renderState, visitor)
+ };
+ }
+
+ var yieldArgs = yieldTemplate(template, env, scope, morph, renderState, visitor);
+
+ return {
+ arity: template.arity,
+ revision: template.revision,
+ yield: yieldArgs,
+ yieldItem: yieldItem(template, env, scope, morph, renderState, visitor),
+ yieldIn: yieldInShadowTemplate(template, env, scope, morph, renderState, visitor),
+
+ render: function(self, blockArguments) {
+ yieldArgs(blockArguments, self);
+ }
+ };
+}
+
+function yieldTemplate(template, env, parentScope, morph, renderState, visitor) {
+ return function(blockArguments, self) {
+ renderState.clearMorph = null;
+
+ if (morph.morphList) {
+ renderState.morphList = morph.morphList.firstChildMorph;
+ renderState.morphList = null;
+ }
+
+ var scope = parentScope;
+
+ if (morph.lastYielded && isStableTemplate(template, morph.lastYielded)) {
+ return morph.lastResult.revalidateWith(env, undefined, self, blockArguments, visitor);
+ }
+
+ // Check to make sure that we actually **need** a new scope, and can't
+ // share the parent scope. Note that we need to move this check into
+ // a host hook, because the host's notion of scope may require a new
+ // scope in more cases than the ones we can determine statically.
+ if (self !== undefined || parentScope === null || template.arity) {
+ scope = env.hooks.createChildScope(parentScope);
+ }
+
+ morph.lastYielded = { self: self, template: template, shadowTemplate: null };
+
+ // Render the template that was selected by the helper
+ render(template, env, scope, { renderNode: morph, self: self, blockArguments: blockArguments });
};
+}
- var helper = lookupHelper(env, context, path);
- var value = helper.call(context, params, hash, options, env);
+function yieldItem(template, env, parentScope, morph, renderState, visitor) {
+ var currentMorph = null;
+ var morphList = morph.morphList;
+ if (morphList) {
+ currentMorph = morphList.firstChildMorph;
+ renderState.morphListStart = currentMorph;
+ }
+
+ return function(key, blockArguments, self) {
+ if (typeof key !== 'string') {
+ throw new Error("You must provide a string key when calling `yieldItem`; you provided " + key);
+ }
+
+ var morphList, morphMap;
+
+ if (!morph.morphList) {
+ morph.morphList = new MorphList();
+ morph.morphMap = {};
+ morph.setMorphList(morph.morphList);
+ }
+
+ morphList = morph.morphList;
+ morphMap = morph.morphMap;
+
+ if (currentMorph && currentMorph.key === key) {
+ yieldTemplate(template, env, parentScope, currentMorph, renderState, visitor)(blockArguments, self);
+ currentMorph = currentMorph.nextMorph;
+ } else if (currentMorph && morphMap[key] !== undefined) {
+ var foundMorph = morphMap[key];
+ yieldTemplate(template, env, parentScope, foundMorph, renderState, visitor)(blockArguments, self);
+ morphList.insertBeforeMorph(foundMorph, currentMorph);
+ } else {
+ var childMorph = createChildMorph(env.dom, morph);
+ childMorph.key = key;
+ morphMap[key] = childMorph;
+ morphList.insertBeforeMorph(childMorph, currentMorph);
+ yieldTemplate(template, env, parentScope, childMorph, renderState, visitor)(blockArguments, self);
+ }
- morph.setContent(value);
+ renderState.morphListStart = currentMorph;
+ renderState.clearMorph = morph.childNodes;
+ morph.childNodes = null;
+ };
+}
+
+function isStableTemplate(template, lastYielded) {
+ return !lastYielded.shadowTemplate && template === lastYielded.template;
}
-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 yieldInShadowTemplate(template, env, parentScope, morph, renderState, visitor) {
+ var hostYield = hostYieldWithShadowTemplate(template, env, parentScope, morph, renderState, visitor);
- morph.setContent(value);
+ return function(shadowTemplate, self) {
+ hostYield(shadowTemplate, env, self, []);
+ };
}
-export function content(env, morph, context, path) {
- var helper = lookupHelper(env, context, path);
+export function hostYieldWithShadowTemplate(template, env, parentScope, morph, renderState, visitor) {
+ return function(shadowTemplate, env, self, blockArguments) {
+ renderState.clearMorph = null;
- var value;
- if (helper) {
- value = helper.call(context, [], {}, { morph: morph }, env);
+ if (morph.lastYielded && isStableShadowRoot(template, shadowTemplate, morph.lastYielded)) {
+ return morph.lastResult.revalidateWith(env, undefined, self, blockArguments, visitor);
+ }
+
+ var shadowScope = env.hooks.createFreshScope();
+ env.hooks.bindShadowScope(env, parentScope, shadowScope, renderState.shadowOptions);
+ env.hooks.bindBlock(env, shadowScope, blockToYield);
+
+ morph.lastYielded = { self: self, template: template, shadowTemplate: shadowTemplate };
+
+ // Render the shadow template with the block available
+ render(shadowTemplate.raw, env, shadowScope, { renderNode: morph, self: self, blockArguments: blockArguments });
+ };
+
+ function blockToYield(env, blockArguments, renderNode, shadowParent, visitor) {
+ if (renderNode.lastResult) {
+ renderNode.lastResult.revalidateWith(env, undefined, undefined, blockArguments, visitor);
+ } else {
+ var scope = parentScope;
+
+ // Since a yielded template shares a `self` with its original context,
+ // we only need to create a new scope if the template has block parameters
+ if (template.arity) {
+ scope = env.hooks.createChildScope(parentScope);
+ }
+
+ render(template, env, scope, { renderNode: renderNode, blockArguments: blockArguments });
+ }
+ }
+}
+
+function isStableShadowRoot(template, shadowTemplate, lastYielded) {
+ return template === lastYielded.template && shadowTemplate === lastYielded.shadowTemplate;
+}
+
+function optionsFor(template, inverse, env, scope, morph, visitor) {
+ var renderState = { morphListStart: null, clearMorph: morph, shadowOptions: null };
+
+ return {
+ templates: {
+ template: wrapForHelper(template, env, scope, morph, renderState, visitor),
+ inverse: wrapForHelper(inverse, env, scope, morph, renderState, visitor)
+ },
+ renderState: renderState
+ };
+}
+
+function thisFor(options) {
+ return {
+ arity: options.template.arity,
+ yield: options.template.yield,
+ yieldItem: options.template.yieldItem,
+ yieldIn: options.template.yieldIn
+ };
+}
+
+/**
+ Host Hook: createScope
+
+ @param {Scope?} parentScope
+ @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.
+
+ 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(env, parentScope) {
+ if (parentScope) {
+ return env.hooks.createChildScope(parentScope);
} else {
- value = get(env, context, path);
+ return env.hooks.createFreshScope();
}
+}
- morph.setContent(value);
+export function createFreshScope() {
+ // because `in` checks have unpredictable performance, keep a
+ // separate dictionary to track whether a local was bound.
+ // See `bindLocal` for more information.
+ return { self: null, block: null, locals: {}, localPresent: {} };
}
-export function element(env, domElement, context, path, params, hash) {
- var helper = lookupHelper(env, context, path);
- if (helper) {
- helper.call(context, params, hash, { element: domElement }, env);
+/**
+ Host Hook: createShadowScope
+
+ @param {Scope?} parentScope
+ @return Scope
+
+ Corresponds to rendering a new template into an existing
+ render tree, but with a new top-level lexical scope. This
+ template is called the "shadow root".
+
+ If a shadow template invokes `{{yield}}`, it will render
+ the block provided to the shadow root in the original
+ lexical scope.
+
+ ```hbs
+ {{!-- post template --}}
+
+ This is my first post
+ {{/post}}
+
+ {{#post title="Goodbye world"}}
+
by {{byline}}
+ This is my last post
+ {{/post}}
+ ```
+
+ ```js
+ helpers.post = function(params, hash, options) {
+ options.template.yieldIn(postTemplate, { props: hash });
+ };
+
+ blog.render({ byline: "Yehuda Katz" });
+ ```
+
+ Produces:
+
+ ```html
+
Hello world
+
by Yehuda Katz
+ This is my first post
+
+
Goodbye world
+
by Yehuda Katz
+ This is my last post
+ ```
+
+ In short, `yieldIn` creates a new top-level scope for the
+ provided template and renders it, making the original block
+ available to `{{yield}}` in that template.
+*/
+export function bindShadowScope(env /*, parentScope, shadowScope */) {
+ return env.hooks.createFreshScope();
+}
+
+export function createChildScope(parent) {
+ var scope = createObject(parent);
+ scope.locals = createObject(parent.locals);
+ return scope;
+}
+
+/**
+ Host Hook: bindSelf
+
+ @param {Scope} scope
+ @param {any} self
+
+ Corresponds to entering a template.
+
+ This hook is invoked when the `self` value for a scope is ready to be bound.
+
+ The host must ensure that child scopes reflect the change to the `self` in
+ future calls to the `get` hook.
+*/
+export function bindSelf(env, scope, self) {
+ scope.self = self;
+}
+
+export function updateSelf(env, scope, self) {
+ env.hooks.bindSelf(env, scope, self);
+}
+
+/**
+ Host Hook: bindLocal
+
+ @param {Environment} env
+ @param {Scope} scope
+ @param {String} name
+ @param {any} value
+
+ Corresponds to entering a template with block arguments.
+
+ This hook is invoked when a local variable for a scope has been provided.
+
+ The host must ensure that child scopes reflect the change in future calls
+ to the `get` hook.
+*/
+export function bindLocal(env, scope, name, value) {
+ scope.localPresent[name] = true;
+ scope.locals[name] = value;
+}
+
+export function updateLocal(env, scope, name, value) {
+ env.hooks.bindLocal(env, scope, name, value);
+}
+
+/**
+ Host Hook: bindBlock
+
+ @param {Environment} env
+ @param {Scope} scope
+ @param {Function} block
+
+ Corresponds to entering a shadow template that was invoked by a block helper with
+ `yieldIn`.
+
+ This hook is invoked with an opaque block that will be passed along to the
+ shadow template, and inserted into the shadow template when `{{yield}}` is used.
+*/
+export function bindBlock(env, scope, block) {
+ scope.block = block;
+}
+
+/**
+ Host Hook: block
+
+ @param {RenderNode} renderNode
+ @param {Environment} env
+ @param {Scope} scope
+ @param {String} path
+ @param {Array} params
+ @param {Object} hash
+ @param {Block} block
+ @param {Block} elseBlock
+
+ Corresponds to:
+
+ ```hbs
+ {{#helper param1 param2 key1=val1 key2=val2}}
+ {{!-- child template --}}
+ {{/helper}}
+ ```
+
+ This host hook is a workhorse of the system. It is invoked
+ whenever a block is encountered, and is responsible for
+ resolving the helper to call, and then invoke it.
+
+ The helper should be invoked with:
+
+ - `{Array} params`: the parameters passed to the helper
+ in the template.
+ - `{Object} hash`: an object containing the keys and values passed
+ in the hash position in the template.
+
+ The values in `params` and `hash` will already be resolved
+ through a previous call to the `get` host hook.
+
+ The helper should be invoked with a `this` value that is
+ an object with one field:
+
+ `{Function} yield`: when invoked, this function executes the
+ block with the current scope. It takes an optional array of
+ block parameters. If block parameters are supplied, HTMLBars
+ will invoke the `bindLocal` host hook to bind the supplied
+ values to the block arguments provided by the template.
+
+ In general, the default implementation of `block` should work
+ for most host environments. It delegates to other host hooks
+ where appropriate, and properly invokes the helper with the
+ appropriate arguments.
+*/
+export function block(morph, env, scope, path, params, hash, template, inverse, visitor) {
+ if (handleRedirect(morph, env, scope, path, params, hash, template, inverse, visitor)) {
+ return;
}
+
+ continueBlock(morph, env, scope, path, params, hash, template, inverse, visitor);
}
-export function attribute(env, attrMorph, domElement, name, value) {
- attrMorph.setContent(value);
+export function continueBlock(morph, env, scope, path, params, hash, template, inverse, visitor) {
+ hostBlock(morph, env, scope, template, inverse, null, visitor, function(options) {
+ var helper = env.hooks.lookupHelper(env, scope, path);
+ env.hooks.invokeHelper(morph, env, scope, visitor, params, hash, helper, options.templates, thisFor(options.templates));
+ });
}
-export function subexpr(env, context, helperName, params, hash) {
- var helper = lookupHelper(env, context, helperName);
- if (helper) {
- return helper.call(context, params, hash, {}, env);
+export function hostBlock(morph, env, scope, template, inverse, shadowOptions, visitor, callback) {
+ var options = optionsFor(template, inverse, env, scope, morph, visitor);
+ renderAndCleanup(morph, env, options, shadowOptions, callback);
+}
+
+function handleRedirect(morph, env, scope, path, params, hash, template, inverse, visitor) {
+ var redirect = env.hooks.classify(env, scope, path);
+ if (redirect) {
+ switch(redirect) {
+ case 'component': env.hooks.component(morph, env, scope, path, hash, template, visitor); break;
+ case 'inline': env.hooks.inline(morph, env, scope, path, params, hash, visitor); break;
+ case 'block': env.hooks.block(morph, env, scope, path, params, hash, template, inverse, visitor); break;
+ default: throw new Error("Internal HTMLBars redirection to " + redirect + " not supported");
+ }
+ return true;
+ }
+
+ if (handleKeyword(path, morph, env, scope, params, hash, template, inverse, visitor)) {
+ return true;
+ }
+
+ return false;
+}
+
+function handleKeyword(path, morph, env, scope, params, hash, template, inverse, visitor) {
+ var keyword = env.hooks.keywords[path];
+ if (!keyword) { return false; }
+
+ if (typeof keyword === 'function') {
+ return keyword(morph, env, scope, params, hash, template, inverse, visitor);
+ }
+
+ if (keyword.willRender) {
+ keyword.willRender(morph, env);
+ }
+
+ var lastState, newState;
+ if (keyword.setupState) {
+ lastState = shallowCopy(morph.state);
+ newState = morph.state = keyword.setupState(lastState, env, scope, params, hash);
+ }
+
+ if (keyword.childEnv) {
+ env = merge(keyword.childEnv(morph.state), env);
+ }
+
+ var firstTime = !morph.rendered;
+
+ if (keyword.isEmpty) {
+ var isEmpty = keyword.isEmpty(morph.state, env, scope, params, hash);
+
+ if (isEmpty) {
+ if (!firstTime) { clearMorph(morph, env, false); }
+ return true;
+ }
+ }
+
+ if (firstTime) {
+ if (keyword.render) {
+ keyword.render(morph, env, scope, params, hash, template, inverse, visitor);
+ }
+ morph.rendered = true;
+ return true;
+ }
+
+ var isStable;
+ if (keyword.isStable) {
+ isStable = keyword.isStable(lastState, newState);
+ } else {
+ isStable = stableState(lastState, newState);
+ }
+
+ if (isStable) {
+ if (keyword.rerender) {
+ var newEnv = keyword.rerender(morph, env, scope, params, hash, template, inverse, visitor);
+ env = newEnv || env;
+ }
+ validateChildMorphs(env, morph, visitor);
+ return true;
} else {
- return get(env, context, helperName);
+ clearMorph(morph, env, false);
+ }
+
+ // If the node is unstable, re-render from scratch
+ if (keyword.render) {
+ keyword.render(morph, env, scope, params, hash, template, inverse, visitor);
+ morph.rendered = true;
+ return true;
+ }
+}
+
+function stableState(oldState, newState) {
+ if (keyLength(oldState) !== keyLength(newState)) { return false; }
+
+ for (var prop in oldState) {
+ if (oldState[prop] !== newState[prop]) { return false; }
+ }
+
+ return true;
+}
+
+export function linkRenderNode(/* morph, env, scope, params, hash */) {
+ return;
+}
+
+/**
+ Host Hook: inline
+
+ @param {RenderNode} renderNode
+ @param {Environment} env
+ @param {Scope} scope
+ @param {String} path
+ @param {Array} params
+ @param {Hash} hash
+
+ Corresponds to:
+
+ ```hbs
+ {{helper param1 param2 key1=val1 key2=val2}}
+ ```
+
+ This host hook is similar to the `block` host hook, but it
+ invokes helpers that do not supply an attached block.
+
+ Like the `block` hook, the helper should be invoked with:
+
+ - `{Array} params`: the parameters passed to the helper
+ in the template.
+ - `{Object} hash`: an object containing the keys and values passed
+ in the hash position in the template.
+
+ The values in `params` and `hash` will already be resolved
+ through a previous call to the `get` host hook.
+
+ In general, the default implementation of `inline` should work
+ for most host environments. It delegates to other host hooks
+ where appropriate, and properly invokes the helper with the
+ appropriate arguments.
+
+ The default implementation of `inline` also makes `partial`
+ a keyword. Instead of invoking a helper named `partial`,
+ it invokes the `partial` host hook.
+*/
+export function inline(morph, env, scope, path, params, hash, visitor) {
+ if (handleRedirect(morph, env, scope, path, params, hash, null, null, visitor)) {
+ return;
+ }
+
+ var options = optionsFor(null, null, env, scope, morph);
+
+ var helper = env.hooks.lookupHelper(env, scope, path);
+ var result = env.hooks.invokeHelper(morph, env, scope, visitor, params, hash, helper, options.templates, thisFor(options.templates));
+
+ if (result && result.value) {
+ var value = result.value;
+ if (morph.lastValue !== value) {
+ morph.setContent(value);
+ }
+ morph.lastValue = value;
+ }
+}
+
+export function keyword(path, morph, env, scope, params, hash, template, inverse, visitor) {
+ handleKeyword(path, morph, env, scope, params, hash, template, inverse, visitor);
+}
+
+export function invokeHelper(morph, env, scope, visitor, _params, _hash, helper, templates, context) {
+ var params = normalizeArray(env, _params);
+ var hash = normalizeObject(env, _hash);
+ return { value: helper.call(context, params, hash, templates) };
+}
+
+function normalizeArray(env, array) {
+ var out = new Array(array.length);
+
+ for (var i=0, l=array.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, visitor) {
+ if (handleRedirect(morph, env, scope, path, params, hash, null, null, visitor)) {
+ return;
+ }
+
+ var helper = env.hooks.lookupHelper(env, scope, path);
+ if (helper) {
+ env.hooks.invokeHelper(null, env, scope, null, params, hash, helper, { element: morph.element });
+ }
+}
+
+/**
+ 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, scope, name, value) {
+ value = env.hooks.getValue(value);
+
+ if (morph.lastValue !== value) {
+ morph.setContent(value);
+ }
+
+ morph.lastValue = value;
+}
+
+export function subexpr(env, scope, helperName, params, hash) {
+ var helper = env.hooks.lookupHelper(env, scope, helperName);
+ var result = env.hooks.invokeHelper(null, env, scope, null, params, hash, helper, {});
+ if (result && result.value) { return result.value; }
+}
+
+/**
+ Host Hook: get
+
+ @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.
+*/
+export function get(env, scope, path) {
if (path === '') {
- return context;
+ return scope.self;
}
var keys = path.split('.');
- var value = context;
- for (var i = 0; i < keys.length; i++) {
+ var value = env.hooks.getRoot(scope, keys[0])[0];
+
+ for (var i = 1; i < keys.length; i++) {
if (value) {
- value = value[keys[i]];
+ value = env.hooks.getChild(value, keys[i]);
} else {
break;
}
}
+
return value;
}
-export function set(env, context, name, value) {
- context[name] = value;
+export function getRoot(scope, key) {
+ if (scope.localPresent[key]) {
+ return [scope.locals[key]];
+ } else if (scope.self) {
+ return [scope.self[key]];
+ } else {
+ return [undefined];
+ }
}
-export function component(env, morph, context, tagName, attrs, template) {
- var helper = lookupHelper(env, context, tagName);
+export function getChild(value, key) {
+ return value[key];
+}
- var value;
- if (helper) {
- var options = {
- morph: morph,
- template: template
- };
+export function getValue(value) {
+ return value;
+}
- value = helper.call(context, [], attrs, options, env);
- } else {
- value = componentFallback(env, morph, context, tagName, attrs, template);
+export function component(morph, env, scope, tagName, attrs, template, visitor) {
+ if (env.hooks.hasHelper(env, scope, tagName)) {
+ return env.hooks.block(morph, env, scope, tagName, [], attrs, template, null, visitor);
}
- morph.setContent(value);
+ componentFallback(morph, env, scope, tagName, attrs, template);
}
export function concat(env, params) {
var value = "";
for (var i = 0, l = params.length; i < l; i++) {
- value += params[i];
+ value += env.hooks.getValue(params[i]);
}
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.setAttribute(name, env.hooks.getValue(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) {
+export function hasHelper(env, scope, helperName) {
+ return env.helpers[helperName] !== undefined;
+}
+
+export function lookupHelper(env, scope, helperName) {
return env.helpers[helperName];
}
+export function bindScope(/* env, scope */) {
+ // this function is used to handle host-specified extensions to scope
+ // other than `self`, `locals` and `block`.
+}
+
+export function updateScope(env, scope) {
+ env.hooks.bindScope(env, scope);
+}
+
export default {
- content: content,
- block: block,
- inline: inline,
+ // fundamental hooks that you will likely want to override
+ bindLocal: bindLocal,
+ bindSelf: bindSelf,
+ bindScope: bindScope,
+ classify: classify,
component: component,
- element: element,
- attribute: attribute,
- subexpr: subexpr,
concat: concat,
+ createFreshScope: createFreshScope,
+ getChild: getChild,
+ getRoot: getRoot,
+ getValue: getValue,
+ keywords: keywords,
+ linkRenderNode: linkRenderNode,
+ partial: partial,
+ subexpr: subexpr,
+
+ // fundamental hooks with good default behavior
+ bindBlock: bindBlock,
+ bindShadowScope: bindShadowScope,
+ updateLocal: updateLocal,
+ updateSelf: updateSelf,
+ updateScope: updateScope,
+ createChildScope: createChildScope,
+ hasHelper: hasHelper,
+ lookupHelper: lookupHelper,
+ invokeHelper: invokeHelper,
+ cleanupRenderNode: null,
+ destroyRenderNode: null,
+ willCleanupTree: null,
+ didCleanupTree: null,
+
+ // derived hooks
+ attribute: attribute,
+ block: block,
+ createScope: createScope,
+ element: element,
get: get,
- set: set
+ inline: inline,
+ range: range,
+ keyword: keyword
};
diff --git a/packages/htmlbars-runtime/lib/main.js b/packages/htmlbars-runtime/lib/main.js
index d8d144e1..145c1356 100644
--- a/packages/htmlbars-runtime/lib/main.js
+++ b/packages/htmlbars-runtime/lib/main.js
@@ -1,7 +1,29 @@
import hooks from 'htmlbars-runtime/hooks';
-import helpers from 'htmlbars-runtime/helpers';
+import render from 'htmlbars-runtime/render';
+import { manualElement } from 'htmlbars-runtime/render';
+import { visitChildren } from "../htmlbars-util/morph-utils";
+import { blockFor, clearMorph } from "../htmlbars-util/template-utils";
+import { validateChildMorphs } from "htmlbars-runtime/expression-visitor";
+import {
+ hostBlock,
+ continueBlock,
+ hostYieldWithShadowTemplate
+} from 'htmlbars-runtime/hooks';
+
+
+var internal = {
+ blockFor: blockFor,
+ manualElement: manualElement,
+ hostBlock: hostBlock,
+ continueBlock: continueBlock,
+ hostYieldWithShadowTemplate: hostYieldWithShadowTemplate,
+ visitChildren: visitChildren,
+ validateChildMorphs: validateChildMorphs,
+ clearMorph: clearMorph
+};
export {
hooks,
- helpers
+ render,
+ internal
};
diff --git a/packages/htmlbars-runtime/lib/morph.js b/packages/htmlbars-runtime/lib/morph.js
new file mode 100644
index 00000000..61427e2a
--- /dev/null
+++ b/packages/htmlbars-runtime/lib/morph.js
@@ -0,0 +1,43 @@
+import MorphBase from "../morph-range";
+import { createObject } from "../htmlbars-util/object-utils";
+
+function HTMLBarsMorph(domHelper, contextualElement) {
+ this.super$constructor(domHelper, contextualElement);
+
+ this.state = {};
+ this.ownerNode = null;
+ this.isDirty = false;
+ this.isSubtreeDirty = false;
+ this.lastYielded = null;
+ this.lastResult = null;
+ this.lastValue = null;
+ this.morphList = null;
+ this.morphMap = null;
+ this.key = null;
+ this.linkedParams = null;
+ this.rendered = false;
+}
+
+HTMLBarsMorph.empty = function(domHelper, contextualElement) {
+ var morph = new HTMLBarsMorph(domHelper, contextualElement);
+ morph.clear();
+ return morph;
+};
+
+HTMLBarsMorph.create = function (domHelper, contextualElement, node) {
+ var morph = new HTMLBarsMorph(domHelper, contextualElement);
+ morph.setNode(node);
+ return morph;
+};
+
+HTMLBarsMorph.attach = function (domHelper, contextualElement, firstNode, lastNode) {
+ var morph = new HTMLBarsMorph(domHelper, contextualElement);
+ morph.setRange(firstNode, lastNode);
+ return morph;
+};
+
+var prototype = HTMLBarsMorph.prototype = createObject(MorphBase.prototype);
+prototype.constructor = HTMLBarsMorph;
+prototype.super$constructor = MorphBase;
+
+export default HTMLBarsMorph;
diff --git a/packages/htmlbars-runtime/lib/render.js b/packages/htmlbars-runtime/lib/render.js
new file mode 100644
index 00000000..8bbbfbb0
--- /dev/null
+++ b/packages/htmlbars-runtime/lib/render.js
@@ -0,0 +1,250 @@
+import { forEach } from "../htmlbars-util/array-utils";
+import { visitChildren } from "../htmlbars-util/morph-utils";
+import ExpressionVisitor from "./expression-visitor";
+import { AlwaysDirtyVisitor } from "./expression-visitor";
+import Morph from "./morph";
+import { clearMorph } from "../htmlbars-util/template-utils";
+
+export default function render(template, env, scope, options) {
+ var dom = env.dom;
+ var contextualElement;
+
+ if (options) {
+ if (options.renderNode) {
+ contextualElement = options.renderNode.contextualElement;
+ } else if (options.contextualElement) {
+ contextualElement = options.contextualElement;
+ }
+ }
+
+ dom.detectNamespace(contextualElement);
+
+ var renderResult = RenderResult.build(env, scope, template, options, contextualElement);
+ renderResult.render();
+
+ return renderResult;
+}
+
+function RenderResult(env, scope, options, rootNode, nodes, fragment, template, shouldSetContent) {
+ this.root = rootNode;
+ this.fragment = fragment;
+
+ this.nodes = nodes;
+ this.template = template;
+ this.env = env;
+ this.scope = scope;
+ this.shouldSetContent = shouldSetContent;
+
+ this.bindScope();
+
+ if (options.self !== undefined) { this.bindSelf(options.self); }
+ if (options.blockArguments !== undefined) { this.bindLocals(options.blockArguments); }
+}
+
+RenderResult.build = function(env, scope, template, options, contextualElement) {
+ var dom = env.dom;
+ var fragment = getCachedFragment(template, env);
+ var nodes = template.buildRenderNodes(dom, fragment, contextualElement);
+
+ var rootNode, ownerNode, shouldSetContent;
+
+ if (options && options.renderNode) {
+ rootNode = options.renderNode;
+ ownerNode = rootNode.ownerNode;
+ shouldSetContent = true;
+ } else {
+ rootNode = dom.createMorph(null, fragment.firstChild, fragment.lastChild, contextualElement);
+ ownerNode = rootNode;
+ initializeNode(rootNode, ownerNode);
+ shouldSetContent = false;
+ }
+
+ if (rootNode.childNodes) {
+ visitChildren(rootNode.childNodes, function(node) {
+ clearMorph(node, env, true);
+ });
+ }
+
+ rootNode.childNodes = nodes;
+
+ forEach(nodes, function(node) {
+ initializeNode(node, ownerNode);
+ });
+
+ return new RenderResult(env, scope, options, rootNode, nodes, fragment, template, shouldSetContent);
+};
+
+export function manualElement(tagName, attributes) {
+ var statements = [];
+
+ for (var key in attributes) {
+ if (typeof attributes[key] === 'string') { continue; }
+ statements.push(["attribute", key, attributes[key]]);
+ }
+
+ statements.push(['content', 'yield']);
+
+ var template = {
+ isHTMLBars: true,
+ revision: "HTMLBars@VERSION_STRING_PLACEHOLDER",
+ arity: 0,
+ cachedFragment: null,
+ hasRendered: false,
+ buildFragment: function buildFragment(dom) {
+ var el0 = dom.createDocumentFragment();
+ var el1 = dom.createElement(tagName);
+
+ for (var key in attributes) {
+ if (typeof attributes[key] !== 'string') { continue; }
+ dom.setAttribute(el1, key, attributes[key]);
+ }
+
+ var el2 = dom.createComment("");
+ dom.appendChild(el1, el2);
+ dom.appendChild(el0, el1);
+ return el0;
+ },
+ buildRenderNodes: function buildRenderNodes(dom, fragment) {
+ var element = dom.childAt(fragment, [0]);
+ var morphs = [];
+
+ for (var key in attributes) {
+ if (typeof attributes[key] === 'string') { continue; }
+ morphs.push(dom.createAttrMorph(element, key));
+ }
+
+ morphs.push(dom.createMorphAt(element, 0, 0));
+ return morphs;
+ },
+ statements: statements,
+ locals: [],
+ templates: []
+ };
+
+ return template;
+}
+
+RenderResult.prototype.render = function() {
+ this.root.lastResult = this;
+ this.root.rendered = true;
+ this.populateNodes(AlwaysDirtyVisitor);
+
+ if (this.shouldSetContent) {
+ this.root.setContent(this.fragment);
+ }
+};
+
+RenderResult.prototype.dirty = function() {
+ visitChildren([this.root], function(node) { node.isDirty = true; });
+};
+
+RenderResult.prototype.revalidate = function(env, self, blockArguments, scope) {
+ this.revalidateWith(env, scope, self, blockArguments, ExpressionVisitor);
+};
+
+RenderResult.prototype.rerender = function(env, self, blockArguments, scope) {
+ this.revalidateWith(env, scope, self, blockArguments, AlwaysDirtyVisitor);
+};
+
+RenderResult.prototype.revalidateWith = function(env, scope, self, blockArguments, visitor) {
+ if (env !== undefined) { this.env = env; }
+ if (scope !== undefined) { this.scope = scope; }
+ this.updateScope();
+
+ if (self !== undefined) { this.updateSelf(self); }
+ if (blockArguments !== undefined) { this.updateLocals(blockArguments); }
+
+ this.populateNodes(visitor);
+};
+
+RenderResult.prototype.destroy = function() {
+ var rootNode = this.root;
+ clearMorph(rootNode, this.env, true);
+};
+
+RenderResult.prototype.populateNodes = function(visitor) {
+ var env = this.env;
+ var scope = this.scope;
+ var template = this.template;
+ var nodes = this.nodes;
+ var statements = template.statements;
+ var i, l;
+
+ for (i=0, l=statements.length; i b.name) {
+ return 1;
+ }
+ if (a.name < b.name) {
+ return -1;
+ }
+ return 0;
+ });
+ }
+ }
+
+ forEach(fragTokens.tokens, normalizeTokens);
+ forEach(htmlTokens.tokens, normalizeTokens);
+
+ var msg = "Expected: " + html + "; Actual: " + fragTokens.html;
+
+ if (message) { msg += " (" + message + ")"; }
+
+ deepEqual(fragTokens.tokens, htmlTokens.tokens, msg);
+}
+
// detect weird IE8 html strings
var ie8InnerHTMLTestElement = document.createElement('div');
ie8InnerHTMLTestElement.setAttribute('id', 'womp');
@@ -103,4 +161,4 @@ export function createObject(obj) {
Temp.prototype = obj;
return new Temp();
}
-}
\ No newline at end of file
+}
diff --git a/packages/htmlbars-util/lib/main.js b/packages/htmlbars-util/lib/main.js
index 1f9c3b95..49f9ad2b 100644
--- a/packages/htmlbars-util/lib/main.js
+++ b/packages/htmlbars-util/lib/main.js
@@ -1,9 +1,13 @@
import SafeString from './htmlbars-util/safe-string';
import { escapeExpression } from './htmlbars-util/handlebars/utils';
import { getAttrNamespace } from './htmlbars-util/namespaces';
+import { validateChildMorphs, linkParams, dump } from './htmlbars-util/morph-utils';
export {
SafeString,
escapeExpression,
- getAttrNamespace
+ getAttrNamespace,
+ validateChildMorphs,
+ linkParams,
+ dump
};
diff --git a/packages/htmlbars-util/lib/morph-utils.js b/packages/htmlbars-util/lib/morph-utils.js
new file mode 100644
index 00000000..9ad3c664
--- /dev/null
+++ b/packages/htmlbars-util/lib/morph-utils.js
@@ -0,0 +1,80 @@
+/*globals console*/
+
+export function visitChildren(nodes, callback) {
+ if (!nodes || nodes.length === 0) { return; }
+
+ nodes = nodes.slice();
+
+ while (nodes.length) {
+ var node = nodes.pop();
+ callback(node);
+
+ if (node.childNodes) {
+ nodes.push.apply(nodes, node.childNodes);
+ } else if (node.firstChildMorph) {
+ var current = node.firstChildMorph;
+
+ while (current) {
+ nodes.push(current);
+ current = current.nextMorph;
+ }
+ } else if (node.morphList) {
+ nodes.push(node.morphList);
+ }
+ }
+}
+
+export function validateChildMorphs(env, morph, visitor) {
+ var morphList = morph.morphList;
+ if (morph.morphList) {
+ var current = morphList.firstChildMorph;
+
+ while (current) {
+ var next = current.nextMorph;
+ validateChildMorphs(env, current, visitor);
+ current = next;
+ }
+ } else if (morph.lastResult) {
+ morph.lastResult.revalidateWith(env, undefined, undefined, undefined, visitor);
+ } else if (morph.childNodes) {
+ // This means that the childNodes were wired up manually
+ for (var i=0, l=morph.childNodes.length; i