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 += '
' + JSON.stringify(data) + '

'; output.appendChild(dom); diff --git a/package.json b/package.json index 4149df67..e80bcb28 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "ember-cli-sauce": "^1.0.0", "git-repo-version": "^0.1.2", "handlebars": "mmun/handlebars.js#new-ast-3238645f", - "morph-range": "^0.1.2", + "morph-range": "^0.2.1", "qunit": "^0.7.2", "rsvp": "~3.0.6" } diff --git a/packages/dom-helper/lib/main.js b/packages/dom-helper/lib/main.js index 3af2d6ac..e364351e 100644 --- a/packages/dom-helper/lib/main.js +++ b/packages/dom-helper/lib/main.js @@ -1,4 +1,4 @@ -import Morph from "./morph-range"; +import Morph from "./htmlbars-runtime/morph"; import AttrMorph from "./morph-attr"; import { buildHTMLDOM, @@ -105,6 +105,15 @@ function buildSVGDOM(html, dom){ return div.firstChild.childNodes; } +function ElementMorph(element, dom, namespace) { + this.element = element; + this.dom = dom; + this.namespace = namespace; + + this.state = {}; + this.isDirty = true; +} + /* * A class wrapping DOM functions to address environment compatibility, * namespaces, contextual elements for morph un-escaped content @@ -342,8 +351,16 @@ prototype.cloneNode = function(element, deep){ return clone; }; +prototype.AttrMorphClass = AttrMorph; + prototype.createAttrMorph = function(element, attrName, namespace){ - return new AttrMorph(element, attrName, this, namespace); + return new this.AttrMorphClass(element, attrName, this, namespace); +}; + +prototype.ElementMorphClass = ElementMorph; + +prototype.createElementMorph = function(element, namespace){ + return new this.ElementMorphClass(element, this, namespace); }; prototype.createUnsafeAttrMorph = function(element, attrName, namespace){ @@ -352,22 +369,45 @@ prototype.createUnsafeAttrMorph = function(element, attrName, namespace){ return morph; }; +prototype.MorphClass = Morph; + prototype.createMorph = function(parent, start, end, contextualElement){ if (contextualElement && contextualElement.nodeType === 11) { throw new Error("Cannot pass a fragment as the contextual element to createMorph"); } - if (!contextualElement && parent.nodeType === 1) { + if (!contextualElement && parent && parent.nodeType === 1) { contextualElement = parent; } - var morph = new Morph(this, contextualElement); + var morph = new this.MorphClass(this, contextualElement); morph.firstNode = start; morph.lastNode = end; - morph.state = {}; - morph.isDirty = true; return morph; }; +prototype.createFragmentMorph = function(contextualElement) { + if (contextualElement && contextualElement.nodeType === 11) { + throw new Error("Cannot pass a fragment as the contextual element to createMorph"); + } + + var fragment = this.createDocumentFragment(); + return Morph.create(this, contextualElement, fragment); +}; + +prototype.replaceContentWithMorph = function(element) { + var firstChild = element.firstChild; + + if (!firstChild) { + var comment = this.createComment(''); + this.appendChild(element, comment); + return Morph.create(this, element, comment); + } else { + var morph = Morph.attach(this, element, firstChild, element.lastChild); + morph.clear(); + return morph; + } +}; + prototype.createUnsafeMorph = function(parent, start, end, contextualElement){ var morph = this.createMorph(parent, start, end, contextualElement); morph.parseTextAsHTML = true; diff --git a/packages/htmlbars-compiler/lib/compiler.js b/packages/htmlbars-compiler/lib/compiler.js index 6d1e9c90..e68a7c80 100644 --- a/packages/htmlbars-compiler/lib/compiler.js +++ b/packages/htmlbars-compiler/lib/compiler.js @@ -1,6 +1,8 @@ /*jshint evil:true*/ import { preprocess } from "../htmlbars-syntax/parser"; import TemplateCompiler from "./template-compiler"; +import { wrap } from "../htmlbars-runtime/hooks"; +import render from "../htmlbars-runtime/render"; /* * Compile a string into a template spec string. The template spec is a string @@ -65,5 +67,5 @@ export function template(templateSpec) { * @return {Template} A function for rendering the template */ export function compile(string, options) { - return template(compileSpec(string, options)); + return wrap(template(compileSpec(string, options)), render); } diff --git a/packages/htmlbars-compiler/lib/fragment-javascript-compiler.js b/packages/htmlbars-compiler/lib/fragment-javascript-compiler.js index 195970e4..e51df292 100644 --- a/packages/htmlbars-compiler/lib/fragment-javascript-compiler.js +++ b/packages/htmlbars-compiler/lib/fragment-javascript-compiler.js @@ -20,7 +20,7 @@ FragmentJavaScriptCompiler.prototype.compile = function(opcodes, options) { this.namespaceFrameStack = [{namespace: null, depth: null}]; this.domNamespace = null; - this.source.push('function build(dom) {\n'); + this.source.push('function buildFragment(dom) {\n'); processOpcodes(this, opcodes); this.source.push(this.indent+'}'); diff --git a/packages/htmlbars-compiler/lib/hydration-javascript-compiler.js b/packages/htmlbars-compiler/lib/hydration-javascript-compiler.js index f2d051d6..21e03828 100644 --- a/packages/htmlbars-compiler/lib/hydration-javascript-compiler.js +++ b/packages/htmlbars-compiler/lib/hydration-javascript-compiler.js @@ -1,5 +1,5 @@ import { processOpcodes } from "./utils"; -import { string, array } from "../htmlbars-util/quoting"; +import { array } from "../htmlbars-util/quoting"; function HydrationJavaScriptCompiler() { this.stack = []; @@ -29,6 +29,11 @@ prototype.compile = function(opcodes, options) { this.hooks = {}; this.hasOpenBoundary = false; this.hasCloseBoundary = false; + this.statements = []; + this.expressionStack = []; + this.locals = []; + this.hasOpenBoundary = false; + this.hasCloseBoundary = false; processOpcodes(this, opcodes); @@ -41,13 +46,31 @@ prototype.compile = function(opcodes, options) { } var i, l; + + var indent = this.indent; + + var morphs; + + var result = { + createMorphsProgram: '', + hydrateMorphsProgram: '', + fragmentProcessingProgram: '', + statements: this.statements, + locals: this.locals, + hasMorphs: false + }; + + result.hydrateMorphsProgram = this.source.join(''); + if (this.morphs.length) { - var morphs = ""; - for (i = 0, l = this.morphs.length; i < l; ++i) { - var morph = this.morphs[i]; - morphs += this.indent+' var '+morph[0]+' = '+morph[1]+';\n'; - } - this.source.unshift(morphs); + result.hasMorphs = true; + morphs = + indent+' var morphs = new Array(' + this.morphs.length + ');\n'; + + for (i = 0, l = this.morphs.length; i < l; ++i) { + var morph = this.morphs[i]; + morphs += indent+' morphs['+i+'] = '+morph+';\n'; + } } if (this.fragmentProcessing.length) { @@ -55,34 +78,54 @@ prototype.compile = function(opcodes, options) { for (i = 0, l = this.fragmentProcessing.length; i < l; ++i) { processing += this.indent+' '+this.fragmentProcessing[i]+'\n'; } - this.source.unshift(processing); + result.fragmentProcessingProgram = processing; } - return this.source.join(''); + var createMorphsProgram; + if (result.hasMorphs) { + createMorphsProgram = + 'function buildRenderNodes(dom, fragment, contextualElement) {\n' + + result.fragmentProcessingProgram + morphs; + + if (this.hasOpenBoundary) { + createMorphsProgram += indent+" dom.insertBoundary(fragment, 0);\n"; + } + + if (this.hasCloseBoundary) { + createMorphsProgram += indent+" dom.insertBoundary(fragment, null);\n"; + } + + createMorphsProgram += + indent + ' return morphs;\n' + + indent+'}'; + } else { + createMorphsProgram = + 'function buildRenderNodes() { return []; }'; + } + + result.createMorphsProgram = createMorphsProgram; + + return result; }; prototype.prepareArray = function(length) { var values = []; for (var i = 0; i < length; i++) { - values.push(this.stack.pop()); + values.push(this.expressionStack.pop()); } - this.stack.push('[' + values.join(', ') + ']'); + this.expressionStack.push(values); }; prototype.prepareObject = function(size) { var pairs = []; for (var i = 0; i < size; i++) { - pairs.push(this.stack.pop() + ': ' + this.stack.pop()); + pairs.push(this.expressionStack.pop(), this.expressionStack.pop()); } - this.stack.push('{' + pairs.join(', ') + '}'); -}; - -prototype.pushRaw = function(value) { - this.stack.push(value); + this.expressionStack.push(pairs); }; prototype.openBoundary = function() { @@ -94,119 +137,76 @@ prototype.closeBoundary = function() { }; prototype.pushLiteral = function(value) { - if (typeof value === 'string') { - this.stack.push(string(value)); - } else { - this.stack.push(value.toString()); - } -}; - -prototype.pushHook = function(name, args) { - this.hooks[name] = true; - this.stack.push(name + '(' + args.join(', ') + ')'); + this.expressionStack.push(value); }; prototype.pushGetHook = function(path) { - this.pushHook('get', [ - 'env', - 'context', - string(path) - ]); + this.expressionStack.push([ 'get', path ]); }; prototype.pushSexprHook = function() { - this.pushHook('subexpr', [ - 'env', - 'context', - this.stack.pop(), // path - this.stack.pop(), // params - this.stack.pop() // hash + this.expressionStack.push([ + 'subexpr', + this.expressionStack.pop(), + this.expressionStack.pop(), + this.expressionStack.pop() ]); }; prototype.pushConcatHook = function() { - this.pushHook('concat', [ - 'env', - this.stack.pop() // parts - ]); + this.expressionStack.push([ 'concat', this.expressionStack.pop() ]); }; -prototype.printHook = function(name, args) { - this.hooks[name] = true; - this.source.push(this.indent + ' ' + name + '(' + args.join(', ') + ');\n'); +prototype.printSetHook = function(name) { + this.locals.push(name); }; -prototype.printSetHook = function(name, index) { - this.printHook('set', [ - 'env', - 'context', - string(name), - 'blockArguments[' + index + ']' +prototype.printBlockHook = function(templateId, inverseId) { + this.statements.push([ + 'block', + this.expressionStack.pop(), // path + this.expressionStack.pop(), // params + this.expressionStack.pop(), // hash + templateId, + inverseId ]); }; -prototype.printBlockHook = function(morphNum, templateId, inverseId) { - this.printHook('block', [ - 'env', - 'morph' + morphNum, - 'context', - this.stack.pop(), // path - this.stack.pop(), // params - this.stack.pop(), // hash - templateId === null ? 'null' : 'child' + templateId, - inverseId === null ? 'null' : 'child' + inverseId - ]); -}; +prototype.printInlineHook = function() { + var path = this.expressionStack.pop(); + var params = this.expressionStack.pop(); + var hash = this.expressionStack.pop(); -prototype.printInlineHook = function(morphNum) { - this.printHook('inline', [ - 'env', - 'morph' + morphNum, - 'context', - this.stack.pop(), // path - this.stack.pop(), // params - this.stack.pop() // hash - ]); + this.statements.push([ 'inline', path, params, hash ]); }; -prototype.printContentHook = function(morphNum) { - this.printHook('content', [ - 'env', - 'morph' + morphNum, - 'context', - this.stack.pop() // path - ]); +prototype.printContentHook = function() { + this.statements.push([ 'content', this.expressionStack.pop() ]); }; -prototype.printComponentHook = function(morphNum, templateId) { - this.printHook('component', [ - 'env', - 'morph' + morphNum, - 'context', - this.stack.pop(), // path - this.stack.pop(), // attrs - templateId === null ? 'null' : 'child' + templateId +prototype.printComponentHook = function(templateId) { + this.statements.push([ + 'component', + this.expressionStack.pop(), // path + this.expressionStack.pop(), // attrs + templateId ]); }; -prototype.printAttributeHook = function(attrMorphNum, elementNum) { - this.printHook('attribute', [ - 'env', - 'attrMorph' + attrMorphNum, - 'element' + elementNum, - this.stack.pop(), // name - this.stack.pop() // value +prototype.printAttributeHook = function() { + this.statements.push([ + 'attribute', + this.expressionStack.pop(), // name + this.expressionStack.pop() // value; ]); }; -prototype.printElementHook = function(elementNum) { - this.printHook('element', [ - 'env', - 'element' + elementNum, - 'context', - this.stack.pop(), // path - this.stack.pop(), // params - this.stack.pop() // hash +prototype.printElementHook = function() { + this.statements.push([ + 'element', + this.expressionStack.pop(), // path + this.expressionStack.pop(), // params + this.expressionStack.pop() // hash ]); }; @@ -220,13 +220,19 @@ prototype.createMorph = function(morphNum, parentPath, startIndex, endIndex, esc ","+(endIndex === null ? "-1" : endIndex)+ (isRoot ? ",contextualElement)" : ")"); - this.morphs.push(['morph' + morphNum, morph]); + this.morphs[morphNum] = morph; }; prototype.createAttrMorph = function(attrMorphNum, elementNum, name, escaped, namespace) { var morphMethod = escaped ? 'createAttrMorph' : 'createUnsafeAttrMorph'; var morph = "dom."+morphMethod+"(element"+elementNum+", '"+name+(namespace ? "', '"+namespace : '')+"')"; - this.morphs.push(['attrMorph' + attrMorphNum, morph]); + this.morphs[attrMorphNum] = morph; +}; + +prototype.createElementMorph = function(morphNum, elementNum ) { + var morphMethod = 'createElementMorph'; + var morph = "dom."+morphMethod+"(element"+elementNum+")"; + this.morphs[morphNum] = morph; }; prototype.repairClonedNode = function(blankChildTextNodes, isElementChecked) { diff --git a/packages/htmlbars-compiler/lib/hydration-opcode-compiler.js b/packages/htmlbars-compiler/lib/hydration-opcode-compiler.js index 630e760b..37273778 100644 --- a/packages/htmlbars-compiler/lib/hydration-opcode-compiler.js +++ b/packages/htmlbars-compiler/lib/hydration-opcode-compiler.js @@ -28,7 +28,6 @@ function HydrationOpcodeCompiler() { this.currentDOMChildIndex = 0; this.morphs = []; this.morphNum = 0; - this.attrMorphNum = 0; this.element = null; this.elementNum = -1; } @@ -60,7 +59,6 @@ HydrationOpcodeCompiler.prototype.startProgram = function(program, c, blankChild this.templateId = 0; this.currentDOMChildIndex = -1; this.morphNum = 0; - this.attrMorphNum = 0; var blockParams = program.blockParams || []; @@ -73,6 +71,10 @@ HydrationOpcodeCompiler.prototype.startProgram = function(program, c, blankChild } }; +HydrationOpcodeCompiler.prototype.insertBoundary = function(first) { + this.opcode(first ? 'openBoundary' : 'closeBoundary'); +}; + HydrationOpcodeCompiler.prototype.endProgram = function() { distributeMorphs(this.morphs, this.opcodes); }; @@ -95,8 +97,7 @@ HydrationOpcodeCompiler.prototype.openElement = function(element, pos, len, must // If our parent reference will be used more than once, cache its reference. if (mustacheCount > 1) { - this.opcode('shareElement', ++this.elementNum); - this.element = null; // Set element to null so we don't cache it twice + shareElement(this); } var isElementChecked = detectIsElementChecked(element); @@ -121,27 +122,31 @@ HydrationOpcodeCompiler.prototype.closeElement = function() { HydrationOpcodeCompiler.prototype.mustache = function(mustache, childIndex, childCount) { this.pushMorphPlaceholderNode(childIndex, childCount); - - var sexpr = mustache.sexpr; - var morphNum = this.morphNum++; - var start = this.currentDOMChildIndex; - var end = this.currentDOMChildIndex; - this.morphs.push([morphNum, this.paths.slice(), start, end, mustache.escaped]); + var sexpr = mustache.sexpr; + var opcode; if (isHelper(sexpr)) { prepareSexpr(this, sexpr); - this.opcode('printInlineHook', morphNum); + opcode = 'printInlineHook'; } else { preparePath(this, sexpr.path); - this.opcode('printContentHook', morphNum); + opcode = 'printContentHook'; } + + var morphNum = this.morphNum++; + var start = this.currentDOMChildIndex; + var end = this.currentDOMChildIndex; + this.morphs.push([morphNum, this.paths.slice(), start, end, mustache.escaped]); + + this.opcode(opcode); }; HydrationOpcodeCompiler.prototype.block = function(block, childIndex, childCount) { this.pushMorphPlaceholderNode(childIndex, childCount); var sexpr = block.sexpr; + prepareSexpr(this, sexpr); var morphNum = this.morphNum++; var start = this.currentDOMChildIndex; @@ -151,8 +156,7 @@ HydrationOpcodeCompiler.prototype.block = function(block, childIndex, childCount var templateId = this.templateId++; var inverseId = block.inverse === null ? null : this.templateId++; - prepareSexpr(this, sexpr); - this.opcode('printBlockHook', morphNum, templateId, inverseId); + this.opcode('printBlockHook', templateId, inverseId); }; HydrationOpcodeCompiler.prototype.component = function(component, childIndex, childCount) { @@ -161,11 +165,6 @@ HydrationOpcodeCompiler.prototype.component = function(component, childIndex, ch var program = component.program || {}; var blockParams = program.blockParams || []; - var morphNum = this.morphNum++; - var start = this.currentDOMChildIndex; - var end = this.currentDOMChildIndex; - this.morphs.push([morphNum, this.paths.slice(), start, end, true]); - var attrs = component.attributes; for (var i = attrs.length - 1; i >= 0; i--) { var name = attrs[i].name; @@ -178,15 +177,20 @@ HydrationOpcodeCompiler.prototype.component = function(component, childIndex, ch this.accept(unwrapMustache(value)); } else if (value.type === 'ConcatStatement') { prepareParams(this, value.parts); - this.opcode('pushConcatHook'); + this.opcode('pushConcatHook', this.morphNum); } this.opcode('pushLiteral', name); } + var morphNum = this.morphNum++; + var start = this.currentDOMChildIndex; + var end = this.currentDOMChildIndex; + this.morphs.push([morphNum, this.paths.slice(), start, end, true]); + this.opcode('prepareObject', attrs.length); this.opcode('pushLiteral', component.tag); - this.opcode('printComponentHook', morphNum, this.templateId++, blockParams.length); + this.opcode('printComponentHook', this.templateId++, blockParams.length); }; HydrationOpcodeCompiler.prototype.attribute = function(attr) { @@ -202,19 +206,19 @@ HydrationOpcodeCompiler.prototype.attribute = function(attr) { this.accept(unwrapMustache(value)); } else if (value.type === 'ConcatStatement') { prepareParams(this, value.parts); - this.opcode('pushConcatHook'); + this.opcode('pushConcatHook', this.morphNum); } this.opcode('pushLiteral', attr.name); + var attrMorphNum = this.morphNum++; + if (this.element !== null) { - this.opcode('shareElement', ++this.elementNum); - this.element = null; + shareElement(this); } - var attrMorphNum = this.attrMorphNum++; this.opcode('createAttrMorph', attrMorphNum, this.elementNum, attr.name, escaped, namespace); - this.opcode('printAttributeHook', attrMorphNum, this.elementNum); + this.opcode('printAttributeHook'); }; HydrationOpcodeCompiler.prototype.elementModifier = function(modifier) { @@ -222,11 +226,11 @@ HydrationOpcodeCompiler.prototype.elementModifier = function(modifier) { // If we have a helper in a node, and this element has not been cached, cache it if (this.element !== null) { - this.opcode('shareElement', ++this.elementNum); - this.element = null; // Reset element so we don't cache it more than once + shareElement(this); } - this.opcode('printElementHook', this.elementNum); + publishElementMorph(this); + this.opcode('printElementHook'); }; HydrationOpcodeCompiler.prototype.pushMorphPlaceholderNode = function(childIndex, childCount) { @@ -295,6 +299,16 @@ function prepareSexpr(compiler, sexpr) { preparePath(compiler, sexpr.path); } +function shareElement(compiler) { + compiler.opcode('shareElement', ++compiler.elementNum); + compiler.element = null; // Set element to null so we don't cache it twice +} + +function publishElementMorph(compiler) { + var morphNum = compiler.morphNum++; + compiler.opcode('createElementMorph', morphNum, compiler.elementNum); +} + function distributeMorphs(morphs, opcodes) { if (morphs.length === 0) { return; diff --git a/packages/htmlbars-compiler/lib/template-compiler.js b/packages/htmlbars-compiler/lib/template-compiler.js index a9551291..3c28ac0f 100644 --- a/packages/htmlbars-compiler/lib/template-compiler.js +++ b/packages/htmlbars-compiler/lib/template-compiler.js @@ -5,6 +5,7 @@ import HydrationJavaScriptCompiler from './hydration-javascript-compiler'; import TemplateVisitor from "./template-visitor"; import { processOpcodes } from "./utils"; import { repeat } from "../htmlbars-util/quoting"; +import { map } from "../htmlbars-util/array-utils"; function TemplateCompiler(options) { this.options = options || {}; @@ -19,11 +20,37 @@ function TemplateCompiler(options) { export default TemplateCompiler; +var dynamicNodes = { + mustache: true, + block: true, + component: true +}; + TemplateCompiler.prototype.compile = function(ast) { var templateVisitor = new TemplateVisitor(); templateVisitor.visit(ast); - processOpcodes(this, templateVisitor.actions); + var normalizedActions = []; + var actions = templateVisitor.actions; + + for (var i=0, l=actions.length - 1; i 0) { templateSignature += ', blockArguments'; } + var statements = map(hydrationPrograms.statements, function(s) { + return indent+' '+JSON.stringify(s); + }).join(",\n"); + + var locals = JSON.stringify(hydrationPrograms.locals); + + var templates = map(this.childTemplates, function(_, index) { + return 'child' + index; + }).join(', '); + var template = '(function() {\n' + this.getChildTemplateVars(indent + ' ') + indent+' return {\n' + indent+' isHTMLBars: true,\n' + indent+' revision: "' + this.revision + '",\n' + - indent+' blockParams: ' + blockParams.length + ',\n' + + indent+' arity: ' + blockParams.length + ',\n' + indent+' cachedFragment: null,\n' + indent+' hasRendered: false,\n' + - indent+' build: ' + fragmentProgram + ',\n' + - indent+' render: function render(' + templateSignature + ') {\n' + - indent+' var dom = env.dom;\n' + - this.getHydrationHooks(indent + ' ', this.hydrationCompiler.hooks) + - indent+' dom.detectNamespace(contextualElement);\n' + - indent+' var fragment;\n' + - indent+' if (env.useFragmentCache && dom.canClone) {\n' + - indent+' if (this.cachedFragment === null) {\n' + - indent+' fragment = this.build(dom);\n' + - indent+' if (this.hasRendered) {\n' + - indent+' this.cachedFragment = fragment;\n' + - indent+' } else {\n' + - indent+' this.hasRendered = true;\n' + - indent+' }\n' + - indent+' }\n' + - indent+' if (this.cachedFragment) {\n' + - indent+' fragment = dom.cloneNode(this.cachedFragment, true);\n' + - indent+' }\n' + - indent+' } else {\n' + - indent+' fragment = this.build(dom);\n' + - indent+' }\n' + - hydrationProgram + - indent+' return fragment;\n' + - indent+' }\n' + + indent+' buildFragment: ' + fragmentProgram + ',\n' + + indent+' buildRenderNodes: ' + hydrationPrograms.createMorphsProgram + ',\n' + + indent+' statements: [\n' + statements + '\n' + + indent+' ],\n' + + indent+' locals: ' + locals + ',\n' + + indent+' templates: [' + templates + ']\n' + indent+' };\n' + indent+'}())'; diff --git a/packages/htmlbars-compiler/tests/dirtying-test.js b/packages/htmlbars-compiler/tests/dirtying-test.js new file mode 100644 index 00000000..b98745da --- /dev/null +++ b/packages/htmlbars-compiler/tests/dirtying-test.js @@ -0,0 +1,708 @@ +import { compile } from "../htmlbars-compiler/compiler"; +import { manualElement } from "../htmlbars-runtime/render"; +import { hostBlock } from "../htmlbars-runtime/hooks"; +import render from "../htmlbars-runtime/render"; +import { blockFor } from "../htmlbars-util/template-utils"; +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 + }; + + registerHelper('if', function(params, hash, options) { + if (!!params[0]) { + return options.template.yield(); + } else if (options.inverse.yield) { + return options.inverse.yield(); + } + }); + + registerHelper('each', function(params) { + var list = params[0]; + + for (var i=0, l=list.length; i 0) { + this.yieldItem(item.key, [item]); + } else { + this.yieldItem(item.key, undefined, item); + } + } + }); + +} + +QUnit.module("HTML-based compiler (dirtying)", { + beforeEach: commonSetup +}); + +test("a simple implementation of a dirtying rerender", function() { + var object = { condition: true, value: 'hello world' }; + var template = compile('
{{#if condition}}

{{value}}

{{else}}

Nothing

{{/if}}
'); + var result = template.render(object, env); + var valueNode = result.fragment.firstChild.firstChild.firstChild; + + equalTokens(result.fragment, '

hello world

', "Initial render"); + + result.rerender(); + + equalTokens(result.fragment, '

hello world

', "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, '

Hello world

'); + + result.rerender(); + + equalTokens(result.fragment, '

Hello world

'); + strictEqual(getValueNode(), valueNode); + + object.title = "Goodbye world"; + + result.rerender(); + equalTokens(result.fragment, '

Goodbye world

'); + strictEqual(getValueNode(), valueNode); + + function getValueNode() { + return result.fragment.firstChild.firstChild.firstChild; + } +}); + +test("a dirtying rerender using `yieldIn` and self", function() { + var component = compile("

{{attrs.name}}{{yield}}

"); + var template = compile("
{{title}}
"); + + registerHelper("simple-component", function(params, hash) { + return this.yieldIn(component, { attrs: hash }); + }); + + var object = { title: "Hello world" }; + var result = template.render(object, env); + + var nameNode = getNameNode(); + var titleNode = getTitleNode(); + equalTokens(result.fragment, '

Yo! Hello world

'); + + rerender(); + equalTokens(result.fragment, '

Yo! Hello world

'); + assertStableNodes(); + + object.title = "Goodbye world"; + + rerender(); + equalTokens(result.fragment, '

Yo! Goodbye world

'); + 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("a dirtying rerender using `yieldIn`, self and block args", function() { + var component = compile("

{{yield attrs.name}}

"); + var template = compile("
{{key}}{{title}}
"); + + registerHelper("simple-component", function(params, hash) { + return this.yieldIn(component, { attrs: hash }); + }); + + var object = { title: "Hello world" }; + var result = template.render(object, env); + + var nameNode = getNameNode(); + var titleNode = getTitleNode(); + equalTokens(result.fragment, '

Yo! Hello world

'); + + rerender(); + equalTokens(result.fragment, '

Yo! Hello world

'); + assertStableNodes(); + + object.title = "Goodbye world"; + + rerender(); + equalTokens(result.fragment, '

Yo! Goodbye world

'); + 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, '
HELLO
'); + + var textRenderNode = result.root.childNodes[0]; + + result.rerender(); + + equalTokens(result.fragment, '
GOODBYE
'); + + textRenderNode.setContent = function() { + ok(false, "Should not get called"); + }; + + // Checks normalized value, not raw value + object.value = "GoOdByE"; + result.rerender(); +}); + +test("attribute nodes follow the normal dirtying rules", function() { + var template = compile("
hello
"); + var object = { value: "world" }; + var result = template.render(object, env); + + equalTokens(result.fragment, "
hello
", "Initial render"); + + object.value = "universe"; + result.revalidate(); // without setting the node to dirty + + equalTokens(result.fragment, "
hello
", "Revalidating without dirtying"); + + var attrRenderNode = result.root.childNodes[0]; + + result.rerender(); + + equalTokens(result.fragment, "
hello
", "Revalidating after dirtying"); + + attrRenderNode.setContent = function() { + ok(false, "Should not get called"); + }; + + object.value = "universe"; + result.rerender(); +}); + +test("attribute nodes w/ concat follow the normal dirtying rules", function() { + var template = compile("
hello
"); + var object = { value: "world" }; + var result = template.render(object, env); + + equalTokens(result.fragment, "
hello
"); + + object.value = "universe"; + result.revalidate(); // without setting the node to dirty + + equalTokens(result.fragment, "
hello
"); + + var attrRenderNode = result.root.childNodes[0]; + + result.rerender(); + + equalTokens(result.fragment, "
hello
"); + + attrRenderNode.setContent = function() { + ok(false, "Should not get called"); + }; + + object.value = "universe"; + result.rerender(); +}); + +testEachHelper( + "An implementation of #each using block params", + "
    {{#each list as |item|}}
  • {{item.name}}
  • {{/each}}
" +); + +testEachHelper( + "An implementation of #each using a self binding", + "
    {{#each list}}
  • {{name}}
  • {{/each}}
" +); + +function testEachHelper(testName, templateSource) { + test(testName, function() { + var template = compile(templateSource); + var object = { list: [ + { key: "1", name: "Tom Dale", "class": "tomdale" }, + { key: "2", name: "Yehuda Katz", "class": "wycats" } + ]}; + var result = template.render(object, env); + + var itemNode = getItemNode('tomdale'); + var nameNode = getNameNode('tomdale'); + + equalTokens(result.fragment, "
  • Tom Dale
  • Yehuda Katz
", "Initial render"); + + rerender(); + assertStableNodes('tomdale', "after no-op rerender"); + equalTokens(result.fragment, "
  • Tom Dale
  • Yehuda Katz
", "After no-op re-render"); + + result.revalidate(); + assertStableNodes('tomdale', "after non-dirty rerender"); + equalTokens(result.fragment, "
  • Tom Dale
  • Yehuda Katz
", "After no-op re-render"); + + object = { list: [object.list[1], object.list[0]] }; + rerender(object); + assertStableNodes('tomdale', "after changing the list order"); + equalTokens(result.fragment, "
  • Yehuda Katz
  • Tom Dale
", "After changing the list order"); + + object = { list: [ + { key: "1", name: "Martin Muñoz", "class": "mmun" }, + { key: "2", name: "Kris Selden", "class": "krisselden" } + ]}; + rerender(object); + assertStableNodes('mmun', "after changing the list entries, but with stable keys"); + equalTokens(result.fragment, "
  • Martin Muñoz
  • Kris Selden
", "After changing the list entries, but with stable keys"); + + object = { list: [ + { key: "1", name: "Martin Muñoz", "class": "mmun" }, + { key: "2", name: "Kristoph Selden", "class": "krisselden" }, + { key: "3", name: "Matthew Beale", "class": "mixonic" } + ]}; + + rerender(object); + assertStableNodes('mmun', "after adding an additional entry"); + equalTokens(result.fragment, "
  • Martin Muñoz
  • Kristoph Selden
  • Matthew Beale
", "After adding an additional entry"); + + object = { list: [ + { key: "1", name: "Martin Muñoz", "class": "mmun" }, + { key: "3", name: "Matthew Beale", "class": "mixonic" } + ]}; + + rerender(object); + assertStableNodes('mmun', "after removing the middle entry"); + equalTokens(result.fragment, "
  • Martin Muñoz
  • Matthew Beale
", "After adding an additional entry"); + + object = { list: [ + { key: "1", name: "Martin Muñoz", "class": "mmun" }, + { key: "4", name: "Stefan Penner", "class": "stefanpenner" }, + { key: "5", name: "Robert Jackson", "class": "rwjblue" } + ]}; + + rerender(object); + assertStableNodes('mmun', "after adding two more entries"); + equalTokens(result.fragment, "
  • Martin Muñoz
  • Stefan Penner
  • Robert Jackson
", "After adding two more entries"); + + // New node for stability check + itemNode = getItemNode('rwjblue'); + nameNode = getNameNode('rwjblue'); + + object = { list: [ + { key: "5", name: "Robert Jackson", "class": "rwjblue" } + ]}; + + rerender(object); + assertStableNodes('rwjblue', "after removing two entries"); + equalTokens(result.fragment, "
  • Robert Jackson
", "After removing two entries"); + + object = { list: [ + { key: "1", name: "Martin Muñoz", "class": "mmun" }, + { key: "4", name: "Stefan Penner", "class": "stefanpenner" }, + { key: "5", name: "Robert Jackson", "class": "rwjblue" } + ]}; + + rerender(object); + assertStableNodes('rwjblue', "after adding back entries"); + equalTokens(result.fragment, "
  • Martin Muñoz
  • Stefan Penner
  • Robert Jackson
", "After adding back entries"); + + // New node for stability check + itemNode = getItemNode('mmun'); + nameNode = getNameNode('mmun'); + + object = { list: [ + { key: "1", name: "Martin Muñoz", "class": "mmun" } + ]}; + + rerender(object); + assertStableNodes('mmun', "after removing from the back"); + equalTokens(result.fragment, "
  • Martin Muñoz
", "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('
    {{testing title}}
    ', '
    hello
    ', { title: 'hello' }); }); +test("Helpers propagate the owner render node", function() { + registerHelper('id', function() { + return this.yield(); + }); + + var template = compile('
    {{#id}}

    {{#id}}{{#id}}{{name}}{{/id}}{{/id}}

    {{/id}}
    '); + var context = { name: "Tom Dale" }; + var result = template.render(context, env); + + equalTokens(result.fragment, '

    Tom Dale

    '); + + var root = result.root; + strictEqual(root, root.childNodes[0].ownerNode); + strictEqual(root, root.childNodes[0].childNodes[0].ownerNode); + strictEqual(root, root.childNodes[0].childNodes[0].childNodes[0].ownerNode); +}); + test("The compiler can handle sexpr helpers", function() { registerHelper('testing', function(params) { return params[0] + "!"; @@ -387,106 +368,32 @@ test("The compiler passes along the hash arguments", function() { compilesTo('
    {{testing first="one" second="two"}}
    ', '
    one-two
    '); }); -test("Simple data binding using text nodes", function() { - var callback; - - hooks.content = function(env, morph, context, path) { - callback = function() { - morph.setContent(context[path]); - }; - callback(); - }; - - var object = { title: 'hello' }; - var fragment = compilesTo('
    {{title}} world
    ', '
    hello world
    ', object); - - object.title = 'goodbye'; - callback(); - - equalTokens(fragment, '
    goodbye world
    '); - - object.title = 'brown cow'; - callback(); - - equalTokens(fragment, '
    brown cow world
    '); -}); - -test("Simple data binding on fragments", function() { - var callback; - - hooks.content = function(env, morph, context, path) { - morph.parseTextAsHTML = true; - callback = function() { - morph.setContent(context[path]); - }; - callback(); - }; - - var object = { title: '

    hello

    to the' }; - var fragment = compilesTo('
    {{title}} world
    ', '

    hello

    to the world
    ', object); - - object.title = '

    goodbye

    to the'; - callback(); - - equalTokens(fragment, '

    goodbye

    to the world
    '); - - object.title = '

    brown cow

    to the'; - callback(); - - equalTokens(fragment, '

    brown cow

    to the world
    '); -}); - test("second render respects whitespace", function () { var template = compile('Hello {{ foo }} '); - template.render({}, env, document.createElement('div')); - var fragment = template.render({}, env, document.createElement('div')); + template.render({}, env, { contextualElement: document.createElement('div') }); + var fragment = template.render({}, env, { contextualElement: document.createElement('div') }).fragment; equal(fragment.childNodes.length, 3, 'fragment contains 3 text nodes'); equal(getTextContent(fragment.childNodes[0]), 'Hello ', 'first text node ends with one space character'); equal(getTextContent(fragment.childNodes[2]), ' ', 'last text node contains one space character'); }); -test("morph receives escaping information", function() { - expect(3); - - hooks.content = function(env, morph, context, path) { - if (path === 'escaped') { - equal(morph.parseTextAsHTML, false); - } else if (path === 'unescaped') { - equal(morph.parseTextAsHTML, true); - } - - morph.setContent(path); - }; - - // so we NEED a reference to div. because it's passed in twice. - // not divs childNodes. - // the parent we need to save is fragment.childNodes - compilesTo('
    {{escaped}}-{{{unescaped}}}
    ', '
    escaped-unescaped
    '); -}); - test("Morphs are escaped correctly", function() { - expect(10); - - registerHelper('testing-unescaped', function(params, hash, options) { - equal(options.morph.parseTextAsHTML, true); - + registerHelper('testing-unescaped', function(params) { return params[0]; }); - registerHelper('testing-escaped', function(params, hash, options, env) { - equal(options.morph.parseTextAsHTML, false); - - if (options.template) { - return options.template.render({}, env, options.morph.contextualElement); + registerHelper('testing-escaped', function(params) { + if (this.yield) { + return this.yield(); } return params[0]; }); - compilesTo('
    {{{testing-unescaped}}}-{{{testing-unescaped "a"}}}
    ', '
    -a
    '); - compilesTo('
    {{testing-escaped}}-{{testing-escaped "b"}}
    ', '
    -b
    '); - compilesTo('
    {{#testing-escaped}}c{{/testing-escaped}}
    ', '
    c
    '); - compilesTo('
    c
    ', '
    c
    '); + compilesTo('
    {{{testing-unescaped "hi"}}}
    ', '
    hi
    '); + compilesTo('
    {{testing-escaped ""}}
    ', '
    <hi>
    '); + compilesTo('
    {{#testing-escaped}}{{/testing-escaped}}
    ', '
    '); + compilesTo('
    ', '
    '); }); test("Attributes can use computed values", function() { @@ -677,42 +584,39 @@ test("Attribute runs can contain helpers", function() { }); */ test("A simple block helper can return the default document fragment", function() { - registerHelper('testing', function(params, hash, options, env) { - return options.template.render(this, env); - }); + registerHelper('testing', function() { return this.yield(); }); compilesTo('{{#testing}}
    123
    {{/testing}}', '
    123
    '); }); +// TODO: NEXT test("A simple block helper can return text", function() { - registerHelper('testing', function(params, hash, options, env) { - return options.template.render(this, env); - }); + registerHelper('testing', function() { return this.yield(); }); compilesTo('{{#testing}}test{{else}}not shown{{/testing}}', 'test'); }); test("A block helper can have an else block", function() { - registerHelper('testing', function(params, hash, options, env) { - return options.inverse.render(this, env); + registerHelper('testing', function(params, hash, options) { + return options.inverse.yield(); }); compilesTo('{{#testing}}Nope{{else}}
    123
    {{/testing}}', '
    123
    '); }); test("A block helper can pass a context to be used in the child", function() { - registerHelper('testing', function(params, hash, options, env) { + registerHelper('testing', function(params, hash, options) { var context = { title: 'Rails is omakase' }; - return options.template.render(context, env); + return options.template.render(context); }); compilesTo('{{#testing}}
    {{title}}
    {{/testing}}', '
    Rails is omakase
    '); }); test("Block helpers receive hash arguments", function() { - registerHelper('testing', function(params, hash, options, env) { + registerHelper('testing', function(params, hash) { if (hash.truth) { - return options.template.render(this, env); + return this.yield(); } }); @@ -766,39 +670,34 @@ test("Node helpers can modify the node after many nodes returned from top-level }); test("Node helpers can be used for attribute bindings", function() { - var callback; - registerHelper('testing', function(params, hash, options) { - var path = hash.href, + var value = hash.href, element = options.element; - var context = this; - - callback = function() { - var value = context[path]; - element.setAttribute('href', value); - }; - callback(); + element.setAttribute('href', value); }); var object = { url: 'linky.html' }; - var fragment = compilesTo('linky', 'linky', object); + var template = compile('linky'); + var result = template.render(object, env); + equalTokens(result.fragment, 'linky'); object.url = 'zippy.html'; - callback(); - equalTokens(fragment, 'linky'); + result.dirty(); + result.revalidate(); + + equalTokens(result.fragment, 'linky'); }); test('Components - Called as helpers', function () { - registerHelper('x-append', function(params, hash, options, env) { - var fragment = options.template.render(this, env, options.morph.contextualElement); - fragment.appendChild(document.createTextNode(hash.text)); - return fragment; + registerHelper('x-append', function(params, hash) { + QUnit.deepEqual(hash, { text: "de" }); + this.yield(); }); var object = { bar: 'e', baz: 'c' }; - compilesTo('ab{{baz}}f','abcdef', object); + compilesTo('ab{{baz}}f','abcf', object); }); if (innerHTMLSupportsCustomTags) { @@ -833,30 +732,20 @@ test('Repaired text nodes are ensured in the right place', function () { test("Simple elements can have dashed attributes", function() { var template = compile("
    content
    "); - var fragment = template.render({}, env); + var fragment = template.render({}, env).fragment; equalTokens(fragment, '
    content
    '); }); test("Block params", function() { - registerHelper('a', function(params, hash, options, env) { - var context = createObject(this); - var span = document.createElement('span'); - span.appendChild(options.template.render(context, env, document.body, ['W', 'X1'])); - return 'A(' + span.innerHTML + ')'; + registerHelper('a', function() { + this.yieldIn(compile("A({{yield 'W' 'X1'}})")); }); - registerHelper('b', function(params, hash, options, env) { - var context = createObject(this); - var span = document.createElement('span'); - span.appendChild(options.template.render(context, env, document.body, ['X2', 'Y'])); - return 'B(' + span.innerHTML + ')'; + registerHelper('b', function() { + this.yieldIn(compile("B({{yield 'X2' 'Y'}})")); }); - registerHelper('c', function(params, hash, options, env) { - var context = createObject(this); - var span = document.createElement('span'); - span.appendChild(options.template.render(context, env, document.body, ['Z'])); - return 'C(' + span.innerHTML + ')'; - // return "C(" + options.template.render() + ")"; + registerHelper('c', function() { + this.yieldIn(compile("C({{yield 'Z'}})")); }); var t = '{{#a as |w x|}}{{w}},{{x}} {{#b as |x y|}}{{x}},{{y}}{{/b}} {{w}},{{x}} {{#c as |z|}}{{x}},{{z}}{{/c}}{{/a}}'; compilesTo(t, 'A(W,X1 B(X2,Y) W,X1 C(X1,Z))', {}); @@ -866,21 +755,20 @@ test("Block params - Helper should know how many block params it was called with expect(4); registerHelper('count-block-params', function(params, hash, options) { - equal(options.template.blockParams, this.count, 'Helpers should receive the correct number of block params in options.template.blockParams.'); + equal(options.template.arity, hash.count, 'Helpers should receive the correct number of block params in options.template.blockParams.'); }); - compile('{{#count-block-params}}{{/count-block-params}}').render({ count: 0 }, env, document.body); - compile('{{#count-block-params as |x|}}{{/count-block-params}}').render({ count: 1 }, env, document.body); - compile('{{#count-block-params as |x y|}}{{/count-block-params}}').render({ count: 2 }, env, document.body); - compile('{{#count-block-params as |x y z|}}{{/count-block-params}}').render({ count: 3 }, env, document.body); + compile('{{#count-block-params count=0}}{{/count-block-params}}').render({}, env, { contextualElement: document.body }); + compile('{{#count-block-params count=1 as |x|}}{{/count-block-params}}').render({}, env, { contextualElement: document.body }); + compile('{{#count-block-params count=2 as |x y|}}{{/count-block-params}}').render({}, env, { contextualElement: document.body }); + compile('{{#count-block-params count=3 as |x y z|}}{{/count-block-params}}').render({}, env, { contextualElement: document.body }); }); test('Block params in HTML syntax', function () { - registerHelper('x-bar', function(params, hash, options, env) { - var context = createObject(this); - var span = document.createElement('span'); - span.appendChild(options.template.render(context, env, document.body, ['Xerxes', 'York', 'Zed'])); - return 'BAR(' + span.innerHTML + ')'; + var layout = compile("BAR({{yield 'Xerxes' 'York' 'Zed'}})"); + + registerHelper('x-bar', function() { + this.yieldIn(layout); }); compilesTo('{{zee}},{{y}},{{x}}', 'BAR(Zed,York,Xerxes)', {}); }); @@ -898,8 +786,8 @@ test('Block params in HTML syntax - Throws exception if given zero parameters', test('Block params in HTML syntax - Works with a single parameter', function () { - registerHelper('x-bar', function(params, hash, options, env) { - return options.template.render({}, env, document.body, ['Xerxes']); + registerHelper('x-bar', function() { + return this.yield(['Xerxes']); }); compilesTo('{{x}}', 'Xerxes', {}); }); @@ -908,14 +796,14 @@ test('Block params in HTML syntax - Works with other attributes', function () { registerHelper('x-bar', function(params, hash) { deepEqual(hash, {firstName: 'Alice', lastName: 'Smith'}); }); - compile('').render({}, env, document.body); + compile('').render({}, env, { contextualElement: document.body }); }); test('Block params in HTML syntax - Ignores whitespace', function () { expect(3); - registerHelper('x-bar', function(params, hash, options) { - return options.template.render({}, env, document.body, ['Xerxes', 'York']); + registerHelper('x-bar', function() { + return this.yield(['Xerxes', 'York']); }); compilesTo('{{x}},{{y}}', 'Xerxes,York', {}); compilesTo('{{x}},{{y}}', 'Xerxes,York', {}); @@ -926,13 +814,13 @@ test('Block params in HTML syntax - Helper should know how many block params it expect(4); registerHelper('count-block-params', function(params, hash, options) { - equal(options.template.blockParams, this.count, 'Helpers should receive the correct number of block params in options.template.blockParams.'); + equal(options.template.arity, parseInt(hash.count, 10), 'Helpers should receive the correct number of block params in options.template.blockParams.'); }); - compile('').render({ count: 0 }, env, document.body); - compile('').render({ count: 1 }, env, document.body); - compile('').render({ count: 2 }, env, document.body); - compile('').render({ count: 3 }, env, document.body); + compile('').render({ count: 0 }, env, { contextualElement: document.body }); + compile('').render({ count: 1 }, env, { contextualElement: document.body }); + compile('').render({ count: 2 }, env, { contextualElement: document.body }); + compile('').render({ count: 3 }, env, { contextualElement: document.body }); }); test("Block params in HTML syntax - Throws an error on invalid block params syntax", function() { @@ -1066,7 +954,7 @@ QUnit.module("HTML-based compiler (output, svg)", { test("Simple elements can have namespaced attributes", function() { var template = compile("content"); - var svgNode = template.render({}, env).firstChild; + var svgNode = template.render({}, env).fragment.firstChild; equalTokens(svgNode, 'content'); equal(svgNode.attributes[0].namespaceURI, 'http://www.w3.org/1999/xlink'); @@ -1074,7 +962,7 @@ test("Simple elements can have namespaced attributes", function() { test("Simple elements can have bound namespaced attributes", function() { var template = compile("content"); - var svgNode = template.render({title: 'svg-title'}, env).firstChild; + var svgNode = template.render({title: 'svg-title'}, env).fragment.firstChild; equalTokens(svgNode, 'content'); equal(svgNode.attributes[0].namespaceURI, 'http://www.w3.org/1999/xlink'); @@ -1082,14 +970,14 @@ test("Simple elements can have bound namespaced attributes", function() { test("SVG element can have capitalized attributes", function() { var template = compile(""); - var svgNode = template.render({}, env).firstChild; + var svgNode = template.render({}, env).fragment.firstChild; equalTokens(svgNode, ''); }); test("The compiler can handle namespaced elements", function() { var html = ''; var template = compile(html); - var svgNode = template.render({}, env).firstChild; + var svgNode = template.render({}, env).fragment.firstChild; equal(svgNode.namespaceURI, svgNamespace, "creates the svg element with a namespace"); equalTokens(svgNode, html); @@ -1098,7 +986,7 @@ test("The compiler can handle namespaced elements", function() { test("The compiler sets namespaces on nested namespaced elements", function() { var html = ''; var template = compile(html); - var svgNode = template.render({}, env).firstChild; + var svgNode = template.render({}, env).fragment.firstChild; equal( svgNode.childNodes[0].namespaceURI, svgNamespace, "creates the path element with a namespace" ); @@ -1108,7 +996,7 @@ test("The compiler sets namespaces on nested namespaced elements", function() { test("The compiler sets a namespace on an HTML integration point", function() { var html = 'Hi'; var template = compile(html); - var svgNode = template.render({}, env).firstChild; + var svgNode = template.render({}, env).fragment.firstChild; equal( svgNode.namespaceURI, svgNamespace, "creates the svg element with a namespace" ); @@ -1120,7 +1008,7 @@ test("The compiler sets a namespace on an HTML integration point", function() { test("The compiler does not set a namespace on an element inside an HTML integration point", function() { var html = '
    '; var template = compile(html); - var svgNode = template.render({}, env).firstChild; + var svgNode = template.render({}, env).fragment.firstChild; equal( svgNode.childNodes[0].childNodes[0].namespaceURI, xhtmlNamespace, "creates the div inside the foreignObject without a namespace" ); @@ -1130,7 +1018,7 @@ test("The compiler does not set a namespace on an element inside an HTML integra test("The compiler pops back to the correct namespace", function() { var html = '
    '; var template = compile(html); - var fragment = template.render({}, env); + var fragment = template.render({}, env).fragment; equal( fragment.childNodes[0].namespaceURI, svgNamespace, "creates the first svg element with a namespace" ); @@ -1143,7 +1031,7 @@ test("The compiler pops back to the correct namespace", function() { test("The compiler pops back to the correct namespace even if exiting last child", function () { var html = '
    '; - var fragment = compile(html).render({}, env); + var fragment = compile(html).render({}, env).fragment; equal(fragment.firstChild.namespaceURI, xhtmlNamespace, "first div's namespace is xhtmlNamespace"); equal(fragment.firstChild.firstChild.namespaceURI, svgNamespace, "svg's namespace is svgNamespace"); @@ -1153,7 +1041,7 @@ test("The compiler pops back to the correct namespace even if exiting last child test("The compiler preserves capitalization of tags", function() { var html = ''; var template = compile(html); - var fragment = template.render({}, env); + var fragment = template.render({}, env).fragment; equalTokens(fragment, html); }); @@ -1161,7 +1049,8 @@ test("The compiler preserves capitalization of tags", function() { test("svg can live with hydration", function() { var template = compile('{{name}}'); - var fragment = template.render({ name: 'Milly' }, env, document.body); + var fragment = template.render({ name: 'Milly' }, env, { contextualElement: document.body }).fragment; + equal( fragment.childNodes[0].namespaceURI, svgNamespace, "svg namespace inside a block is present" ); @@ -1169,7 +1058,7 @@ test("svg can live with hydration", function() { test("top-level unsafe morph uses the correct namespace", function() { var template = compile('{{{foo}}}'); - var fragment = template.render({ foo: 'FOO' }, env, document.body); + var fragment = template.render({ foo: 'FOO' }, env, { contextualElement: document.body }).fragment; equal(getTextContent(fragment), 'FOO', 'element from unsafe morph is displayed'); equal(fragment.childNodes[1].namespaceURI, xhtmlNamespace, 'element from unsafe morph has correct namespace'); @@ -1177,7 +1066,7 @@ test("top-level unsafe morph uses the correct namespace", function() { test("nested unsafe morph uses the correct namespace", function() { var template = compile('{{{foo}}}
    '); - var fragment = template.render({ foo: '' }, env, document.body); + var fragment = template.render({ foo: '' }, env, { contextualElement: document.body }).fragment; equal(fragment.childNodes[0].childNodes[0].namespaceURI, svgNamespace, 'element from unsafe morph has correct namespace'); @@ -1186,7 +1075,7 @@ test("nested unsafe morph uses the correct namespace", function() { test("svg can take some hydration", function() { var template = compile('
    {{name}}
    '); - var fragment = template.render({ name: 'Milly' }, env); + var fragment = template.render({ name: 'Milly' }, env).fragment; equal( fragment.firstChild.childNodes[0].namespaceURI, svgNamespace, "svg namespace inside a block is present" ); @@ -1196,8 +1085,9 @@ test("svg can take some hydration", function() { test("root svg can take some hydration", function() { var template = compile('{{name}}'); - var fragment = template.render({ name: 'Milly' }, env); + var fragment = template.render({ name: 'Milly' }, env).fragment; var svgNode = fragment.firstChild; + equal( svgNode.namespaceURI, svgNamespace, "svg namespace inside a block is present" ); @@ -1208,24 +1098,23 @@ test("root svg can take some hydration", function() { test("Block helper allows interior namespace", function() { var isTrue = true; - registerHelper('testing', function(params, hash, options, env) { - var morph = options.morph; + registerHelper('testing', function(params, hash, options) { if (isTrue) { - return options.template.render(this, env, morph.contextualElement); + return this.yield(); } else { - return options.inverse.render(this, env, morph.contextualElement); + return options.inverse.yield(); } }); var template = compile('{{#testing}}{{else}}
    {{/testing}}'); - var fragment = template.render({ isTrue: true }, env, document.body); + var fragment = template.render({ isTrue: true }, env, { contextualElement: document.body }).fragment; equal( fragment.firstChild.nextSibling.namespaceURI, svgNamespace, "svg namespace inside a block is present" ); isTrue = false; - fragment = template.render({ isTrue: false }, env, document.body); + fragment = template.render({ isTrue: false }, env, { contextualElement: document.body }).fragment; equal( fragment.firstChild.nextSibling.namespaceURI, xhtmlNamespace, "inverse block path has a normal namespace"); @@ -1235,14 +1124,13 @@ test("Block helper allows interior namespace", function() { }); test("Block helper allows namespace to bleed through", function() { - registerHelper('testing', function(params, hash, options, env) { - var morph = options.morph; - return options.template.render(this, env, morph.contextualElement); + registerHelper('testing', function() { + return this.yield(); }); var template = compile('
    {{#testing}}{{/testing}}
    '); - var fragment = template.render({ isTrue: true }, env); + var fragment = template.render({ isTrue: true }, env).fragment; var svgNode = fragment.firstChild.firstChild; equal( svgNode.namespaceURI, svgNamespace, "svg tag has an svg namespace" ); @@ -1251,14 +1139,13 @@ test("Block helper allows namespace to bleed through", function() { }); test("Block helper with root svg allows namespace to bleed through", function() { - registerHelper('testing', function(params, hash, options, env) { - var morph = options.morph; - return options.template.render(this, env, morph.contextualElement); + registerHelper('testing', function() { + return this.yield(); }); var template = compile('{{#testing}}{{/testing}}'); - var fragment = template.render({ isTrue: true }, env); + var fragment = template.render({ isTrue: true }, env).fragment; var svgNode = fragment.firstChild; equal( svgNode.namespaceURI, svgNamespace, "svg tag has an svg namespace" ); @@ -1267,14 +1154,13 @@ test("Block helper with root svg allows namespace to bleed through", function() }); test("Block helper with root foreignObject allows namespace to bleed through", function() { - registerHelper('testing', function(params, hash, options, env) { - var morph = options.morph; - return options.template.render(this, env, morph.contextualElement); + registerHelper('testing', function() { + return this.yield(); }); var template = compile('{{#testing}}
    {{/testing}}
    '); - var fragment = template.render({ isTrue: true }, env, document.createElementNS(svgNamespace, 'svg')); + var fragment = template.render({ isTrue: true }, env, { contextualElement: document.createElementNS(svgNamespace, 'svg') }).fragment; var svgNode = fragment.firstChild; equal( svgNode.namespaceURI, svgNamespace, "foreignObject tag has an svg namespace" ); diff --git a/packages/htmlbars-compiler/tests/hydration-opcode-compiler-test.js b/packages/htmlbars-compiler/tests/hydration-opcode-compiler-test.js index a4832d2d..0087fe67 100644 --- a/packages/htmlbars-compiler/tests/hydration-opcode-compiler-test.js +++ b/packages/htmlbars-compiler/tests/hydration-opcode-compiler-test.js @@ -18,9 +18,9 @@ test("simple example", function() { [ "createMorph", [ 0, [ 0 ], 0, 0, true ] ], [ "createMorph", [ 1, [ 0 ], 2, 2, true ] ], [ "pushLiteral", [ "foo" ] ], - [ "printContentHook", [ 0 ] ], + [ "printContentHook", [ ] ], [ "pushLiteral", [ "baz" ] ], - [ "printContentHook", [ 1 ] ], + [ "printContentHook", [ ] ], [ "popParent", [] ] ]); }); @@ -33,7 +33,7 @@ test("simple block", function() { [ "prepareObject", [ 0 ] ], [ "prepareArray", [ 0 ] ], [ "pushLiteral", [ "foo" ] ], - [ "printBlockHook", [ 0, 0, null ] ], + [ "printBlockHook", [ 0, null ] ], [ "popParent", [] ] ]); }); @@ -46,7 +46,7 @@ test("simple block with block params", function() { [ "prepareObject", [ 0 ] ], [ "prepareArray", [ 0 ] ], [ "pushLiteral", [ "foo" ] ], - [ "printBlockHook", [ 0, 0, null ] ], + [ "printBlockHook", [ 0, null ] ], [ "popParent", [] ] ]); }); @@ -57,7 +57,7 @@ test("element with a sole mustache child", function() { [ "consumeParent", [ 0 ] ], [ "createMorph", [ 0, [ 0 ], 0, 0, true ] ], [ "pushLiteral", [ "foo" ] ], - [ "printContentHook", [ 0 ] ], + [ "printContentHook", [ ] ], [ "popParent", [] ] ]); }); @@ -68,7 +68,7 @@ test("element with a mustache between two text nodes", function() { [ "consumeParent", [ 0 ] ], [ "createMorph", [ 0, [ 0 ], 1, 1, true ] ], [ "pushLiteral", [ "foo" ] ], - [ "printContentHook", [ 0 ] ], + [ "printContentHook", [ ] ], [ "popParent", [] ] ]); }); @@ -80,7 +80,7 @@ test("mustache two elements deep", function() { [ "consumeParent", [ 0 ] ], [ "createMorph", [ 0, [ 0, 0 ], 0, 0, true ] ], [ "pushLiteral", [ "foo" ] ], - [ "printContentHook", [ 0 ] ], + [ "printContentHook", [ ] ], [ "popParent", [] ], [ "popParent", [] ] ]); @@ -92,12 +92,12 @@ test("two sibling elements with mustaches", function() { [ "consumeParent", [ 0 ] ], [ "createMorph", [ 0, [ 0 ], 0, 0, true ] ], [ "pushLiteral", [ "foo" ] ], - [ "printContentHook", [ 0 ] ], + [ "printContentHook", [ ] ], [ "popParent", [] ], [ "consumeParent", [ 1 ] ], [ "createMorph", [ 1, [ 1 ], 0, 0, true ] ], [ "pushLiteral", [ "bar" ] ], - [ "printContentHook", [ 1 ] ], + [ "printContentHook", [ ] ], [ "popParent", [] ] ]); }); @@ -109,10 +109,10 @@ test("mustaches at the root", function() { [ "createMorph", [ 1, [ ], 2, 2, true ] ], [ "openBoundary", [ ] ], [ "pushLiteral", [ "foo" ] ], - [ "printContentHook", [ 0 ] ], + [ "printContentHook", [ ] ], [ "closeBoundary", [ ] ], [ "pushLiteral", [ "bar" ] ], - [ "printContentHook", [ 1 ] ] + [ "printContentHook", [ ] ] ]); }); @@ -126,13 +126,13 @@ test("back to back mustaches should have a text node inserted between them", fun [ "createMorph", [ 2, [0], 2, 2, true ] ], [ "createMorph", [ 3, [0], 4, 4, true] ], [ "pushLiteral", [ "foo" ] ], - [ "printContentHook", [ 0 ] ], + [ "printContentHook", [ ] ], [ "pushLiteral", [ "bar" ] ], - [ "printContentHook", [ 1 ] ], + [ "printContentHook", [ ] ], [ "pushLiteral", [ "baz" ] ], - [ "printContentHook", [ 2 ] ], + [ "printContentHook", [ ] ], [ "pushLiteral", [ "qux" ] ], - [ "printContentHook", [ 3 ] ], + [ "printContentHook", [ ] ], [ "popParent", [] ] ]); }); @@ -149,7 +149,7 @@ test("helper usage", function() { [ "pushLiteral", [ "bar" ] ], [ "prepareArray", [ 4 ] ], [ "pushLiteral", [ "foo" ] ], - [ "printInlineHook", [ 0 ] ], + [ "printInlineHook", [ ] ], [ "popParent", [] ] ]); }); @@ -162,7 +162,8 @@ test("node mustache", function() { [ "prepareArray", [ 0 ] ], [ "pushLiteral", [ "foo" ] ], [ "shareElement", [ 0 ] ], - [ "printElementHook", [ 0 ] ], + [ "createElementMorph", [ 0, 0 ] ], + [ "printElementHook", [ ] ], [ "popParent", [] ] ]); }); @@ -176,7 +177,8 @@ test("node helper", function() { [ "prepareArray", [ 1 ] ], [ "pushLiteral", [ "foo" ] ], [ "shareElement", [ 0 ] ], - [ "printElementHook", [ 0 ] ], + [ "createElementMorph", [ 0, 0 ] ], + [ "printElementHook", [ ] ], [ "popParent", [] ] ]); }); @@ -189,11 +191,11 @@ test("attribute mustache", function() { [ "pushGetHook", [ "foo" ] ], [ "pushLiteral", [ "before " ] ], [ "prepareArray", [ 3 ] ], - [ "pushConcatHook", [ ] ], + [ "pushConcatHook", [ 0 ] ], [ "pushLiteral", [ "class" ] ], [ "shareElement", [ 0 ] ], [ "createAttrMorph", [ 0, 0, "class", true, null ] ], - [ "printAttributeHook", [ 0, 0 ] ], + [ "printAttributeHook", [ ] ], [ "popParent", [] ] ]); }); @@ -204,11 +206,11 @@ test("quoted attribute mustache", function() { [ "consumeParent", [ 0 ] ], [ "pushGetHook", [ "foo" ] ], [ "prepareArray", [ 1 ] ], - [ "pushConcatHook", [ ] ], + [ "pushConcatHook", [ 0 ] ], [ "pushLiteral", [ "class" ] ], [ "shareElement", [ 0 ] ], [ "createAttrMorph", [ 0, 0, "class", true, null ] ], - [ "printAttributeHook", [ 0, 0 ] ], + [ "printAttributeHook", [ ] ], [ "popParent", [] ] ]); }); @@ -221,7 +223,7 @@ test("safe bare attribute mustache", function() { [ "pushLiteral", [ "class" ] ], [ "shareElement", [ 0 ] ], [ "createAttrMorph", [ 0, 0, "class", true, null ] ], - [ "printAttributeHook", [ 0, 0 ] ], + [ "printAttributeHook", [ ] ], [ "popParent", [] ] ]); }); @@ -234,7 +236,7 @@ test("unsafe bare attribute mustache", function() { [ "pushLiteral", [ "class" ] ], [ "shareElement", [ 0 ] ], [ "createAttrMorph", [ 0, 0, "class", false, null ] ], - [ "printAttributeHook", [ 0, 0 ] ], + [ "printAttributeHook", [ ] ], [ "popParent", [] ] ]); }); @@ -251,11 +253,11 @@ test("attribute helper", function() { [ "pushSexprHook", [ ] ], [ "pushLiteral", [ "before " ] ], [ "prepareArray", [ 3 ] ], - [ "pushConcatHook", [ ] ], + [ "pushConcatHook", [ 0 ] ], [ "pushLiteral", [ "class" ] ], [ "shareElement", [ 0 ] ], [ "createAttrMorph", [ 0, 0, "class", true, null ] ], - [ "printAttributeHook", [ 0, 0 ] ], + [ "printAttributeHook", [ ] ], [ "popParent", [] ] ]); }); @@ -273,26 +275,26 @@ test("attribute helpers", function() { [ "pushSexprHook", [ ] ], [ "pushLiteral", [ "before " ] ], [ "prepareArray", [ 3 ] ], - [ "pushConcatHook", [ ] ], + [ "pushConcatHook", [ 0 ] ], [ "pushLiteral", [ "class" ] ], [ "createAttrMorph", [ 0, 0, "class", true, null ] ], - [ "printAttributeHook", [ 0, 0 ] ], + [ "printAttributeHook", [ ] ], [ "pushGetHook", [ 'bare' ] ], [ "pushLiteral", [ 'id' ] ], [ "createAttrMorph", [ 1, 0, 'id', true, null ] ], - [ "printAttributeHook", [ 1, 0 ] ], + [ "printAttributeHook", [ ] ], [ "popParent", [] ], - [ "createMorph", [ 0, [], 1, 1, true ] ], + [ "createMorph", [ 2, [], 1, 1, true ] ], [ "pushLiteral", [ 'morphThing' ] ], - [ "printContentHook", [ 0 ] ], + [ "printContentHook", [ ] ], [ "consumeParent", [ 2 ] ], [ "pushGetHook", [ 'ohMy' ] ], [ "prepareArray", [ 1 ] ], - [ "pushConcatHook", [] ], + [ "pushConcatHook", [ 3 ] ], [ "pushLiteral", [ 'class' ] ], [ "shareElement", [ 1 ] ], - [ "createAttrMorph", [ 2, 1, 'class', true, null ] ], - [ "printAttributeHook", [ 2, 1 ] ], + [ "createAttrMorph", [ 3, 1, 'class', true, null ] ], + [ "printAttributeHook", [ ] ], [ "popParent", [] ] ]); }); diff --git a/packages/htmlbars-compiler/tests/template-compiler-test.js b/packages/htmlbars-compiler/tests/template-compiler-test.js index 4bcf12d4..bc793fa8 100644 --- a/packages/htmlbars-compiler/tests/template-compiler-test.js +++ b/packages/htmlbars-compiler/tests/template-compiler-test.js @@ -1,15 +1,8 @@ import TemplateCompiler from "../htmlbars-compiler/template-compiler"; import { preprocess } from "../htmlbars-syntax/parser"; -import { equalHTML } from "../htmlbars-test-helpers"; -import defaultHooks from "../htmlbars-runtime/hooks"; -import defaultHelpers from "../htmlbars-runtime/helpers"; -import { merge } from "../htmlbars-util/object-utils"; -import DOMHelper from "../dom-helper"; QUnit.module("TemplateCompiler"); -var dom, hooks, helpers; - function countNamespaceChanges(template) { var ast = preprocess(template); var compiler = new TemplateCompiler(); @@ -18,38 +11,6 @@ function countNamespaceChanges(template) { return matches ? matches.length : 0; } -test("it works", function testFunction() { - /* jshint evil: true */ - var ast = preprocess('
    {{#if working}}Hello {{firstName}} {{lastName}}!{{/if}}
    '); - 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}} + + {{/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 --}} +

    {{props.title}}

    + {{yield}} + + {{!-- blog template --}} + {{#post title="Hello world"}} +

    by {{byline}}

    +
    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