Treat null/undefined/false attributes as absent

So, in HTML, the following are equivalent, and both mean that a checkbox is checked, because the `checked` attribute is present:

- `<input type="checkbox" checked>`
- `<input type="checkbox" checked="">`

We can't mess with that.  On the other hand, in Spacebars before this commit, the following would *also* result in the checkbox being checked, regardless of whether `foo` evaluates to null, undefined, false, or the empty string:

- `<input type="checkbox" checked={{foo}}>`
- `<input type="checkbox" checked="{{foo}}">`

With this commit, the checkbox will NOT be checked if `foo` evaluates to null, undefined, or false.

To achieve this:

- In HTMLjs, an attribute is considered absent if its value is "nully" after being fully evaluated (i.e. after expanding functions and components via HTML.evaluateDynamicAttributes / HTML.evaluate).  A nully value is one consisting of null, undefined, an empty array, or an array of those things.  `false` is not nully and renders as "false".  An empty string is not nully, and will "prop open" an attribute that would otherwise collapse into absence.

- Spacebars.mustache converts null, undefined, and false to null.  So if you use {{foo}} anywhere in a template and foo evaluates to "false", that gets to converted to a null in HTMLjs (which is ignored).  (true is rendered as "true".)

- When parsing HTML, an attribute that consists of *no tokens* becomes an empty string in the HTMLjs, which props open the attribute (unlike null or an empty array).  (Since comment tokens are stripped during tokenization, if there are only comments in an attribute value that counts as no tokens.)
This commit is contained in:
David Greenspan
2014-01-27 14:39:35 -08:00
parent 7c58b08f53
commit 599ac16632
10 changed files with 168 additions and 26 deletions

View File

@@ -294,12 +294,17 @@ var convertCharRef = function (token) {
};
// Input is always a dictionary (even if zero attributes) and each
// value in the dictionary is an array of `Chars` and `CharRef`
// tokens. An empty array means the attribute has a value of "".
// value in the dictionary is an array of `Chars`, `CharRef`,
// and maybe `Special` tokens.
//
// Output is null if there are zero attributes, and otherwise a
// dictionary. Each value in the dictionary is a string (possibly
// empty) or an array of non-empty strings and CharRef tags.
// dictionary. Each value in the dictionary is HTMLjs (e.g. a
// string or an array of `Chars`, `CharRef`, and `Special`
// nodes).
//
// An attribute value with no input tokens is represented as "",
// not an empty array, in order to prop open empty attributes
// with no template tags.
var parseAttrs = function (attrs) {
var result = null;
@@ -316,13 +321,7 @@ var parseAttrs = function (attrs) {
} else if (token.t === 'Special') {
outParts.push(HTML.Special(token.v));
} else if (token.t === 'Chars') {
var str = token.v;
var N = outParts.length;
if (N && (typeof outParts[N - 1] === 'string'))
outParts[N - 1] += str;
else
outParts.push(str);
pushOrAppendString(outParts, token.v);
}
}
@@ -331,9 +330,8 @@ var parseAttrs = function (attrs) {
// array, even if there is only one Special.
result[k] = outParts;
} else {
var outValue = (outParts.length === 0 ? '' :
(outParts.length === 1 ? outParts[0] :
outParts));
var outValue = (inValue.length === 0 ? '' :
(outParts.length === 1 ? outParts[0] : outParts));
var properKey = HTML.properCaseAttributeName(k);
result[properKey] = outValue;
}

View File

@@ -15,7 +15,7 @@ HTML.evaluate = function (node, parentComponent) {
if (node == null) {
return node;
} else if (typeof node === 'function') {
return node();
return HTML.evaluate(node(), parentComponent);
} else if (node instanceof Array) {
var result = [];
for (var i = 0; i < node.length; i++)
@@ -24,7 +24,7 @@ HTML.evaluate = function (node, parentComponent) {
} else if (typeof node.instantiate === 'function') {
// component
var instance = node.instantiate(parentComponent || null);
var content = instance.render();
var content = instance.render('STATIC');
return HTML.evaluate(content, instance);
} else if (node instanceof HTML.Tag) {
var newChildren = [];

View File

@@ -105,4 +105,8 @@ Tinytest.add("htmljs - attributes", function (test) {
test.equal(HTML.evaluateDynamicAttributes({x: function () { return 'abc'; },
$dynamic: [{ x: function () { return 'def'; }}]}),
{ x: 'def' });
});
Tinytest.add("htmljs - details", function (test) {
test.equal(HTML.toHTML(false), "false");
});

View File

@@ -224,4 +224,13 @@ Tinytest.add("spacebars - parse", function (test) {
test.equal(HTML.toJS(Spacebars.parse('<a {{! x}} b=c{{! x}} {{! x}}></a>')),
'HTML.A({b: "c"})');
// currently, if there are only comments, the attribute is truthy. This is
// because comments are stripped during tokenization. If we include
// comments in the token stream, these cases will become falsy for selected.
test.equal(HTML.toJS(Spacebars.parse('<input selected={{!foo}}>')),
'HTML.INPUT({selected: ""})');
test.equal(HTML.toJS(Spacebars.parse('<input selected={{!foo}}{{!bar}}>')),
'HTML.INPUT({selected: ""})');
});

View File

@@ -38,7 +38,8 @@ HTML.Tag.prototype.toJS = function (options) {
kvStrs.push(toObjectLiteralKey(k) + ': ' +
HTML.toJS(this.attrs[k], options));
}
argStrs.push('{' + kvStrs.join(', ') + '}');
if (kvStrs.length)
argStrs.push('{' + kvStrs.join(', ') + '}');
}
for (var i = 0; i < this.children.length; i++) {

View File

@@ -52,6 +52,10 @@
{{{html}}}
</template>
<template name="spacebars_template_test_triple2">
x{{{html}}}{{{html2}}}{{{html3}}}y
</template>
<template name="spacebars_template_test_inclusion_args">
{{> foo bar}}
</template>
@@ -424,3 +428,23 @@ Hi there!
<template name="spacebars_template_test_inclusion_helpers_are_isolated_subtemplate">
</template>
<template name="spacebars_template_test_nully_attributes0">
<input type="checkbox" checked="" stuff="">
</template>
<template name="spacebars_template_test_nully_attributes1">
<input type="checkbox" checked={{foo}} stuff={{foo}}>
</template>
<template name="spacebars_template_test_nully_attributes2">
<input type="checkbox" checked={{foo}}{{bar}} stuff={{foo}}{{bar}}>
</template>
<template name="spacebars_template_test_nully_attributes3">
<input type="checkbox" checked={{#if foo}}{{/if}} stuff={{#if foo}}{{/if}}>
</template>
<template name="spacebars_template_test_double">
{{foo}}
</template>

View File

@@ -140,6 +140,13 @@ Tinytest.add("spacebars - templates - triple", function (test) {
span = elems[0];
test.equal(span.className, 'hi');
test.equal(stripComments(span.innerHTML), 'blah');
var tmpl = Template.spacebars_template_test_triple2;
tmpl.html = function () {};
tmpl.html2 = function () { return null; };
// no tmpl.html3
div = renderToDiv(tmpl);
test.equal(stripComments(div.innerHTML), 'xy');
});
Tinytest.add("spacebars - templates - inclusion args", function (test) {
@@ -784,7 +791,6 @@ testAsyncMulti('spacebars - template - rendered template is DOM in rendered call
function (test, expect) {
var tmpl = Template.spacebars_template_test_aaa;
tmpl.rendered = expect(function () {
console.log('in rendered');
test.equal(trim(stripComments(div.innerHTML)), "aaa");
});
var div = renderToDiv(tmpl);
@@ -1251,3 +1257,86 @@ Tinytest.add('spacebars - templates - inclusion helpers are isolated', function
Deps.flush({_throwErrors: true});
}, /Expected null or template/);
});
Tinytest.add('spacebars - templates - nully attributes', function (test) {
var tmpls = {
0: Template.spacebars_template_test_nully_attributes0,
1: Template.spacebars_template_test_nully_attributes1,
2: Template.spacebars_template_test_nully_attributes2,
3: Template.spacebars_template_test_nully_attributes3,
4: Template.spacebars_template_test_nully_attributes4,
5: Template.spacebars_template_test_nully_attributes5,
6: Template.spacebars_template_test_nully_attributes6
};
var run = function (whichTemplate, data, expectTrue) {
//var withData = UI.With(function () { return data; },
//tmpls[whichTemplate]);
var templateWithData = tmpls[whichTemplate].withData(function () {
return data; });
var div = renderToDiv(templateWithData);
var input = div.querySelector('input');
var descr = JSON.stringify([whichTemplate, data, expectTrue]);
if (expectTrue) {
test.isTrue(input.checked, descr);
test.equal(typeof input.getAttribute('stuff'), 'string', descr);
} else {
test.isFalse(input.checked);
test.equal(JSON.stringify(input.getAttribute('stuff')), 'null', descr);
}
var html = HTML.toHTML(templateWithData);
test.equal(/ checked="[^"]*"/.test(html), !! expectTrue);
test.equal(/ stuff="[^"]*"/.test(html), !! expectTrue);
};
run(0, {}, true);
var truthies = [true, ''];
var falsies = [false, null, undefined];
_.each(truthies, function (x) {
run(1, {foo: x}, true);
});
_.each(falsies, function (x) {
run(1, {foo: x}, false);
});
_.each(truthies, function (x) {
_.each(truthies, function (y) {
run(2, {foo: x, bar: y}, true);
});
_.each(falsies, function (y) {
run(2, {foo: x, bar: y}, true);
});
});
_.each(falsies, function (x) {
_.each(truthies, function (y) {
run(2, {foo: x, bar: y}, true);
});
_.each(falsies, function (y) {
run(2, {foo: x, bar: y}, false);
});
});
run(3, {foo: true}, false);
run(3, {foo: false}, false);
});
Tinytest.add("spacebars - templates - double", function (test) {
var tmpl = Template.spacebars_template_test_double;
var run = function (foo, expectedResult) {
tmpl.foo = foo;
var div = renderToDiv(tmpl);
test.equal(stripComments(div.innerHTML), expectedResult);
};
run('asdf', 'asdf');
run(1.23, '1.23');
run(0, '0');
run(true, 'true');
run(false, '');
run(null, '');
run(undefined, '');
});

View File

@@ -125,9 +125,10 @@ Spacebars.mustache = function (value/*, args*/) {
if (result instanceof Handlebars.SafeString)
return HTML.Raw(result.toString());
else
// map `null` and `undefined` to "", stringify anything else
// (e.g. strings, booleans, numbers including 0).
return String(result == null ? '' : result);
// map `null`, `undefined`, and `false` to null, which is important
// so that attributes with nully values are considered absent.
// stringify anything else (e.g. strings, booleans, numbers including 0).
return (result == null || result === false) ? null : String(result);
};
Spacebars.attrMustache = function (value/*, args*/) {
@@ -151,7 +152,9 @@ Spacebars.attrMustache = function (value/*, args*/) {
// Called on the return value from `Spacebars.mustache` in case the
// template uses triple-stache (`{{{foo bar baz}}}`).
Spacebars.makeRaw = function (value) {
if (value instanceof HTML.Raw)
if (value == null) // null or undefined
return null;
else if (value instanceof HTML.Raw)
return value;
else
return HTML.Raw(value);

View File

@@ -583,10 +583,10 @@ Tinytest.add('templating - helper typecast Issue #617', function (test) {
});
Tinytest.add('templating - each falsy Issue #801', function (test) {
//Minor test for issue #801
//Minor test for issue #801 (#each over array containing nulls)
Template.test_template_issue801.values = function() { return [0,1,2,null,undefined,false]; };
var div = renderToDiv(Template.test_template_issue801);
test.equal(canonicalizeHtml(div.innerHTML), "012false");
test.equal(canonicalizeHtml(div.innerHTML), "012");
});
Tinytest.add('templating - duplicate template error', function (test) {

View File

@@ -21,7 +21,8 @@ Tinytest.add("ui - render - basic", function (test) {
materialize(input, div);
test.equal(canonicalizeHtml(div.innerHTML), expectedInnerHTML);
test.equal(toHTML(input), expectedHTML);
test.equal(toCode(input), expectedCode);
if (typeof expectedCode !== 'undefined')
test.equal(toCode(input), expectedCode);
};
run(P('Hello'),
@@ -73,6 +74,20 @@ Tinytest.add("ui - render - basic", function (test) {
'<div class="foo"><ul><li><p><a href="#one">One</a></p></li><li><p>Two<br>Three</p></li></ul></div>',
'HTML.DIV({"class": "foo"}, HTML.UL(HTML.LI(HTML.P(HTML.A({href: "#one"}, "One"))), HTML.LI(HTML.P("Two", HTML.BR(), "Three"))))');
// Test nully attributes
run(BR({x: null,
y: [[], []],
a: [['']]}),
'<br a="">',
'<br a="">',
'HTML.BR({a: [[""]]})');
run(BR({
x: function () { return function () { return []; }; },
a: function () { return function () { return ''; }; }}),
'<br a="">',
'<br a="">');
});
// test that we correctly update the 'value' property on input fields
@@ -640,4 +655,3 @@ Tinytest.add("ui - render - SVG", function (test) {
test.equal(circle.namespaceURI, "http://www.w3.org/2000/svg");
test.equal(circle.parentNode.namespaceURI, "http://www.w3.org/2000/svg");
});