reactive attribute tests and clean-up

in particular, fully support nested arrays in attribute values
and null and undefined attribute values (which indicate no attribute)
This commit is contained in:
David Greenspan
2013-11-01 19:25:37 -07:00
parent 09fa915fb0
commit dd4d1c930b
3 changed files with 230 additions and 77 deletions

View File

@@ -18,7 +18,11 @@ canonicalizeHtml = function(html) {
attrs = attrs.replace(/^\s+/g, '');
attrs = attrs.replace(/\s+$/g, '');
attrs = attrs.replace(/\s+/g, ' ');
var attrList = attrs.split(' ');
// for the purpose of splitting attributes in a string like
// 'a="b" c="d"', assume they are separated by a single space
// and values are double-quoted, but allow for spaces inside
// the quotes. Split on space following quote.
var attrList = attrs.replace(/" /g, '"\u0000').split('\u0000');
// put attributes in alphabetical order
attrList.sort();
var tagContents = [tagName];

View File

@@ -39,7 +39,7 @@ var makeTagFunc = function (name) {
var attrsGiven = (optAttrs && (typeof optAttrs === 'object') &&
(typeof optAttrs.splice !== 'function'));
var attrs = (attrsGiven ? optAttrs : null);
if (attrsGiven && (typeof attrs.$attrs === 'funciton'))
if (attrsGiven && (typeof attrs.$attrs === 'function'))
attrs = attrs.$attrs;
var tag = new Tag(attrs);
@@ -112,11 +112,16 @@ var insert = function (nodeOrRange, parent, before) {
// Values in the `attrs` dictionary are in pseudo-DOM form -- a string,
// CharRef, or array of strings and CharRefs -- but they are passed to
// the AttributeHandler in string form.
var updateAttributes = function(elem, attrs, handlers) {
var updateAttributes = function(elem, newAttrs, handlers) {
if (handlers) {
for (var k in handlers) {
if (! attrs.hasOwnProperty(k)) {
// remove old attributes (and handlers)
if (! newAttrs.hasOwnProperty(k)) {
// remove attributes (and handlers) for attribute names
// that don't exist as keys of `newAttrs` and so won't
// be visited when traversing it. (Attributes that
// exist in the `newAttrs` object but are `null`
// are handled later.)
var handler = handlers[k];
var oldValue = handler.value;
handler.value = null;
@@ -126,23 +131,29 @@ var updateAttributes = function(elem, attrs, handlers) {
}
}
for (var k in attrs) {
var handler;
for (var k in newAttrs) {
var handler = null;
var oldValue;
var value = attributeValueToString(attrs[k]);
var value = attributeValueToString(newAttrs[k]);
if ((! handlers) || (! handlers.hasOwnProperty(k))) {
// make new handler
checkAttributeName(k);
handler = makeAttributeHandler2(k, value);
if (handlers)
handlers[k] = handler;
oldValue = null;
if (value !== null) {
// make new handler
checkAttributeName(k);
handler = makeAttributeHandler2(k, value);
if (handlers)
handlers[k] = handler;
oldValue = null;
}
} else {
handler = handlers[k];
oldValue = handler.value;
}
handler.value = value;
handler.update(elem, oldValue, value);
if (handler) {
handler.value = value;
handler.update(elem, oldValue, value);
if (value === null)
delete handlers[k];
}
}
};
@@ -202,12 +213,17 @@ var materialize = function (node, parent, before) {
insert(document.createTextNode(node), parent, before);
} else if (typeof node === 'function') {
var range = new UI.DomRange;
Deps.autorun(function (c) {
var rangeUpdater = Deps.autorun(function (c) {
if (! c.firstRun)
range.removeAll();
materialize(node(), range);
});
range.parented = function () {
UI.DomBackend2.onRemoveElement(range.parentNode(), function () {
rangeUpdater.stop();
});
};
insert(range, parent, before);
} else if (node == null) {
// null or undefined.
@@ -233,67 +249,88 @@ var properCaseTagName = function (name) {
};
// Takes an attribute value -- i.e. a string, CharRef, or array of strings and
// CharRefs -- and renders it as a double-quoted string literal suitable for an
// HTML attribute value.
var attributeValueToQuotedString = (function () {
var attributeValuePartToQuotedStringPart = function (v) {
if (typeof v === 'string') {
return v.replace(/"/g, '"').replace(/&/g, '&');
} else if (v.tagName === 'CharRef') {
return v.attrs.html;
}
};
return function (v) {
var result = '"';
if (typeof v === 'object' && (typeof v.length === 'number') && ! v.tagName) {
// array
for (var i = 0; i < v.length; i++)
result += attributeValuePartToQuotedStringPart(v[i]);
} else {
result += attributeValuePartToQuotedStringPart(v);
}
result += '"';
return result;
};
})();
// Takes an attribute value -- i.e. a string, CharRef, or array of strings and
// CharRefs -- and converts it to a string suitable for passing to `setAttribute`.
var attributeValueToString = (function () {
var attributeValuePartToString = function (v) {
if (typeof v === 'string') {
return v;
} else if (v.tagName === 'CharRef') {
return v.attrs.str;
}
};
return function (v) {
if (typeof v === 'object' && (typeof v.length === 'number') && ! v.tagName) {
// array
var result = '';
for (var i = 0; i < v.length; i++)
result += attributeValuePartToString(v[i]);
return result;
} else {
return attributeValuePartToString(v);
}
};
})();
// Takes an attribute value -- i.e. a string, CharRef, or array of strings and
// CharRefs -- and converts it to JavaScript code.
var attributeValueToCode = function (v) {
if (typeof v === 'object' && (typeof v.length === 'number') && ! v.tagName) {
// CharRefs (and arrays) -- and renders it as a double-quoted string literal
// suitable for an HTML attribute value (without the quotes). Returns `null`
// if there's no attribute value (`null`, `undefined`, or empty array).
var attributeValueToQuotedContents = function (v) {
if (v == null) {
// null or undefined
return null;
} else if (typeof v === 'string') {
return v.replace(/"/g, '&quot;').replace(/&/g, '&amp;');
} else if (v.tagName === 'CharRef') {
return v.attrs.html;
} else if (typeof v === 'object' && (typeof v.length === 'number')) {
// array or tag
if (v.tagName)
throw new Error("Unexpected tag in attribute value: " + v.tagName);
// array
var partStrs = [];
for (var i = 0; i < v.length; i++)
partStrs.push(toCode(v[i]));
return '[' + partStrs.join(', ') + ']';
var parts = [];
for (var i = 0; i < v.length; i++) {
var part = attributeValueToQuotedContents(v[i]);
if (part !== null)
parts.push(part);
}
return parts.length ? parts.join('') : null;
} else {
throw new Error("Unexpected node in attribute value: " + v);
}
};
// Takes an attribute value -- i.e. a string, CharRef, or array of strings and
// CharRefs (and arrays) -- and converts it to a string suitable for passing
// to `setAttribute`. May return `null` to mean no attribute.
var attributeValueToString = function (v) {
if (v == null) {
// null or undefined
return null;
} else if (typeof v === 'string') {
return v;
} else if (v.tagName === 'CharRef') {
return v.attrs.str;
} else if (typeof v === 'object' && (typeof v.length === 'number')) {
// array or tag
if (v.tagName)
throw new Error("Unexpected tag in attribute value: " + v.tagName);
// array
var parts = [];
for (var i = 0; i < v.length; i++) {
var part = attributeValueToString(v[i]);
if (part !== null)
parts.push(part);
}
return parts.length ? parts.join('') : null;
} else {
throw new Error("Unexpected node in attribute value: " + v);
}
};
// Takes an attribute value -- i.e. a string, CharRef, or array of strings and
// CharRefs (and arrays) -- and converts it to JavaScript code. May also return
// `null` to indicate that the attribute should not be included because it has
// an identically "nully" value (`null`, `undefined`, `[]`, `[[]]`, etc.).
var attributeValueToCode = function (v) {
if (v == null) {
// null or undefined
return null;
} else if (typeof v === 'string') {
return toJSLiteral(v);
} else if (v.tagName === 'CharRef') {
return toCode(v);
} else if (typeof v === 'object' && (typeof v.length === 'number')) {
// array or tag
if (v.tagName)
throw new Error("Unexpected tag in attribute value: " + v.tagName);
// array
var parts = [];
for (var i = 0; i < v.length; i++) {
var part = attributeValueToCode(v[i]);
if (part !== null)
parts.push(part);
}
return parts.length ? ('[' + parts.join(', ') + ']') : null;
} else {
throw new Error("Unexpected node in attribute value: " + v);
}
};
@@ -362,9 +399,12 @@ var toHTML = function (node) {
var attrs = node.attrs;
if (typeof attrs === 'function')
attrs = attrs();
_.each(attrs, function (v, k) {
checkAttributeName(k);
result += ' ' + k + '=' + attributeValueToQuotedString(v);
v = attributeValueToQuotedContents(v);
if (v !== null)
result += ' ' + k + '="' + v + '"';
});
}
result += '>';
@@ -446,7 +486,9 @@ var toCode = function (node) {
var kvStrs = [];
_.each(node.attrs, function (v, k) {
checkAttributeName(k);
kvStrs.push(toObjectLiteralKey(k) + ': ' + attributeValueToCode(v));
v = attributeValueToCode(v);
if (v !== null)
kvStrs.push(toObjectLiteralKey(k) + ': ' + v);
});
argStrs.push('{' + kvStrs.join(', ') + '}');
}

View File

@@ -10,6 +10,7 @@ var BR = UI.Tag.BR;
var A = UI.Tag.A;
var UL = UI.Tag.UL;
var LI = UI.Tag.LI;
var SPAN = UI.Tag.SPAN;
Tinytest.add("ui - render2 - basic", function (test) {
var run = function (input, expectedInnerHTML, expectedHTML, expectedCode) {
@@ -101,3 +102,109 @@ Tinytest.add("ui - render2 - closures", function (test) {
})();
});
Tinytest.add("ui - render2 - closure GC", function (test) {
// test that removing parent element removes listeners and stops autoruns.
(function () {
var R = ReactiveVar('Hello');
var test1 = P(function () { return R.get(); });
var div = document.createElement("DIV");
materialize(test1, div);
test.equal(canonicalizeHtml(div.innerHTML), "<p>Hello</p>");
R.set('World');
Deps.flush();
test.equal(canonicalizeHtml(div.innerHTML), "<p>World</p>");
test.equal(R.numListeners(), 1);
$(div).remove();
test.equal(R.numListeners(), 0);
R.set('Steve');
Deps.flush();
// should not have changed:
test.equal(canonicalizeHtml(div.innerHTML), "<p>World</p>");
})();
});
Tinytest.add("ui - render2 - reactive attributes", function (test) {
(function () {
var R = ReactiveVar({'class': ['david gre', CharRef({html: '&euml;', str: '\u00eb'}), 'nspan'],
id: 'foo'});
var spanCode = SPAN({$attrs: function () { return R.get(); }});
test.equal(typeof spanCode.attrs, 'function');
test.equal(toHTML(spanCode), '<span class="david gre&euml;nspan" id="foo"></span>');
test.equal(R.numListeners(), 0);
var div = document.createElement("DIV");
materialize(spanCode, div);
test.equal(canonicalizeHtml(div.innerHTML), '<span class="david gre\u00ebnspan" id="foo"></span>');
test.equal(R.numListeners(), 1);
var span = div.firstChild;
test.equal(span.nodeName, 'SPAN');
span.className += ' blah';
R.set({'class': 'david smith', id: 'bar'});
Deps.flush();
test.equal(canonicalizeHtml(div.innerHTML), '<span class="david blah smith" id="bar"></span>');
test.equal(R.numListeners(), 1);
R.set({});
Deps.flush();
test.equal(canonicalizeHtml(div.innerHTML), '<span class="blah"></span>');
test.equal(R.numListeners(), 1);
$(div).remove();
test.equal(R.numListeners(), 0);
})();
// Test `null`, `undefined`, and `[]` attributes
(function () {
var R = ReactiveVar({id: 'foo',
aaa: null,
bbb: undefined,
ccc: [],
ddd: [null],
eee: [undefined],
fff: [[]],
ggg: ['x', ['y', ['z']]]});
var spanCode = SPAN({$attrs: function () { return R.get(); }});
test.equal(toHTML(spanCode), '<span id="foo" ggg="xyz"></span>');
test.equal(toCode(SPAN(R.get())),
'UI.Tag.SPAN({id: "foo", ggg: ["x", ["y", ["z"]]]})');
var div = document.createElement("DIV");
materialize(spanCode, div);
var span = div.firstChild;
test.equal(span.nodeName, 'SPAN');
test.equal(canonicalizeHtml(div.innerHTML), '<span ggg="xyz" id="foo"></span>');
R.set({id: 'foo', ggg: [[], [], []]});
Deps.flush();
test.equal(canonicalizeHtml(div.innerHTML), '<span id="foo"></span>');
R.set({id: 'foo', ggg: null});
Deps.flush();
test.equal(canonicalizeHtml(div.innerHTML), '<span id="foo"></span>');
R.set({id: 'foo', ggg: ''});
Deps.flush();
test.equal(canonicalizeHtml(div.innerHTML), '<span ggg="" id="foo"></span>');
$(span).remove();
test.equal(R.numListeners(), 0);
})();
});