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/packages/dom-helper/lib/main.js b/packages/dom-helper/lib/main.js index 3af2d6ac..3f31d4d0 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 "../morph-range"; 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 @@ -346,6 +355,10 @@ prototype.createAttrMorph = function(element, attrName, namespace){ return new AttrMorph(element, attrName, this, namespace); }; +prototype.createElementMorph = function(element, namespace){ + return new ElementMorph(element, this, namespace); +}; + prototype.createUnsafeAttrMorph = function(element, attrName, namespace){ var morph = this.createAttrMorph(element, attrName, namespace); morph.escaped = false; @@ -357,7 +370,7 @@ prototype.createMorph = function(parent, start, end, contextualElement){ 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); diff --git a/packages/htmlbars-compiler/lib/compiler.js b/packages/htmlbars-compiler/lib/compiler.js index 6b57c9e7..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,31 +67,5 @@ export function template(templateSpec) { * @return {Template} A function for rendering the template */ export function compile(string, options) { - return template(compileSpec(string, options)); -} - -/* - * Compile a string into a template spec string. The template spec is a string - * representation of a template. Usually, you would use compileSpec for - * pre-compilation of a template on the server. - * - * Example usage: - * - * var templateSpec = compileSpec("Howdy {{name}}"); - * // This next step is basically what plain compile does - * var template = new Function("return " + templateSpec)(); - * - * @method compileSpec - * @param {String} string An htmlbars template string - * @return {Function} A template spec string - */ -export function compileSpec(string, options) { - var ast = preprocess(string, options); - var compiler = new TemplateCompiler(options); - var program = compiler.compile(ast); - return program; -} - -export function template(program) { - return new Function("return " + program)(); + 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 650a0a29..e5677b2a 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.elementHelper = function(sexpr) { @@ -222,11 +226,11 @@ HydrationOpcodeCompiler.prototype.elementHelper = function(sexpr) { // 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 bcd0c55f..1f9c9aed 100644 --- a/packages/htmlbars-compiler/lib/template-compiler.js +++ b/packages/htmlbars-compiler/lib/template-compiler.js @@ -18,11 +18,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 = hydrationPrograms.statements.map(function(s) { + return indent+' '+JSON.stringify(s); + }).join(",\n"); + + var locals = JSON.stringify(hydrationPrograms.locals); + + var templates = this.childTemplates.map(function(_, index) { + return 'child' + index; + }).join(', '); + var template = '(function() {\n' + this.getChildTemplateVars(indent + ' ') + @@ -96,30 +136,12 @@ TemplateCompiler.prototype.endProgram = function(program, programDepth) { indent+' blockParams: ' + 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..c96c2460 --- /dev/null +++ b/packages/htmlbars-compiler/tests/dirtying-test.js @@ -0,0 +1,231 @@ +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); + helpers = {}; + partials = {}; + + env = { + dom: new DOMHelper(), + hooks: hooks, + helpers: helpers, + partials: partials, + useFragmentCache: true + }; +} + +QUnit.module("HTML-based compiler (dirtying)", { + beforeEach: commonSetup +}); + +test("a simple implementation of a dirtying rerender", function() { + var makeNodeDirty; + + // This represents the internals of a higher-level helper API + registerHelper('if', function(params, hash, options) { + var renderNode = options.renderNode; + + makeNodeDirty = function() { + renderNode.isDirty = true; + }; + + var state = renderNode.state; + var value = params[0]; + var normalized = !!value; + + // If the node is unstable + if (state.condition !== normalized) { + state.condition = normalized; + + if (normalized) { + return options.template.yield(); + } else { + return options.inverse.yield(); + } + } + }); + + var object = { condition: true, value: 'hello world' }; + var template = compile('
{{#if condition}}

{{value}}

{{else}}

Nothing

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

hello world

'); + + makeNodeDirty(); + result.revalidate(); + + equalTokens(result.fragment, '

hello world

'); + + // Even though the #if was stable, a dirty child node is updated + object.value = 'goodbye world'; + var textRenderNode = result.root.childNodes[0].childNodes[0]; + textRenderNode.isDirty = true; + result.revalidate(); + equalTokens(result.fragment, '

goodbye world

'); + + // Should not update since render node is not marked as dirty + object.condition = false; + result.revalidate(); + equalTokens(result.fragment, '

goodbye world

'); + + makeNodeDirty(); + result.revalidate(); + equalTokens(result.fragment, '

Nothing

'); +}); + +test("block helpers whose template has a morph at the edge", function() { + registerHelper('id', function(params, hash, options) { + return options.template.yield(); + }); + + var template = compile("{{#id}}{{value}}{{/id}}"); + var object = { value: "hello world" }; + var result = template.render(object, env); + + equalTokens(result.fragment, 'hello world'); + var firstNode = result.root.firstNode; + equal(firstNode.nodeType, 3, "first node of the parent template"); + equal(firstNode.textContent, "", "its content should be empty"); + + var secondNode = firstNode.nextSibling; + equal(secondNode.nodeType, 3, "first node of the helper template should be a text node"); + equal(secondNode.textContent, "", "its content should be empty"); + + var textContent = secondNode.nextSibling; + equal(textContent.nodeType, 3, "second node of the helper should be a text node"); + equal(textContent.textContent, "hello world", "its content should be hello world"); + + var fourthNode = textContent.nextSibling; + equal(fourthNode.nodeType, 3, "last node of the helper should be a text node"); + equal(fourthNode.textContent, "", "its content should be empty"); + + var lastNode = fourthNode.nextSibling; + equal(lastNode.nodeType, 3, "last node of the parent template should be a text node"); + equal(lastNode.textContent, "", "its content should be empty"); + + strictEqual(lastNode.nextSibling, null, "there should only be five nodes"); +}); + +test("clean content doesn't get blown away", function() { + var template = compile("
{{value}}
"); + var object = { value: "hello" }; + var result = template.render(object, env); + + var textNode = result.fragment.firstChild.firstChild; + equal(textNode.textContent, "hello"); + + object.value = "goodbye"; + result.revalidate(); // without setting the node to dirty + + equalTokens(result.fragment, '
hello
'); + + var textRenderNode = result.root.childNodes[0]; + + textRenderNode.setContent = function() { + ok(false, "Should not get called"); + }; + + object.value = "hello"; + result.dirty(); + result.revalidate(); +}); + +test("helper calls follow the normal dirtying rules", function() { + registerHelper('capitalize', function(params) { + return params[0].toUpperCase(); + }); + + var template = compile("
{{capitalize value}}
"); + var object = { value: "hello" }; + var result = template.render(object, env); + + var textNode = result.fragment.firstChild.firstChild; + equal(textNode.textContent, "HELLO"); + + object.value = "goodbye"; + result.revalidate(); // without setting the node to dirty + + equalTokens(result.fragment, '
HELLO
'); + + var textRenderNode = result.root.childNodes[0]; + + result.dirty(); + result.revalidate(); + + equalTokens(result.fragment, '
GOODBYE
'); + + textRenderNode.setContent = function() { + ok(false, "Should not get called"); + }; + + // Checks normalized value, not raw value + object.value = "GoOdByE"; + result.dirty(); + result.revalidate(); +}); + +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
"); + + object.value = "universe"; + result.revalidate(); // without setting the node to dirty + + equalTokens(result.fragment, "
hello
"); + + var attrRenderNode = result.root.childNodes[0]; + + result.dirty(); + result.revalidate(); + + equalTokens(result.fragment, "
hello
"); + + attrRenderNode.setContent = function() { + ok(false, "Should not get called"); + }; + + object.value = "universe"; + result.dirty(); + result.revalidate(); +}); + +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.dirty(); + result.revalidate(); + + equalTokens(result.fragment, "
hello
"); + + attrRenderNode.setContent = function() { + ok(false, "Should not get called"); + }; + + object.value = "universe"; + result.dirty(); + result.revalidate(); +}); diff --git a/packages/htmlbars-compiler/tests/html-compiler-test.js b/packages/htmlbars-compiler/tests/html-compiler-test.js index a104be3c..541440be 100644 --- a/packages/htmlbars-compiler/tests/html-compiler-test.js +++ b/packages/htmlbars-compiler/tests/html-compiler-test.js @@ -1,11 +1,9 @@ import { compile } from "../htmlbars-compiler/compiler"; import { forEach } from "../htmlbars-util/array-utils"; -import { tokenize } from "../simple-html-tokenizer"; import defaultHooks from "../htmlbars-runtime/hooks"; -import defaultHelpers from "../htmlbars-runtime/helpers"; import { merge } from "../htmlbars-util/object-utils"; import DOMHelper from "../dom-helper"; -import { createObject, normalizeInnerHTML, getTextContent } from "../htmlbars-test-helpers"; +import { normalizeInnerHTML, getTextContent, equalTokens } from "../htmlbars-test-helpers"; var xhtmlNamespace = "http://www.w3.org/1999/xhtml", svgNamespace = "http://www.w3.org/2000/svg"; @@ -26,13 +24,6 @@ var innerHTMLHandlesNewlines = (function() { return div.innerHTML.length === 8; })(); -// IE8 removes comments and does other unspeakable things with innerHTML -var ie8GenerateTokensNeeded = (function() { - var div = document.createElement("div"); - div.innerHTML = ""; - return div.innerHTML === ""; -})(); - function registerHelper(name, callback) { helpers[name] = callback; } @@ -43,55 +34,15 @@ function registerPartial(name, html) { function compilesTo(html, expected, context) { var template = compile(html); - var fragment = template.render(context, env, document.body); + var fragment = template.render(context, env, { contextualElement: document.body }).fragment; equalTokens(fragment, expected === undefined ? html : expected); return fragment; } -function generateTokens(fragmentOrHtml) { - var div = document.createElement("div"); - if (typeof fragmentOrHtml === 'string') { - div.innerHTML = fragmentOrHtml; - } else { - div.appendChild(fragmentOrHtml.cloneNode(true)); - } - if (ie8GenerateTokensNeeded) { - // IE8 drops comments and does other unspeakable things on `innerHTML`. - // So in that case we do it to both the expected and actual so that they match. - var div2 = document.createElement("div"); - div2.innerHTML = div.innerHTML; - div.innerHTML = div2.innerHTML; - } - return tokenize(div.innerHTML); -} - -function equalTokens(fragment, html) { - var fragTokens = generateTokens(fragment); - var htmlTokens = generateTokens(html); - - function normalizeTokens(token) { - if (token.type === 'StartTag') { - token.attributes = token.attributes.sort(function(a,b){ - if (a.name > b.name) { - return 1; - } - if (a.name < b.name) { - return -1; - } - return 0; - }); - } - } - - forEach(fragTokens, normalizeTokens); - forEach(htmlTokens, normalizeTokens); - - deepEqual(fragTokens, htmlTokens); -} function commonSetup() { hooks = merge({}, defaultHooks); - helpers = merge({}, defaultHelpers); + helpers = {}; partials = {}; env = { @@ -109,56 +60,69 @@ QUnit.module("HTML-based compiler (output)", { test("Simple content produces a document fragment", function() { var template = compile("content"); - var fragment = template.render({}, env); + var fragment = template.render({}, env).fragment; equalTokens(fragment, "content"); }); test("Simple elements are created", function() { var template = compile("

hello!

content
"); - var fragment = template.render({}, env); + var fragment = template.render({}, env).fragment; equalTokens(fragment, "

hello!

content
"); }); +test("Simple elements can be re-rendered", function() { + var template = compile("

hello!

content
"); + var result = template.render({}, env); + var fragment = result.fragment; + + var oldFirstChild = fragment.firstChild; + + result.revalidate(); + + strictEqual(fragment.firstChild, oldFirstChild); + equalTokens(fragment, "

hello!

content
"); +}); + test("Simple elements can have attributes", function() { var template = compile("
content
"); - var fragment = template.render({}, env); + var fragment = template.render({}, env).fragment; equalTokens(fragment, '
content
'); }); test("Simple elements can have an empty attribute", function() { var template = compile("
content
"); - var fragment = template.render({}, env); + var fragment = template.render({}, env).fragment; equalTokens(fragment, '
content
'); }); test("presence of `disabled` attribute without value marks as disabled", function() { var template = compile(''); - var inputNode = template.render({}, env).firstChild; + var inputNode = template.render({}, env).fragment.firstChild; ok(inputNode.disabled, 'disabled without value set as property is true'); }); test("Null quoted attribute value calls toString on the value", function() { var template = compile(''); - var inputNode = template.render({isDisabled: null}, env).firstChild; + var inputNode = template.render({isDisabled: null}, env).fragment.firstChild; ok(inputNode.disabled, 'string of "null" set as property is true'); }); test("Null unquoted attribute value removes that attribute", function() { var template = compile(''); - var inputNode = template.render({isDisabled: null}, env).firstChild; + var inputNode = template.render({isDisabled: null}, env).fragment.firstChild; equalTokens(inputNode, ''); }); test("unquoted attribute string is just that", function() { var template = compile(''); - var inputNode = template.render({}, env).firstChild; + var inputNode = template.render({}, env).fragment.firstChild; equal(inputNode.tagName, 'INPUT', 'input tag'); equal(inputNode.value, 'funstuff', 'value is set as property'); @@ -166,7 +130,7 @@ test("unquoted attribute string is just that", function() { test("unquoted attribute expression is string", function() { var template = compile(''); - var inputNode = template.render({funstuff: "oh my"}, env).firstChild; + var inputNode = template.render({funstuff: "oh my"}, env).fragment.firstChild; equal(inputNode.tagName, 'INPUT', 'input tag'); equal(inputNode.value, 'oh my', 'string is set to property'); @@ -174,7 +138,7 @@ test("unquoted attribute expression is string", function() { test("unquoted attribute expression works when followed by another attribute", function() { var template = compile('
'); - var divNode = template.render({funstuff: "oh my"}, env).firstChild; + var divNode = template.render({funstuff: "oh my"}, env).fragment.firstChild; equalTokens(divNode, '
'); }); @@ -194,13 +158,13 @@ test("Unquoted attribute value with multiple nodes throws an exception", functio test("Simple elements can have arbitrary attributes", function() { var template = compile("
content
"); - var divNode = template.render({}, env).firstChild; + var divNode = template.render({}, env).fragment.firstChild; equalTokens(divNode, '
content
'); }); test("checked attribute and checked property are present after clone and hydrate", function() { var template = compile(""); - var inputNode = template.render({}, env).firstChild; + var inputNode = template.render({}, env).fragment.firstChild; equal(inputNode.tagName, 'INPUT', 'input tag'); equal(inputNode.checked, true, 'input tag is checked'); }); @@ -209,7 +173,7 @@ test("checked attribute and checked property are present after clone and hydrate function shouldBeVoid(tagName) { var html = "<" + tagName + " data-foo='bar'>

hello

"; var template = compile(html); - var fragment = template.render({}, env); + var fragment = template.render({}, env).fragment; var div = document.createElement("div"); @@ -234,7 +198,7 @@ test("Void elements are self-closing", function() { test("The compiler can handle nesting", function() { var html = '

hi!

 More content'; var template = compile(html); - var fragment = template.render({}, env); + var fragment = template.render({}, env).fragment; equalTokens(fragment, html); }); @@ -309,7 +273,7 @@ test("The compiler can handle top-level unescaped HTML", function() { test("The compiler can handle top-level unescaped tr", function() { var template = compile('{{{html}}}'); var context = { html: 'Yo' }; - var fragment = template.render(context, env, document.createElement('table')); + var fragment = template.render(context, env, { contextualElement: document.createElement('table') }).fragment; equal( fragment.firstChild.nextSibling.tagName, 'TR', @@ -319,7 +283,7 @@ test("The compiler can handle top-level unescaped tr", function() { test("The compiler can handle top-level unescaped td inside tr contextualElement", function() { var template = compile('{{{html}}}'); var context = { html: 'Yo' }; - var fragment = template.render(context, env, document.createElement('tr')); + var fragment = template.render(context, env, { contextualElement: document.createElement('tr') }).fragment; equal( fragment.firstChild.nextSibling.tagName, 'TD', @@ -327,13 +291,13 @@ test("The compiler can handle top-level unescaped td inside tr contextualElement }); test("The compiler can handle unescaped tr in top of content", function() { - registerHelper('test', function(params, hash, options, env) { - return options.template.render(this, env, options.morph.contextualElement); + registerHelper('test', function() { + return this.yield(); }); var template = compile('{{#test}}{{{html}}}{{/test}}'); var context = { html: 'Yo' }; - var fragment = template.render(context, env, document.createElement('table')); + var fragment = template.render(context, env, { contextualElement: document.createElement('table') }).fragment; equal( fragment.firstChild.nextSibling.nextSibling.tagName, 'TR', @@ -341,13 +305,13 @@ test("The compiler can handle unescaped tr in top of content", function() { }); test("The compiler can handle unescaped tr inside fragment table", function() { - registerHelper('test', function(params, hash, options, env) { - return options.template.render(this, env, options.morph.contextualElement); + registerHelper('test', function() { + return this.yield(); }); var template = compile('{{#test}}{{{html}}}{{/test}}
'); var context = { html: 'Yo' }; - var fragment = template.render(context, env, document.createElement('div')); + var fragment = template.render(context, env, { contextualElement: document.createElement('div') }).fragment; var tableNode = fragment.firstChild; equal( @@ -363,6 +327,23 @@ test("The compiler can handle simple helpers", function() { compilesTo('
{{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] + "!"; @@ -390,9 +371,9 @@ test("The compiler passes along the hash arguments", function() { test("Simple data binding using text nodes", function() { var callback; - hooks.content = function(env, morph, context, path) { + hooks.content = function(morph, env, scope, path) { callback = function() { - morph.setContent(context[path]); + morph.setContent(scope.self[path]); }; callback(); }; @@ -414,10 +395,10 @@ test("Simple data binding using text nodes", function() { test("Simple data binding on fragments", function() { var callback; - hooks.content = function(env, morph, context, path) { + hooks.content = function(morph, env, scope, path) { morph.parseTextAsHTML = true; callback = function() { - morph.setContent(context[path]); + morph.setContent(scope.self[path]); }; callback(); }; @@ -436,10 +417,41 @@ test("Simple data binding on fragments", function() { equalTokens(fragment, '

brown cow

to the world
'); }); +test("Simple data binding on fragments - re-rendering", function() { + hooks.content = function(morph, env, scope, path) { + morph.parseTextAsHTML = true; + morph.setContent(scope.self[path]); + }; + + var object = { title: '

hello

to the' }; + var template = compile('
{{title}} world
'); + var result = template.render(object, env); + + var fragment = result.fragment; + + equalTokens(fragment, '

hello

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

goodbye

to the'; + + var oldFirstChild = fragment.firstChild; + + result.revalidate(object); + + strictEqual(fragment.firstChild, oldFirstChild, "Static nodes in the fragment should have stable identity"); + equalTokens(fragment, '

goodbye

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

brown cow

to the'; + + result.revalidate(object); + + strictEqual(fragment.firstChild, oldFirstChild, "Static nodes in the fragment should have stable identity"); + equalTokens(fragment, '

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'); @@ -448,7 +460,7 @@ test("second render respects whitespace", function () { test("morph receives escaping information", function() { expect(3); - hooks.content = function(env, morph, context, path) { + hooks.content = function(morph, env, scope, path) { if (path === 'escaped') { equal(morph.parseTextAsHTML, false); } else if (path === 'unescaped') { @@ -468,16 +480,16 @@ test("Morphs are escaped correctly", function() { expect(10); registerHelper('testing-unescaped', function(params, hash, options) { - equal(options.morph.parseTextAsHTML, true); + equal(options.renderNode.parseTextAsHTML, true); return params[0]; }); - registerHelper('testing-escaped', function(params, hash, options, env) { - equal(options.morph.parseTextAsHTML, false); + registerHelper('testing-escaped', function(params, hash, options) { + equal(options.renderNode.parseTextAsHTML, false); if (options.template) { - return options.template.render({}, env, options.morph.contextualElement); + return options.template.render({}); } return params[0]; @@ -677,42 +689,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,36 +775,36 @@ 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; + var xAppendComponent = compile('{{yield}}{{text}}'); + + registerHelper('x-append', function(params, hash, options) { + var rootNode = options.renderNode; + options.renderNode = null; + var result = this.yield(); + options.renderNode = rootNode; + xAppendComponent.render({ yield: result.fragment, text: hash.text }, env, options); }); var object = { bar: 'e', baz: 'c' }; compilesTo('ab{{baz}}f','abcdef', object); @@ -833,30 +842,35 @@ 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
'); }); +function yieldTemplate(parentTemplate, options, callback, bind) { + var node = options.renderNode; + options.renderNode = null; + var child = callback.call(bind); + options.renderNode = node; + + compile(parentTemplate).render({ yield: child.fragment }, env, options); +} + 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(params, hash, options) { + yieldTemplate("A({{yield}})", options, function() { + return this.yield(['W', 'X1']); + }, this); }); - 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(params, hash, options) { + yieldTemplate("B({{yield}})", options, function() { + return this.yield(['X2', 'Y']); + }, this); }); - 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(params, hash, options) { + yieldTemplate("C({{yield}})", options, function() { + return this.yield(['Z']); + }, this); }); 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 +880,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.blockParams, 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 + ')'; + registerHelper('x-bar', function(params, hash, options) { + yieldTemplate("BAR({{yield}})", options, function() { + return this.yield(['Xerxes', 'York', 'Zed']); + }, this); }); compilesTo('{{zee}},{{y}},{{x}}', 'BAR(Zed,York,Xerxes)', {}); }); @@ -898,8 +911,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 +921,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 +939,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.blockParams, 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 +1079,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 +1087,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 +1095,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 +1111,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 +1121,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 +1133,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 +1143,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 +1156,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 +1166,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 +1174,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 +1183,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 +1191,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 +1200,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 +1210,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 +1223,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 +1249,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 +1264,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 +1279,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 866f8b79..295c7870 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..6c3bbf46 --- /dev/null +++ b/packages/htmlbars-runtime/lib/expression-visitor.js @@ -0,0 +1,93 @@ +export default { + accept: function(node, morph, env, scope, template) { + // Primitive literals are unambiguously non-array representations of + // themselves. + if (typeof node !== 'object') { + return node; + } + + var type = node[0]; + return this[type](node, morph, env, scope, template); + }, + + acceptArray: function(nodes, morph, env, scope, template) { + return nodes.map(function(node) { + return this.accept(node, morph, env, scope, template); + }, this); + }, + + acceptObject: function(pairs, morph, env, scope, template) { + var object = {}; + + for (var i=0, l=pairs.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, + blockParams: template.blockParams, + render: function(self, env, options, blockArguments) { + var scope = env.hooks.createScope(null, template.blockParams); + scope.self = self; + return render(template, env, scope, options, blockArguments); + } + }; +} + +export function wrapForHelper(template, env, originalScope, options) { + if (template === null) { return null; } + + return { + isHTMLBars: true, + blockParams: template.blockParams, + + yield: function(blockArguments) { + var scope = originalScope; + + if (blockArguments !== undefined) { + scope = env.hooks.createScope(originalScope, template.blockParams); + } + + return render(template, env, scope, options, blockArguments); + }, + + render: function(newSelf, blockArguments) { + var scope = originalScope; + if (newSelf !== originalScope.self || blockArguments !== undefined) { + scope = env.hooks.createScope(originalScope, template.blockParams); + scope.self = newSelf; + } + + return render(template, env, scope, options, blockArguments); + } + }; +} + +function optionsFor(morph, env, scope, template, inverse) { var options = { - morph: morph, - template: template, - inverse: inverse + renderNode: morph, + env: env, + template: null, + inverse: null }; - var helper = lookupHelper(env, context, path); - var value = helper.call(context, params, hash, options, env); + options.template = wrapForHelper(template, env, scope, options); + options.inverse = wrapForHelper(inverse, env, scope, options); - morph.setContent(value); + return options; } -export function inline(env, morph, context, path, params, hash) { - var helper = lookupHelper(env, context, path); - var value = helper.call(context, params, hash, { morph: morph }, env); +function thisFor(options) { + return { yield: options.template.yield }; +} + +/** + Host Hook: createScope + + @param {Scope?} parentScope + @param {Array} localVariables + @return Scope + + Corresponds to entering a new HTMLBars block. + + This hook is invoked when a block is entered with + a new `self` or additional local variables. + + When invoked for a top-level template, the + `parentScope` is `null`, and this hook should return + a fresh Scope. + + When invoked for a child template, the `parentScope` + is the scope for the parent environment, and + `localVariables` is an array of names of new variable + bindings that should be created for this scope. + + Note that the `Scope` is an opaque value that is + passed to other host hooks. For example, the `get` + hook uses the scope to retrieve a value for a given + scope and variable name. +*/ +export function createScope(parentScope, localVariables) { + var scope; + + if (parentScope) { + scope = createObject(parentScope); + scope.locals = createObject(parentScope.locals); + } else { + scope = { self: null, locals: {} }; + } - morph.setContent(value); + for (var i=0, l=localVariables.length; i + ``` + + This hook is responsible for invoking a helper that + modifies an element. + + Its purpose is largely legacy support for awkward + idioms that became common when using the string-based + Handlebars engine. + + Most of the uses of the `element` hook are expected + to be superseded by component syntax and the + `attribute` hook. +*/ +export function element(morph, env, scope, path, params, hash) { + if (morph.isDirty) { + var helper = lookupHelper(env, scope, path); + if (helper) { + helper(params, hash, { element: morph.element }); + } + + morph.isDirty = false; } } -export function attribute(env, attrMorph, domElement, name, value) { - attrMorph.setContent(value); +/** + Host hook: attribute + + @param {RenderNode} renderNode + @param {Environment} env + @param {String} name + @param {any} value + + Corresponds to: + + ```hbs +
+ ``` + + This hook is responsible for updating a render node + that represents an element's attribute with a value. + + It receives the name of the attribute as well as an + already-resolved value, and should update the render + node with the value if appropriate. +*/ +export function attribute(morph, env, name, value) { + if (morph.isDirty) { + var state = morph.state; + + if (state.lastValue !== value) { + morph.setContent(value); + } + + state.lastValue = value; + morph.isDirty = false; + } } -export function subexpr(env, context, helperName, params, hash) { - var helper = lookupHelper(env, context, helperName); +export function subexpr(morph, env, scope, helperName, params, hash) { + if (!morph.isDirty) { return; } + + var helper = lookupHelper(env, scope, helperName); if (helper) { - return helper.call(context, params, hash, {}, env); + return helper(params, hash, {}); } else { - return get(env, context, helperName); + return env.hooks.get(morph, env, scope, helperName); } } -export function get(env, context, path) { +/** + Host Hook: get + + @param {RenderNode} renderNode + @param {Environment} env + @param {Scope} scope + @param {String} path + + Corresponds to: + + ```hbs + {{foo.bar}} + ^ + + {{helper foo.bar key=value}} + ^ ^ + ``` + + This hook is the "leaf" hook of the system. It is used to + resolve a path relative to the current scope. + + NOTE: This should be refactored into three hooks: splitting + the path into parts, looking up the first part on the scope, + and resolving the remainder a piece at a time. It would also + be useful to have a "classification" hook that handles + classifying a name as either a helper or value. +*/ +export function get(morph, env, scope, path) { + if (!morph.isDirty) { return; } + if (path === '') { - return context; + return scope.self; } var keys = path.split('.'); - var value = context; + var value = (keys[0] in scope.locals) ? scope.locals : scope.self; + for (var i = 0; i < keys.length; i++) { if (value) { value = value[keys[i]]; @@ -68,29 +508,27 @@ export function get(env, context, path) { return value; } -export function set(env, context, name, value) { - context[name] = value; +export function bindLocal(env, scope, name, value) { + scope.locals[name] = value; } -export function component(env, morph, context, tagName, attrs, template) { - var helper = lookupHelper(env, context, tagName); - - var value; - if (helper) { - var options = { - morph: morph, - template: template - }; +export function component(morph, env, scope, tagName, attrs, template) { + if (morph.isDirty) { + var helper = lookupHelper(env, scope, tagName); + if (helper) { + var options = optionsFor(morph, env, scope, template, null); + helper.call(thisFor(options), [], attrs, options); + } else { + componentFallback(morph, env, scope, tagName, attrs, template); + } - value = helper.call(context, [], attrs, options, env); - } else { - value = componentFallback(env, morph, context, tagName, attrs, template); + morph.isDirty = false; } - - morph.setContent(value); } -export function concat(env, params) { +export function concat(morph, env, params) { + if (!morph.isDirty) { return; } + var value = ""; for (var i = 0, l = params.length; i < l; i++) { value += params[i]; @@ -98,28 +536,43 @@ export function concat(env, params) { return value; } -function componentFallback(env, morph, context, tagName, attrs, template) { +function componentFallback(morph, env, scope, tagName, attrs, template) { var element = env.dom.createElement(tagName); for (var name in attrs) { element.setAttribute(name, attrs[name]); } - element.appendChild(template.render(context, env, morph.contextualElement)); - return element; + var fragment = render(template, env, scope, {}).fragment; + element.appendChild(fragment); + morph.setNode(element); } -function lookupHelper(env, context, helperName) { +function lookupHelper(env, scope, helperName) { return env.helpers[helperName]; } +// IE8 does not have Object.create, so use a polyfill if needed. +// Polyfill based on Mozilla's (MDN) +export function createObject(obj) { + if (typeof Object.create === 'function') { + return Object.create(obj); + } else { + var Temp = function() {}; + Temp.prototype = obj; + return new Temp(); + } +} + export default { + createScope: createScope, content: content, block: block, inline: inline, + partial: partial, component: component, element: element, attribute: attribute, subexpr: subexpr, concat: concat, get: get, - set: set + bindLocal: bindLocal }; diff --git a/packages/htmlbars-runtime/lib/main.js b/packages/htmlbars-runtime/lib/main.js index d8d144e1..06e18696 100644 --- a/packages/htmlbars-runtime/lib/main.js +++ b/packages/htmlbars-runtime/lib/main.js @@ -1,7 +1,7 @@ import hooks from 'htmlbars-runtime/hooks'; -import helpers from 'htmlbars-runtime/helpers'; +import render from 'htmlbars-runtime/render'; export { hooks, - helpers + render }; diff --git a/packages/htmlbars-runtime/lib/render.js b/packages/htmlbars-runtime/lib/render.js new file mode 100644 index 00000000..dd805053 --- /dev/null +++ b/packages/htmlbars-runtime/lib/render.js @@ -0,0 +1,104 @@ +import { forEach } from "../htmlbars-util/array-utils"; +import ExpressionVisitor from "./expression-visitor"; + +export default function render(template, env, scope, options, blockArguments) { + var dom = env.dom; + var contextualElement; + + if (options && options.renderNode) { + contextualElement = options.renderNode.contextualElement; + } else if (options && options.contextualElement) { + contextualElement = options.contextualElement; + } + + dom.detectNamespace(contextualElement); + + var fragment = getCachedFragment(template, env); + var nodes = template.buildRenderNodes(dom, fragment, contextualElement); + + var rootNode, ownerNode; + + if (options && options.renderNode) { + rootNode = options.renderNode; + ownerNode = rootNode.ownerNode; + } else { + rootNode = dom.createMorph(null, fragment.firstChild, fragment.lastChild, contextualElement); + ownerNode = rootNode; + initializeNode(rootNode, ownerNode); + } + + // TODO Invoke disposal hook recursively on old rootNode.childNodes + + rootNode.childNodes = nodes; + + forEach(nodes, function(node) { + initializeNode(node, ownerNode); + }); + + var statements = template.statements; + var locals = template.locals; + + populateNodes(scope, blockArguments); + + if (options && options.renderNode) { + rootNode.setContent(fragment); + } + + return { + root: rootNode, + fragment: fragment, + dirty: function() { + var nodes = [rootNode]; + + while (nodes.length) { + var node = nodes.pop(); + node.isDirty = true; + nodes.push.apply(nodes, node.childNodes); + } + }, + revalidate: function(newScope, newBlockArguments) { + if (newScope !== undefined) { scope.self = newScope; } + populateNodes(scope, newBlockArguments || blockArguments); + } + }; + + function populateNodes(scope, blockArguments) { + var i, l; + + for (i=0, l=locals.length; i b.name) { + return 1; + } + if (a.name < b.name) { + return -1; + } + return 0; + }); + } + } + + forEach(fragTokens.tokens, normalizeTokens); + forEach(htmlTokens.tokens, normalizeTokens); + + deepEqual(fragTokens.tokens, htmlTokens.tokens, "Expected: " + html + "; Actual: " + fragTokens.html); +} + // detect weird IE8 html strings var ie8InnerHTMLTestElement = document.createElement('div'); ie8InnerHTMLTestElement.setAttribute('id', 'womp'); @@ -103,4 +157,4 @@ export function createObject(obj) { Temp.prototype = obj; return new Temp(); } -} \ No newline at end of file +} diff --git a/packages/morph-attr/lib/main.js b/packages/morph-attr/lib/main.js index 33da989e..9cc5a6b6 100644 --- a/packages/morph-attr/lib/main.js +++ b/packages/morph-attr/lib/main.js @@ -27,6 +27,8 @@ function AttrMorph(element, attrName, domHelper, namespace) { this.element = element; this.domHelper = domHelper; this.namespace = namespace !== undefined ? namespace : getAttrNamespace(attrName); + this.state = {}; + this.isDirty = true; this.escaped = true; var normalizedAttrName = normalizeProperty(this.element, attrName);