From 77959b1918882f6d9154c6f5185726f7a2f7c401 Mon Sep 17 00:00:00 2001
From: Tom Coleman
Date: Thu, 5 Jul 2012 16:06:17 +1000
Subject: [PATCH 001/212] Altered get_package_dir to search env.PACKAGE_DIRS
---
app/lib/files.js | 20 ++++++++++++++++----
app/lib/packages.js | 6 +++---
app/meteor/run.js | 2 +-
3 files changed, 20 insertions(+), 8 deletions(-)
diff --git a/app/lib/files.js b/app/lib/files.js
index 0b9addf12f..23aec920c7 100644
--- a/app/lib/files.js
+++ b/app/lib/files.js
@@ -191,10 +191,22 @@ var files = module.exports = {
else
return path.join(__dirname, '../..');
},
-
- // Return where the packages are stored
- get_package_dir: function () {
- return path.join(__dirname, '../../packages');
+
+ // if PACKAGE_DIRS env variable is set, and one ends in name, return that dir
+ // otherwise, return default package dir for name
+ get_package_dir: function (name) {
+
+ if (process.env.PACKAGE_DIRS) {
+ var re = new RegExp('/' + name + '/?$');
+ var match = _.find(process.env.PACKAGE_DIRS.split(':'), function(dir) {
+ return re.exec(dir);
+ });
+ if (match)
+ return match;
+ }
+
+ // if none of those worked, the default is:
+ return path.join(__dirname, '../../packages', name);
},
// Return the directory that contains the core tool (the top-level
diff --git a/app/lib/packages.js b/app/lib/packages.js
index 6262c3e56c..a80ac6c2c6 100644
--- a/app/lib/packages.js
+++ b/app/lib/packages.js
@@ -84,10 +84,10 @@ _.extend(Package.prototype, {
init_from_library: function (name) {
var self = this;
self.name = name;
- self.source_root = path.join(__dirname, '../../packages', name);
+ self.source_root = files.get_package_dir(name);
self.serve_root = path.join('/packages', name);
-
- var fullpath = path.join(files.get_package_dir(), name, 'package.js');
+
+ var fullpath = path.join(self.source_root, 'package.js');
var code = fs.readFileSync(fullpath).toString();
// \n is necessary in case final line is a //-comment
var wrapped = "(function(Package,require){" + code + "\n})";
diff --git a/app/meteor/run.js b/app/meteor/run.js
index 26c21bafd5..53c107ce72 100644
--- a/app/meteor/run.js
+++ b/app/meteor/run.js
@@ -272,7 +272,7 @@ var DependencyWatcher = function (deps, app_dir, on_change) {
self.specific_files = {};
for (var pkg in (deps.packages || {})) {
_.each(deps.packages[pkg], function (file) {
- self.specific_files[path.join(files.get_package_dir(), pkg, file)]
+ self.specific_files[path.join(files.get_package_dir(pkg), file)]
= true;
});
};
From 014884aa5c47a99c7beb56c8f7a93c726972aa1e Mon Sep 17 00:00:00 2001
From: Tom Coleman
Date: Thu, 5 Jul 2012 16:40:09 +1000
Subject: [PATCH 002/212] Changed it so you provide a list of dirs to search
for packages in.
Seems more consistent with the way that other projects do it. Plus it makes list / add / remove work better.
---
app/lib/files.js | 36 +++++++++++++++++++++++-------------
app/lib/packages.js | 14 ++++++++------
2 files changed, 31 insertions(+), 19 deletions(-)
diff --git a/app/lib/files.js b/app/lib/files.js
index 23aec920c7..53245bbd56 100644
--- a/app/lib/files.js
+++ b/app/lib/files.js
@@ -192,21 +192,31 @@ var files = module.exports = {
return path.join(__dirname, '../..');
},
- // if PACKAGE_DIRS env variable is set, and one ends in name, return that dir
- // otherwise, return default package dir for name
+ // returns a list of places where packages can be found.
+ // 1. directories set via process.env.PACKAGES_DIRS
+ // 2. default is packages/ in the meteor directory
+ // XXX: 3. a per project directory? (vendor/packages in rails parlance?)
+ get_package_dirs: function() {
+ var package_dirs = [path.join(__dirname, '../../packages')];
+ if (process.env.PACKAGE_DIRS)
+ package_dirs = process.env.PACKAGE_DIRS.split(':').concat(package_dirs);
+
+ return package_dirs;
+ },
+
+ // search package dirs for a package named name.
+ // undefined if the package isn't in any dir
get_package_dir: function (name) {
+ var ret;
+ _.find(this.get_package_dirs(), function(package_dir) {
+ var dir = path.join(package_dir, name);
+ if (path.existsSync(dir)) {
+ ret = dir;
+ return true;
+ }
+ });
- if (process.env.PACKAGE_DIRS) {
- var re = new RegExp('/' + name + '/?$');
- var match = _.find(process.env.PACKAGE_DIRS.split(':'), function(dir) {
- return re.exec(dir);
- });
- if (match)
- return match;
- }
-
- // if none of those worked, the default is:
- return path.join(__dirname, '../../packages', name);
+ return ret;
},
// Return the directory that contains the core tool (the top-level
diff --git a/app/lib/packages.js b/app/lib/packages.js
index a80ac6c2c6..426e3144f9 100644
--- a/app/lib/packages.js
+++ b/app/lib/packages.js
@@ -266,12 +266,14 @@ var packages = module.exports = {
// a package object.
list: function () {
var ret = {};
- var dir = files.get_package_dir();
- _.each(fs.readdirSync(dir), function (name) {
- // skip .meteor directory
- if (path.existsSync(path.join(dir, name, 'package.js')))
- ret[name] = packages.get(name);
- });
+
+ _.each(files.get_package_dirs(), function(dir) {
+ _.each(fs.readdirSync(dir), function (name) {
+ // skip .meteor directory
+ if (path.existsSync(path.join(dir, name, 'package.js')))
+ ret[name] = packages.get(name);
+ });
+ })
return ret;
},
From 8036d1d4914589a6c62fadc5abad945b355f9ed3 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Fri, 8 Jun 2012 12:02:23 -0700
Subject: [PATCH 003/212] Reorganize LiveUI internals to have an object per
chunk
---
packages/liveui/liveui.js | 718 ++++++++++++++++----------------
packages/liveui/liveui_tests.js | 27 +-
2 files changed, 395 insertions(+), 350 deletions(-)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 9a7365d93b..9781951da5 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -2,38 +2,26 @@ Meteor.ui = Meteor.ui || {};
(function() {
- // In render mode (i.e. inside Meteor.ui.render), this is an
- // object, otherwise it is null.
- // callbacks: id -> func, where id ranges from 1 to callbacks._count.
- Meteor.ui._render_mode = null;
+ Meteor.ui._inRenderMode = false;
- // `in_range` is a package-private argument used to render inside
- // an existing LiveRange on an update.
- Meteor.ui.render = function (html_func, react_data, in_range) {
- if (typeof html_func !== "function")
- throw new Error("Meteor.ui.render() requires a function as its first argument.");
+ var newChunksById = {};
- if (Meteor.ui._render_mode)
- throw new Error("Can't nest Meteor.ui.render.");
+ var materialize = function(calcHtml, chunkCallback) {
- var cx = new Meteor.deps.Context;
+ Meteor.ui._inRenderMode = true;
- Meteor.ui._render_mode = {callbacks: {_count: 0}};
- var html, rangeCallbacks;
+ var html;
try {
- html = cx.run(html_func); // run the caller's html_func
+ html = calcHtml();
} finally {
- rangeCallbacks = Meteor.ui._render_mode.callbacks;
- Meteor.ui._render_mode = null;
+ Meteor.ui._inRenderMode = false;
}
- if (typeof html !== "string")
- throw new Error("Render function must return a string");
-
var frag = Meteor.ui._htmlToFragment(html);
if (! frag.firstChild)
frag.appendChild(document.createComment("empty"));
+ var materializedChunks = [];
// Helper that invokes `f` on every comment node under `parent`.
// If `f` returns a node, visit that node next.
@@ -51,10 +39,9 @@ Meteor.ui = Meteor.ui || {};
// walk comments and create ranges
var rangeStartNodes = {};
- var rangesCreated = []; // [[range, id], ...]
each_comment(frag, function(n) {
- var rangeCommentMatch = /^\s*(START|END)RANGE_(\S+)/.exec(n.nodeValue);
+ var rangeCommentMatch = /^\s*(START|END)CHUNK_(\S+)/.exec(n.nodeValue);
if (! rangeCommentMatch)
return null;
@@ -105,16 +92,16 @@ Meteor.ui = Meteor.ui || {};
endNode === startNode.parentNode.nextSibling) {
endNode = startNode.parentNode.lastChild;
} else {
- var r = new RegExp('', 'g');
+ var r = new RegExp('', 'g');
var match = r.exec(html);
var help = "";
if (match) {
var comment_end = r.lastIndex;
var comment_start = comment_end - match[0].length;
var stripped_before = html.slice(0, comment_start).replace(
- //g, '');
+ //g, '');
var stripped_after = html.slice(comment_end).replace(
- //g, '');
+ //g, '');
var context_amount = 50;
var context = stripped_before.slice(-context_amount) +
stripped_after.slice(0, context_amount);
@@ -126,59 +113,44 @@ Meteor.ui = Meteor.ui || {};
}
var range = new Meteor.ui._LiveRange(Meteor.ui._tag, startNode, endNode);
- rangesCreated.push([range, id]);
+ var chunk = newChunksById[id];
+ if (chunk) {
+ chunk._gainRange(range);
+ materializedChunks.push(chunk);
+ }
return next;
});
+ newChunksById = {};
- var range;
- if (in_range) {
- // Called to re-render a chunk; update that chunk in place.
- Meteor.ui._intelligent_replace(in_range, frag);
- range = in_range;
- } else {
- range = new Meteor.ui._LiveRange(Meteor.ui._tag, frag);
- }
-
- // Call "added to DOM" callbacks to wire up all sub-chunks.
- _.each(rangesCreated, function(x) {
- var range = x[0];
- var id = x[1];
- if (rangeCallbacks[id])
- rangeCallbacks[id](range);
- });
-
- Meteor.ui._wire_up(cx, range, html_func, react_data);
-
- return (in_range ? null : frag);
+ if (chunkCallback)
+ _.each(materializedChunks, chunkCallback);
+ return frag;
};
- Meteor.ui.chunk = function(html_func, react_data) {
+ Meteor.ui.render = function (html_func, options) {
+ if (typeof html_func !== "function")
+ throw new Error("Meteor.ui.render() requires a function as its first argument.");
+
+ if (Meteor.ui._inRenderMode)
+ throw new Error("Can't nest Meteor.ui.render.");
+
+ return new Chunk(html_func, options)._asFragment();
+ };
+
+ Meteor.ui.chunk = function(html_func, options) {
if (typeof html_func !== "function")
throw new Error("Meteor.ui.chunk() requires a function as its first argument.");
- if (! Meteor.ui._render_mode) {
- return html_func();
- }
-
- var cx = new Meteor.deps.Context;
- var html = cx.run(html_func);
-
- if (typeof html !== "string")
- throw new Error("Render function must return a string");
-
- return Meteor.ui._ranged_html(html, function(range) {
- Meteor.ui._wire_up(cx, range, html_func, react_data);
- });
+ return new Chunk(html_func, options)._asHtml();
};
-
- Meteor.ui.listChunk = function (observable, doc_func, else_func, react_data) {
+ Meteor.ui.listChunk = function (observable, doc_func, else_func, options) {
if (arguments.length === 3 && typeof else_func === "object") {
- // support (observable, doc_func, react_data) form
- react_data = else_func;
+ // support (observable, doc_func, options) arguments
+ options = else_func;
else_func = null;
}
@@ -186,57 +158,120 @@ Meteor.ui = Meteor.ui || {};
throw new Error("Meteor.ui.listChunk() requires a function as first argument");
else_func = (typeof else_func === "function" ? else_func :
function() { return ""; });
- react_data = react_data || {};
- var buf = [];
- var receiver = new Meteor.ui._CallbackReceiver();
+ var docChunks = [];
+ var elseChunk = new Chunk(else_func);
+ var outerChunk = null;
- var handle = observable.observe(receiver);
- receiver.flush_to_array(buf);
+ var queuedUpdates = [];
+ var enqueue = function(f) {
+ queuedUpdates.push(f);
+ outerChunk && outerChunk.update();
+ };
+ var runQueuedUpdates = function() {
+ _.each(queuedUpdates, function(qu) { qu(); });
+ queuedUpdates.length = 0;
+ };
- var inner_html;
- if (buf.length === 0) {
- inner_html = Meteor.ui.chunk(else_func, react_data);
- } else {
- var doc_render = function(doc) {
- return Meteor.ui._ranged_html(
- Meteor.ui.chunk(function() { return doc_func(doc); },
- _.extend({}, react_data, {event_data: doc})));
- };
- inner_html = _.map(buf, doc_render).join('');
- }
+ var insertFrag = function(frag, i) {
+ if (i === docChunks.length)
+ docChunks[i-1]._range.insert_after(frag);
+ else
+ docChunks[i]._range.insert_before(frag);
+ };
- if (! Meteor.ui._render_mode) {
- handle.stop();
- return inner_html;
- }
+ var handle = observable.observe({
+ added: function(doc, before_idx) {
+ enqueue(function() {
+ var addedChunk = new Chunk(doc_func, {data: doc});
- return Meteor.ui._ranged_html(inner_html, function(outer_range) {
- var range_list = [];
- // find immediate sub-ranges of range, and add to range_list
- if (buf.length > 0) {
- outer_range.visit(function(is_start, r) {
- if (is_start)
- range_list.push(r);
- return false;
+ if (outerChunk) {
+ var frag = addedChunk._asFragment();
+ if (elseChunk)
+ // else case -> one item
+ outerChunk._range.replace_contents(frag);
+ else
+ insertFrag(frag, before_idx);
+ }
+
+ elseChunk && elseChunk.kill();
+ elseChunk = null;
+ docChunks.splice(before_idx, 0, addedChunk);
+ });
+ },
+ removed: function(doc, at_idx) {
+ enqueue(function() {
+ if (outerChunk) {
+ if (docChunks.length === 1) {
+ // one item -> else case
+ elseChunk = new Chunk(else_func);
+ var frag = elseChunk._asFragment();
+ outerChunk._range.replace_contents(frag);
+ } else {
+ // remove item
+ var removedChunk = docChunks[at_idx];
+ removedChunk._range.extract();
+ }
+ }
+
+ docChunks.splice(at_idx, 1)[0].kill();
+ });
+ },
+ moved: function(doc, old_idx, new_idx) {
+ enqueue(function() {
+ if (old_idx === new_idx)
+ return;
+
+ var movedChunk = docChunks[old_idx];
+ var frag;
+ if (outerChunk) {
+ // We know the list has at least two items,
+ // at old_idx and new_idx, so `extract` will
+ // succeed.
+ var frag = movedChunk._range.extract();
+ // remove chunk from list at old index
+ }
+ docChunks.splice(old_idx, 1);
+
+ if (outerChunk)
+ insertFrag(frag, new_idx);
+
+ // insert chunk into list at new index
+ docChunks.splice(new_idx, 0, movedChunk);
+ });
+ },
+ changed: function(doc, at_idx) {
+ enqueue(function() {
+ var chunk = docChunks[at_idx];
+ chunk._data = doc;
+ if (outerChunk)
+ chunk.update();
});
}
-
- Meteor.ui._wire_up_list(outer_range, range_list, receiver, handle,
- doc_func, else_func, react_data);
});
+
+ runQueuedUpdates();
+
+ outerChunk = new Chunk(function() {
+ return _.map(
+ (elseChunk ? [elseChunk] : docChunks),
+ function(ch) { return ch._asHtml(); }).join('');
+ }, options);
+
+ outerChunk.onupdate = function() {
+ // override the normal behavior (of recalculating
+ // and smart-patching the whole contents of the chunk)
+ runQueuedUpdates();
+ };
+
+ outerChunk.onkill = function() {
+ handle.stop();
+ };
+
+ return outerChunk._asHtml();
};
- var killContext = function(range) {
- var cx = range.context;
- if (cx && ! cx.killed) {
- cx.killed = true;
- cx.invalidate && cx.invalidate();
- delete range.context;
- }
- };
-
Meteor.ui._tag = "_liveui";
var _checkOffscreen = function(range) {
@@ -272,7 +307,8 @@ Meteor.ui = Meteor.ui || {};
cx.on_invalidate(function() {
--frag._liveui_refs;
if (! frag._liveui_refs)
- cleanup_frag(frag);
+ // wrap the frag in a new LiveRange that will be destroyed
+ cleanup_range(new Meteor.ui._LiveRange(Meteor.ui._tag, frag));
});
cx.invalidate();
};
@@ -297,126 +333,260 @@ Meteor.ui = Meteor.ui || {};
}
};
- var CallbackReceiver = function() {
+ var wireEvents = function(chunk, andEnclosing) {
+ // Attach events to top-level nodes in `chunk` as specified
+ // by its event handlers.
+ //
+ // If `andEnclosing` is true, we also walk up the chunk
+ // hierarchy looking for event types we need to handle
+ // based on handlers in ancestor chunks. This is necessary
+ // when a chunk is updated or a rendered fragment is added
+ // to the DOM -- basically, when a chunk acquires ancestors.
+ //
+ // In modern browsers (all except IE <= 8), this level of
+ // subtlety is not actually required, because the implementation
+ // of Meteor.ui._event.registerEventType binds one handler
+ // per type globally on the document. However, the Old IE impl
+ // takes advantage of it.
+
+ var range = chunk._range;
+
+ for(var c = chunk; c; c = c.parentChunk()) {
+ var handlers = c._eventhandlers;
+
+ if (handlers) {
+ _.each(handlers.types, function(t) {
+ for(var n = range.firstNode(), after = range.lastNode().nextSibling;
+ n && n !== after;
+ n = n.nextSibling)
+ Meteor.ui._event.registerEventType(t, n);
+ });
+ }
+
+ if (! andEnclosing)
+ break;
+ }
+ };
+
+ var Chunk = function(html_func, options) {
var self = this;
- self.queue = [];
- self.deps = {};
+ options = options || {};
- // attach these callback funcs to each instance, as they may
- // not be called as methods by livedata.
- _.each(["added", "removed", "moved", "changed"], function (name) {
- self[name] = function (/* arguments */) {
- self.queue.push([name].concat(_.toArray(arguments)));
- self.signal();
- };
- });
+ self._range = null;
+ self._calculate = function() {
+ return html_func(this._data);
+ };
+ self._msgs = [];
+ self._msgCx = null;
+ self._data = (options.data || options.event_data || null); // XXX
+ self._eventhandlers =
+ options.events ? unpackEventMap(options.events) : null;
+ self._killed = false;
+ self._context = null;
+
+ // Allow Meteor.deps to signal us about a data change by
+ // invalidating self._context. By the time we see the
+ // invalidation, it's flush time. We immediately set up
+ // a new context for next time.
+ // Always having the latest context in an instance variable
+ // makes clean-up easier.
+ var ondirty = function() {
+ self._send("update");
+ self._context = new Meteor.deps.Context;
+ self._context.on_invalidate(ondirty);
+ };
+ self._context = new Meteor.deps.Context;
+ self._context.on_invalidate(ondirty);
+
+ // use original Context's unique id as our Chunk's unique id
+ self.id = self._context.id;
};
- Meteor.ui._CallbackReceiver = CallbackReceiver;
+ Chunk.prototype._asHtml = function() {
+ var self = this;
- CallbackReceiver.prototype.flush_to = function(t) {
- // fire all queued events on new target
- _.each(this.queue, function(x) {
- var name = x[0];
- var args = x.slice(1);
- t[name].apply(t, args);
+ var html = self._context.run(function() {
+ return self._calculate();
});
- this.queue.length = 0;
- };
- CallbackReceiver.prototype.flush_to_array = function(array) {
- // apply all queued events to array
- _.each(this.queue, function(x) {
- switch (x[0]) {
- case 'added': array.splice(x[2], 0, x[1]); break;
- case 'removed': array.splice(x[2], 1); break;
- case 'moved': array.splice(x[3], 0, array.splice(x[2], 1)[0]); break;
- case 'changed': array[x[2]] = x[1]; break;
- }
- });
- this.queue.length = 0;
- };
- CallbackReceiver.prototype.signal = function() {
- if (this.queue.length > 0) {
- for(var id in this.deps)
- this.deps[id].invalidate();
- }
- };
- CallbackReceiver.prototype.depend = function() {
- var context = Meteor.deps.Context.current;
- if (context && !(context.id in this.deps)) {
- this.deps[context.id] = context;
- var self = this;
- context.on_invalidate(function() {
- delete self.deps[context.id];
- });
+
+ if (typeof html !== "string")
+ throw new Error("Render function must return a string");
+
+ if (! Meteor.ui._inRenderMode) {
+ // no reactivity possible, so kill the chunk (on next flush)
+ self.kill();
+ return html;
+ } else {
+ var id = self.id;
+ newChunksById[id] = self;
+ return "" + html +
+ "";
}
};
- // Performs a replacement by determining which nodes should
- // be preserved and invoking Meteor.ui._Patcher as appropriate.
- Meteor.ui._intelligent_replace = function(tgtRange, srcParent) {
+ Chunk.prototype._gainRange = function(range) {
+ var self = this;
+ self._range = range;
+ range.chunk = self;
+ self._send("added");
+ };
+
+ Chunk.prototype._asFragment = function() {
+ var self = this;
+ var frag = materialize(function() {
+ return self._asHtml();
+ }, wireEvents);
+ self._send("render");
+ return frag;
+ };
+
+ Chunk.prototype.onupdate = function() {
+ var self = this;
+ var frag = materialize(function() {
+ return self._calculate();
+ });
+
+ // DIFF/PATCH
+
+ var range = self._range;
// Table-body fix: if tgtRange is in a table and srcParent
// contains a TR, wrap fragment in a TBODY on all browsers,
// so that it will display properly in IE.
- if (tgtRange.containerNode().nodeName === "TABLE" &&
- _.any(srcParent.childNodes,
+ if (range.containerNode().nodeName === "TABLE" &&
+ _.any(frag.childNodes,
function(n) { return n.nodeName === "TR"; })) {
var tbody = document.createElement("TBODY");
- while (srcParent.firstChild)
- tbody.appendChild(srcParent.firstChild);
- srcParent.appendChild(tbody);
+ while (frag.firstChild)
+ tbody.appendChild(frag.firstChild);
+ frag.appendChild(tbody);
}
var copyFunc = function(t, s) {
Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
};
- //tgtRange.replace_contents(srcParent);
-
- tgtRange.operate(function(start, end) {
+ range.operate(function(start, end) {
// clear all LiveRanges on target
+ // XXX do this in terms of chunks
cleanup_range(new Meteor.ui._LiveRange(Meteor.ui._tag, start, end));
var patcher = new Meteor.ui._Patcher(
- start.parentNode, srcParent,
+ start.parentNode, frag,
start.previousSibling, end.nextSibling);
patcher.diffpatch(copyFunc);
});
- attach_secondary_events(tgtRange);
+ self._send("render");
};
- Meteor.ui._wire_up = function(cx, range, html_func, react_data) {
- // wire events
- var data = react_data || {};
- if (data.events)
- range.event_handlers = unpackEventMap(data.events);
- if (data.event_data)
- range.event_data = data.event_data;
+ Chunk.prototype._send = function(message) {
+ var self = this;
- attach_events(range);
+ self._msgs.push(message);
- // record that if we see this range offscreen during a flush,
- // we are to kill the context (mark it killed and invalidate it).
- // Kill old context from previous update.
- killContext(range);
- range.context = cx;
-
- // wire update
- cx.on_invalidate(function(old_cx) {
- if (old_cx.killed)
- return; // context was invalidated as part of killing it
- if (_checkOffscreen(range))
+ var processMessage = function(msg) {
+ if (self._killed)
return;
- Meteor.ui.render(html_func, react_data, range);
+ // If chunk is not onscreen at flush time, any message
+ // is treated like "kill". All future messages will be
+ // ignored.
+ if (msg === "kill" || (! self._range) || _checkOffscreen(self._range)) {
+ // Pronounce this chunk dead. We rely on this finalization to clean
+ // up the deps context, which is first created in the constructor.
+ // There are many ways for a chunk to die -- never rendered, never
+ // added to the DOM, removed as part of an update, removed
+ // surreptitiously -- but all roads lead here.
+ self._killed = true;
+ self._context.invalidate();
+ self._context = null;
+ self.onkill && self.onkill();
+ } else if (msg === "added") {
+ // This chunk is part of the document for the first time.
+ wireEvents(self);
+ self.onadded && self.onadded();
+ } else if (msg === "update") {
+ // Rerender this chunk in place, in whole or in part.
+ self._context.run(function() {
+ self.onupdate();
+ });
+ } else if (msg === "render") {
+ // This chunk is the root of a Meteor.ui.render or a reactive
+ // update. Its descendent nodes are (most likely) new to the
+ // document.
+ wireEvents(self, true);
+ }
+ };
+
+ // schedule message to be processed at flush time
+ if (! self._msgCx) {
+ var cx = new Meteor.deps.Context;
+ cx.on_invalidate(function() {
+ self._msgCx = null;
+ var msgs = self._msgs;
+ self._msgs = [];
+
+ _.each(msgs, processMessage);
+ });
+ cx.invalidate();
+ self._msgCx = cx;
+ };
+ };
+
+ Chunk.prototype.kill = function() {
+ // schedule killing for flush time.
+ if (! this._killed)
+ this._send("kill");
+ };
+
+ Chunk.prototype.update = function() {
+ // invalidate the context, as if a data dependency changed.
+ // we'll get an "update" message at flush time.
+ this._context.invalidate();
+ };
+
+ Chunk.prototype.childChunks = function() {
+ if (! this._range)
+ throw new Error("Chunk not rendered yet");
+
+ var chunks = [];
+ this._range.visit(function(is_start, r) {
+ if (! is_start)
+ return false;
+ if (! r.chunk)
+ return true; // allow for intervening LiveRanges
+ chunks.push(r.chunk);
+ return false;
});
+
+ return chunks;
+ };
+
+ Chunk.prototype.parentChunk = function() {
+ if (! this._range)
+ throw new Error("Chunk not rendered yet");
+
+ for(var r = this._range.findParent(); r; r = r.findParent())
+ if (r.chunk)
+ return r.chunk;
+
+ return null;
+ };
+
+ Meteor.ui._findChunk = function(node) {
+ var range = Meteor.ui._LiveRange.findRange(Meteor.ui._tag, node);
+
+ for(var r = range; r; r = r.findParent())
+ if (r.chunk)
+ return r.chunk;
+
+ return null;
};
// Convert an event map from the developer into an internal
- // format for range.event_handlers. The internal format is
+ // format for range._eventhandlers. The internal format is
// an array of objects with properties {type, selector, callback}.
// The array has an expando property `types`, which is a list
// of all the unique event types used (as an optimization for
@@ -447,166 +617,18 @@ Meteor.ui = Meteor.ui || {};
return handlers;
};
- Meteor.ui._wire_up_list =
- function(outer_range, range_list, receiver, handle_to_stop,
- doc_func, else_func, react_data)
- {
- react_data = react_data || {};
-
- outer_range.context = new Meteor.deps.Context;
- outer_range.context.run(function() {
- receiver.depend();
- });
- outer_range.context.on_invalidate(function update(old_cx) {
- if (old_cx.killed || _checkOffscreen(outer_range)) {
- if (handle_to_stop)
- handle_to_stop.stop();
- return;
- }
-
- receiver.flush_to(callbacks);
-
- Meteor.ui._wire_up_list(outer_range, range_list, receiver,
- handle_to_stop, doc_func, else_func,
- react_data);
- });
-
- var renderItem = function(doc, in_range) {
- return Meteor.ui.render(
- _.bind(doc_func, null, doc),
- _.extend({}, react_data, {event_data: doc}),
- in_range);
- };
-
- var renderElse = function() {
- return Meteor.ui.render(else_func, react_data);
- };
-
- var callbacks = {
- added: function(doc, before_idx) {
- var frag = renderItem(doc);
- var range = new Meteor.ui._LiveRange(Meteor.ui._tag, frag);
- if (range_list.length === 0)
- cleanup_frag(outer_range.replace_contents(frag));
- else if (before_idx === range_list.length)
- range_list[range_list.length-1].insert_after(frag);
- else
- range_list[before_idx].insert_before(frag);
-
- attach_secondary_events(range);
-
- range_list.splice(before_idx, 0, range);
- },
- removed: function(doc, at_idx) {
- if (range_list.length === 1) {
- cleanup_frag(
- outer_range.replace_contents(renderElse()));
- attach_secondary_events(outer_range);
- } else {
- cleanup_frag(range_list[at_idx].extract());
- }
-
- range_list.splice(at_idx, 1);
- },
- moved: function(doc, old_idx, new_idx) {
- if (old_idx === new_idx)
- return;
-
- var range = range_list[old_idx];
- // We know the list has at least two items,
- // at old_idx and new_idx, so `extract` will succeed.
- var frag = range.extract(true);
- range_list.splice(old_idx, 1);
-
- if (new_idx === range_list.length)
- range_list[range_list.length-1].insert_after(frag);
- else
- range_list[new_idx].insert_before(frag);
- range_list.splice(new_idx, 0, range);
- },
- changed: function(doc, at_idx) {
- var range = range_list[at_idx];
-
- // replace the render in the immediately nested range
- range.visit(function(is_start, r) {
- if (is_start)
- renderItem(doc, r);
- return false;
- });
- }
- };
- };
-
- Meteor.ui._ranged_html = function(html, callback) {
- if (! Meteor.ui._render_mode)
- return html;
-
- var callbacks = Meteor.ui._render_mode.callbacks;
-
- var commentId = ++callbacks._count;
- callbacks[commentId] = callback;
- return "" + html +
- "";
- };
-
- var cleanup_frag = function(frag) {
- // wrap the frag in a new LiveRange that will be destroyed
- cleanup_range(new Meteor.ui._LiveRange(Meteor.ui._tag, frag));
- };
-
- // Cleans up a range and its descendant ranges by calling
- // killContext on them (which removes any associated context
- // from dependency tracking) and then destroy (which removes
- // the liverange data from the DOM).
+ // Cleans up a range and its descendant ranges by killing
+ // any attached chunks (which removes the associated contexts
+ // from dependency tracking) and then destroying the LiveRanges
+ // (which removes the liverange data from the DOM).
var cleanup_range = function(range) {
range.visit(function(is_start, range) {
if (is_start)
- killContext(range);
+ range.chunk && range.chunk.kill();
});
range.destroy(true);
};
- // Attach events specified by `range` to top-level nodes in `range`.
- // The nodes may still be in a DocumentFragment.
- var attach_events = function(range) {
- if (! range.event_handlers)
- return;
-
- _.each(range.event_handlers.types, function(t) {
- for(var n = range.firstNode(), after = range.lastNode().nextSibling;
- n && n !== after;
- n = n.nextSibling)
- Meteor.ui._event.registerEventType(t, n);
- });
- };
-
- // Attach events specified by enclosing ranges of `range`, at the
- // same DOM level, to nodes in `range`. This is necessary if
- // `range` has just been inserted (as in the case of list 'added'
- // events) or if it has been re-rendered but its enclosing ranges
- // haven't. In either case, the nodes in `range` have been rendered
- // without taking enclosing ranges into account, so additional event
- // handlers need to be attached.
- var attach_secondary_events = function(range) {
- // Implementations of LiveEvents that use whole-document event capture
- // (all except old IE) don't actually need any of this; this function
- // could be a no-op.
- for(var r = range; r; r = r.findParent()) {
- if (r === range)
- continue;
- if (! r.event_handlers)
- continue;
-
- var eventTypes = r.event_handlers.types;
- _.each(eventTypes, function(t) {
- for(var n = range.firstNode(), after = range.lastNode().nextSibling;
- n && n !== after;
- n = n.nextSibling)
- Meteor.ui._event.registerEventType(t, n);
- });
- }
- };
-
// Handle a currently-propagating event on a particular node.
// We walk all enclosing liveranges of the node, from the inside out,
// looking for matching handlers. If the app calls stopPropagation(),
@@ -618,14 +640,12 @@ Meteor.ui = Meteor.ui || {};
if (! curNode)
return;
- var innerRange = Meteor.ui._LiveRange.findRange(Meteor.ui._tag, curNode);
- if (! innerRange)
- return;
+ var innerChunk = Meteor.ui._findChunk(curNode);
var type = event.type;
- for(var range = innerRange; range; range = range.findParent()) {
- var event_handlers = range.event_handlers;
+ for(var chunk = innerChunk; chunk; chunk = chunk.parentChunk()) {
+ var event_handlers = chunk._eventhandlers;
if (! event_handlers)
continue;
@@ -637,7 +657,7 @@ Meteor.ui = Meteor.ui || {};
var selector = h.selector;
if (selector) {
- var contextNode = range.containerNode();
+ var contextNode = chunk._range.containerNode();
var results = $(contextNode).find(selector);
if (! _.contains(results, curNode))
continue;
@@ -665,13 +685,13 @@ Meteor.ui = Meteor.ui || {};
};
- // find the innermost enclosing liverange that has event_data
+ // find the innermost enclosing liverange that has event data
var findEventData = function(node) {
- var innerRange = Meteor.ui._LiveRange.findRange(Meteor.ui._tag, node);
+ var innerChunk = Meteor.ui._findChunk(node);
- for(var range = innerRange; range; range = range.findParent())
- if (range.event_data)
- return range.event_data;
+ for(var chunk = innerChunk; chunk; chunk = chunk.parentChunk())
+ if (chunk._data)
+ return chunk._data;
return null;
};
diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js
index 4824e60e7b..6f108e966e 100644
--- a/packages/liveui/liveui_tests.js
+++ b/packages/liveui/liveui_tests.js
@@ -864,7 +864,9 @@ Tinytest.add("liveui - listChunk stop", function(test) {
return "#"+doc._id;
});
test.equal(result, "#123#456");
- test.equal(numHandles, 0); // listChunk called handle.stop();
+ Meteor.flush();
+ // chunk killed because not created inside Meteor.ui.render
+ test.equal(numHandles, 0);
var R = ReactiveVar(1);
@@ -1052,6 +1054,7 @@ Tinytest.add("liveui - listChunk event_data", function(test) {
observer.added({_id: '3', name: 'Baz'}, 2);
observer.added({_id: '4', name: 'Qux'}, 3);
};
+ return { stop: function() {} };
}},
function(doc) {
R.get(); // depend on R
@@ -1532,6 +1535,28 @@ Tinytest.add("liveui - event handling", function(test) {
div.kill();
Meteor.flush();
+ // Test that reactive fragments manually inserted inside
+ // a reactive fragment eventually get wired.
+ event_buf.length = 0;
+ div = OnscreenDiv(Meteor.ui.render(function() {
+ return "";
+ }, { events: eventmap("click span", event_buf) }));
+ Meteor.flush();
+ div.node().firstChild.appendChild(Meteor.ui.render(function() {
+ return 'hello';
+ }));
+ clickElement(getid("foozy"));
+ // implementation has no way to know we've inserted the fragment
+ test.equal(event_buf, []);
+ event_buf.length = 0;
+ Meteor.flush();
+ clickElement(getid("foozy"));
+ // now should be wired up
+ test.equal(event_buf, ['click span']);
+ event_buf.length = 0;
+ div.kill();
+ Meteor.flush();
+
// Event data comes from event.currentTarget, not event.target
var data_buf = [];
div = OnscreenDiv(Meteor.ui.render(function() {
From e47567a11e66e98c1416b627b6872a9e50256968 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Wed, 20 Jun 2012 16:17:07 -0700
Subject: [PATCH 004/212] organization and comments
---
packages/liveui/liveui.js | 1114 ++++++++++++++++++++-----------------
1 file changed, 615 insertions(+), 499 deletions(-)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 9781951da5..17b57b81fe 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -2,10 +2,481 @@ Meteor.ui = Meteor.ui || {};
(function() {
+ //////////////////// PUBLIC API
+
+ Meteor.ui.render = function (html_func, options) {
+ if (typeof html_func !== "function")
+ throw new Error("Meteor.ui.render() requires a function as its first argument.");
+
+ if (Meteor.ui._inRenderMode)
+ throw new Error("Can't nest Meteor.ui.render.");
+
+ return new Chunk(html_func, options)._asFragment();
+ };
+
+ Meteor.ui.chunk = function(html_func, options) {
+ if (typeof html_func !== "function")
+ throw new Error("Meteor.ui.chunk() requires a function as its first argument.");
+
+ return new Chunk(html_func, options)._asHtml();
+ };
+
+ Meteor.ui.listChunk = function (observable, doc_func, else_func, options) {
+ if (arguments.length === 3 && typeof else_func === "object") {
+ // support optional else_func, arguments (observable, doc_func, options)
+ options = else_func;
+ else_func = null;
+ }
+
+ if (typeof doc_func !== "function")
+ throw new Error("Meteor.ui.listChunk() requires a function as first argument");
+
+ // else_func defaults to function returning ""
+ else_func = (typeof else_func === "function" ? else_func :
+ function() { return ""; });
+
+ // State: Keeping track of our child chunks.
+ // At any time, if the list is empty, then docChunks is [] and
+ // elseChunk is a chunk; otherwise, docChunks is a list with a
+ // chunk for each document, and elseChunk is null.
+ var docChunks = [];
+ var elseChunk = new Chunk(else_func);
+ // The outer chunk that contains the other chunks and handles the
+ // updates. We don't create the outer chunk until after we have
+ // called observable.observe(...) and handled the first wave of
+ // updates.
+ var outerChunk = null;
+
+ // Queue updates due to observe callbacks, to process at flush time
+ // when outerChunk's onupdate() fires.
+ var queuedUpdates = [];
+ var enqueue = function(f) {
+ queuedUpdates.push(f);
+ outerChunk && outerChunk.update();
+ };
+ var runQueuedUpdates = function() {
+ _.each(queuedUpdates, function(qu) { qu(); });
+ queuedUpdates.length = 0;
+ };
+
+ // Helper to insert a fragment into the document based on
+ // document chunk index.
+ var insertFrag = function(frag, i) {
+ if (i === docChunks.length)
+ docChunks[i-1]._range.insert_after(frag);
+ else
+ docChunks[i]._range.insert_before(frag);
+ };
+
+ // Register our data callbacks on the observable.
+ //
+ // The initial state of the list will be set by callbacks that
+ // fire right away, typically (or always?) a sequence of "added"
+ // calls. Since there is no outerChunk yet, we distinguish
+ // this case by outerChunk being falsy.
+ //
+ // Callbacks are responsible for maintaining the docChunks/elseChunk
+ // state, manipulating the DOM as appropriate, and calling kill()
+ // on chunks that are removed from the DOM so that they can be
+ // cleaned up immediately. Using enqueue(...), they defer action
+ // until outerChunk.onupdate() is called.
+ var handle = observable.observe({
+ added: function(doc, before_idx) {
+ enqueue(function() {
+ var addedChunk = new Chunk(doc_func, {data: doc});
+
+ if (outerChunk) {
+ var frag = addedChunk._asFragment();
+ if (elseChunk)
+ // else case -> one item
+ outerChunk._range.replace_contents(frag);
+ else
+ insertFrag(frag, before_idx);
+ }
+
+ elseChunk && elseChunk.kill();
+ elseChunk = null;
+ docChunks.splice(before_idx, 0, addedChunk);
+ });
+ },
+ removed: function(doc, at_idx) {
+ enqueue(function() {
+ if (outerChunk) {
+ if (docChunks.length === 1) {
+ // one item -> else case
+ elseChunk = new Chunk(else_func);
+ var frag = elseChunk._asFragment();
+ outerChunk._range.replace_contents(frag);
+ } else {
+ // remove item
+ var removedChunk = docChunks[at_idx];
+ removedChunk._range.extract();
+ }
+ }
+
+ docChunks.splice(at_idx, 1)[0].kill();
+ });
+ },
+ moved: function(doc, old_idx, new_idx) {
+ enqueue(function() {
+ if (old_idx === new_idx)
+ return;
+
+ var movedChunk = docChunks[old_idx];
+ var frag;
+ if (outerChunk) {
+ // We know the list has at least two items,
+ // at old_idx and new_idx, so `extract` will
+ // succeed.
+ var frag = movedChunk._range.extract();
+ // remove chunk from list at old index
+ }
+ docChunks.splice(old_idx, 1);
+
+ if (outerChunk)
+ insertFrag(frag, new_idx);
+
+ // insert chunk into list at new index
+ docChunks.splice(new_idx, 0, movedChunk);
+ });
+ },
+ changed: function(doc, at_idx) {
+ enqueue(function() {
+ var chunk = docChunks[at_idx];
+ // set the chunk's data, which determines the argument
+ // to doc_func.
+ chunk._data = doc;
+ if (outerChunk)
+ chunk.update();
+ });
+ }
+ });
+
+ // Process the updates generated by the initial observe(...).
+ runQueuedUpdates();
+
+ // Create the outer chunk by calculating the appropriate HTML
+ // and passing in the options we were given.
+ outerChunk = new Chunk(function() {
+ return _.map(
+ (elseChunk ? [elseChunk] : docChunks),
+ function(ch) { return ch._asHtml(); }).join('');
+ }, options);
+
+ // Override the normal behavior on update, which is to
+ // recalculate the HTML and diff/patch the DOM.
+ // Instead, we just run the incremental update functions
+ // we've queued.
+ outerChunk.onupdate = function() {
+ runQueuedUpdates();
+ };
+
+ // Finalizer: when chunk is cleaned up, kill the observer handle.
+ // This will happen even if the chunk is never used or listChunk
+ // wasn't called inside render, as all Chunks are eventually
+ // finalized after _asHtml() is called.
+ outerChunk.onkill = function() {
+ handle.stop();
+ };
+
+ return outerChunk._asHtml();
+ };
+
+ //////////////////// CHUNK OBJECT
+
+ // A Chunk object ties together the following:
+ //
+ // - A function returning HTML (html_func -> self._calculate())
+ // - A LiveRange (self._range) in the DOM
+ // - A data object (options.data -> self._data)
+ // - Event handlers (options.events -> self._eventHandlers)
+ // - A rolling deps context for invalidation (self._context)
+ // - A message queue for taking actions at flush time
+ //
+ // Since context invalidations are deferred until "flush time" by
+ // Meteor.deps, it would be confusing at all levels if we sometimes
+ // updated the DOM at other times. Flush time is also the point at
+ // which we can kill a chunk that is found to be offscreen (or was
+ // never materialized as DOM). Because of this, we defer all actions
+ // until flush time via self._send(...).
+ //
+ // A newly-instantiated Chunk object is in an initial, "uncalculated"
+ // state, with no HTML or DOM generated yet, and no LiveRange. The
+ // next step is to call either _asHtml() or _asFragment() to get
+ // initial HTML or a complete reactive fragment for the chunk.
+ // Once one of these methods is called, the chunk is guaranteed to
+ // be visited at flush time (via the message queue), when it will
+ // either survive, if it received a LiveRange and was added to the
+ // document, or be killed.
+
+ var Chunk = Meteor.ui._Chunk = function(html_func, options) {
+ var self = this;
+
+ options = options || {};
+
+ self._range = null;
+ self._calculate = function() {
+ return html_func(this._data);
+ };
+ self._msgs = [];
+ self._msgCx = null;
+ self._data = (options.data || options.event_data || null); // XXX
+ self._eventHandlers =
+ options.events ? unpackEventMap(options.events) : null;
+ self._killed = false;
+ self._context = null;
+
+ // Allow Meteor.deps to signal us about a data change by
+ // invalidating self._context. By the time we see the
+ // invalidation, it's flush time. We immediately set up
+ // a new context for next time.
+ // Always having the latest context in an instance variable
+ // makes clean-up easier.
+ var ondirty = function() {
+ self._send("update");
+ self._context = new Meteor.deps.Context;
+ self._context.on_invalidate(ondirty);
+ };
+ self._context = new Meteor.deps.Context;
+ self._context.on_invalidate(ondirty);
+
+ // use original Context's unique id as our Chunk's unique id
+ self.id = self._context.id;
+ };
+
+ // Returns HTML for this newly-created chunk, annotated with
+ // comments containing the chunk's ID if we are in render mode.
+ // The HTML is determined by calling self._calculate().
+ Chunk.prototype._asHtml = function() {
+ var self = this;
+
+ var html = self._context.run(function() {
+ return self._calculate();
+ });
+
+ if (typeof html !== "string")
+ throw new Error("Render function must return a string");
+
+ if (! Meteor.ui._inRenderMode) {
+ // no reactivity possible, so kill the chunk (on next flush)
+ self.kill();
+ return html;
+ } else {
+ var id = self.id;
+ newChunksById[id] = self;
+ return "" + html +
+ "";
+ }
+ };
+
+ // Returns a reactive fragment for this newly-created chunk
+ // by materializing the result of self._asHtml().
+ Chunk.prototype._asFragment = function() {
+ var self = this;
+ var frag = materialize(
+ function() { return self._asHtml(); },
+ // Events will be wired at flush time anyway, but the developer might
+ // expect them to be present immediately for some reason. Unit tests
+ // rely on this.
+ wireEvents);
+ // Indicate that we are at the root of a render.
+ self._send("render");
+ return frag;
+ };
+
+ // Called upon materialization of the chunk's HTML into DOM,
+ // marking the point where we have a LiveRange.
+ Chunk.prototype._gainRange = function(range) {
+ var self = this;
+ self._range = range;
+ range.chunk = self;
+ // Start the message queue. Handling this message will cause
+ // an offscreen check and potentially kill the chunk
+ // if it never got used.
+ self._send("added");
+ };
+
+ // Callback to update or re-render this chunk in the DOM.
+ // Always called inside the chunk's dependency context.
+ Chunk.prototype.onupdate = function() {
+ // Default behavior on update is to recalculate the HTML
+ // and patch the new DOM into place.
+
+ var self = this;
+ var frag = materialize(function() {
+ return self._calculate();
+ });
+
+ // DIFF/PATCH
+
+ var range = self._range;
+
+ // Table-body fix: if tgtRange is in a table and srcParent
+ // contains a TR, wrap fragment in a TBODY on all browsers,
+ // so that it will display properly in IE.
+ if (range.containerNode().nodeName === "TABLE" &&
+ _.any(frag.childNodes,
+ function(n) { return n.nodeName === "TR"; })) {
+ var tbody = document.createElement("TBODY");
+ while (frag.firstChild)
+ tbody.appendChild(frag.firstChild);
+ frag.appendChild(tbody);
+ }
+
+ // Since we are patching from a source DOM with LiveRanges onto
+ // a clean target DOM, when we decide to keep a node from the
+ // target DOM we need to "transplant" (copy) the LiveRange data
+ // from the source node.
+ var copyFunc = function(t, s) {
+ Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
+ };
+
+ range.operate(function(start, end) {
+ // clear all LiveRanges on target
+ cleanup_range(new Meteor.ui._LiveRange(Meteor.ui._tag, start, end));
+
+ var patcher = new Meteor.ui._Patcher(
+ start.parentNode, frag,
+ start.previousSibling, end.nextSibling);
+ patcher.diffpatch(copyFunc);
+ });
+
+ // Indicate that we are at the root of a re-render.
+ self._send("render");
+ };
+
+ // Internal mechanism to enqueue a named message for this chunk,
+ // to be processed at flush time.
+ Chunk.prototype._send = function(message) {
+ var self = this;
+
+ self._msgs.push(message);
+
+ var processMessage = function(msg) {
+ if (self._killed)
+ return;
+
+ // If chunk is not onscreen at flush time, any message
+ // is treated like "kill". All future messages will be
+ // ignored.
+ if (msg === "kill" || (! self._range) || _checkOffscreen(self._range)) {
+ // Pronounce this chunk dead. We rely on this finalization to clean
+ // up the deps context, which is first created in the constructor.
+ // There are many ways for a chunk to die -- never rendered, never
+ // added to the DOM, removed as part of an update, removed
+ // surreptitiously -- but all roads lead here.
+ self._range = null; // can't count on LiveRange in onkill handler
+ self._killed = true;
+ self._context.invalidate();
+ self._context = null;
+ self.onkill && self.onkill();
+ } else if (msg === "added") {
+ // This chunk is part of the document for the first time.
+ wireEvents(self);
+ } else if (msg === "update") {
+ // Rerender this chunk in place, in whole or in part.
+ self._context.run(function() {
+ self.onupdate();
+ });
+ } else if (msg === "render") {
+ // This chunk is the root of a Meteor.ui.render or a reactive
+ // update. Its descendent nodes are (most likely) new to the
+ // document.
+ wireEvents(self, true);
+ }
+ };
+
+ // schedule message to be processed at flush time
+ if (! self._msgCx) {
+ var cx = new Meteor.deps.Context;
+ cx.on_invalidate(function() {
+ self._msgCx = null;
+ var msgs = self._msgs;
+ self._msgs = [];
+
+ _.each(msgs, processMessage);
+ });
+ cx.invalidate();
+ self._msgCx = cx;
+ };
+ };
+
+ // Kills this chunk. Safe to call at any time from anywhere.
+ Chunk.prototype.kill = function() {
+ // schedule killing for flush time.
+ if (! this._killed)
+ this._send("kill");
+ };
+
+ // Updates this chunk, as if a data dependency changed.
+ Chunk.prototype.update = function() {
+ // we'll get an "update" message at flush time.
+ this._context.invalidate();
+ };
+
+ // Returns an array of immediate descendent chunks in the chunk
+ // hierarchy.
+ Chunk.prototype.childChunks = function() {
+ if (! this._range)
+ throw new Error("Chunk not rendered yet");
+
+ var chunks = [];
+ this._range.visit(function(is_start, r) {
+ if (! is_start)
+ return false;
+ if (! r.chunk)
+ return true; // allow for intervening LiveRanges
+ chunks.push(r.chunk);
+ return false;
+ });
+
+ return chunks;
+ };
+
+ // Returns this chunk's enclosing chunk in the hierarchy, if
+ // any, or null.
+ Chunk.prototype.parentChunk = function() {
+ if (! this._range)
+ throw new Error("Chunk not rendered yet");
+
+ for(var r = this._range.findParent(); r; r = r.findParent())
+ if (r.chunk)
+ return r.chunk;
+
+ return null;
+ };
+
+ // Finds the innermost enclosing chunk of a DOM node, if any, or
+ // returns null.
+ Meteor.ui._findChunk = function(node) {
+ var range = Meteor.ui._LiveRange.findRange(Meteor.ui._tag, node);
+
+ for(var r = range; r; r = r.findParent())
+ if (r.chunk)
+ return r.chunk;
+
+ return null;
+ };
+
+ //////////////////// MATERIALIZATION (HTML -> DOM)
+
+ Meteor.ui._tag = "_liveui";
Meteor.ui._inRenderMode = false;
+ var newChunksById = {}; // id -> chunk
- var newChunksById = {};
-
+ // Materializes HTML into DOM nodes and chunks.
+ //
+ // Calls calcHtml() in "render mode". In render mode,
+ // chunks register themselves in the newChunksById map
+ // when they are converted into HTML and produce
+ // HTML comments marking where the chunks begin and
+ // end. We use those comments to create LiveRanges
+ // and associate them with the chunks.
+ //
+ // Once the comments are found and the new chunks are
+ // given LiveRanges, we call chunkCallback on each one,
+ // and then return a DocumentFragment of the materialized
+ // DOM.
var materialize = function(calcHtml, chunkCallback) {
Meteor.ui._inRenderMode = true;
@@ -130,149 +601,158 @@ Meteor.ui = Meteor.ui || {};
return frag;
};
- Meteor.ui.render = function (html_func, options) {
- if (typeof html_func !== "function")
- throw new Error("Meteor.ui.render() requires a function as its first argument.");
+ //////////////////// CHUNK EVENT SUPPORT
- if (Meteor.ui._inRenderMode)
- throw new Error("Can't nest Meteor.ui.render.");
+ var wireEvents = function(chunk, andEnclosing) {
+ // Attach events to top-level nodes in `chunk` as specified
+ // by its event handlers.
+ //
+ // If `andEnclosing` is true, we also walk up the chunk
+ // hierarchy looking for event types we need to handle
+ // based on handlers in ancestor chunks. This is necessary
+ // when a chunk is updated or a rendered fragment is added
+ // to the DOM -- basically, when a chunk acquires ancestors.
+ //
+ // In modern browsers (all except IE <= 8), this level of
+ // subtlety is not actually required, because the implementation
+ // of Meteor.ui._event.registerEventType binds one handler
+ // per type globally on the document. However, the Old IE impl
+ // takes advantage of it.
- return new Chunk(html_func, options)._asFragment();
- };
+ var range = chunk._range;
- Meteor.ui.chunk = function(html_func, options) {
- if (typeof html_func !== "function")
- throw new Error("Meteor.ui.chunk() requires a function as its first argument.");
+ for(var c = chunk; c; c = c.parentChunk()) {
+ var handlers = c._eventHandlers;
- return new Chunk(html_func, options)._asHtml();
- };
-
- Meteor.ui.listChunk = function (observable, doc_func, else_func, options) {
- if (arguments.length === 3 && typeof else_func === "object") {
- // support (observable, doc_func, options) arguments
- options = else_func;
- else_func = null;
- }
-
- if (typeof doc_func !== "function")
- throw new Error("Meteor.ui.listChunk() requires a function as first argument");
- else_func = (typeof else_func === "function" ? else_func :
- function() { return ""; });
-
- var docChunks = [];
- var elseChunk = new Chunk(else_func);
- var outerChunk = null;
-
- var queuedUpdates = [];
- var enqueue = function(f) {
- queuedUpdates.push(f);
- outerChunk && outerChunk.update();
- };
- var runQueuedUpdates = function() {
- _.each(queuedUpdates, function(qu) { qu(); });
- queuedUpdates.length = 0;
- };
-
- var insertFrag = function(frag, i) {
- if (i === docChunks.length)
- docChunks[i-1]._range.insert_after(frag);
- else
- docChunks[i]._range.insert_before(frag);
- };
-
- var handle = observable.observe({
- added: function(doc, before_idx) {
- enqueue(function() {
- var addedChunk = new Chunk(doc_func, {data: doc});
-
- if (outerChunk) {
- var frag = addedChunk._asFragment();
- if (elseChunk)
- // else case -> one item
- outerChunk._range.replace_contents(frag);
- else
- insertFrag(frag, before_idx);
- }
-
- elseChunk && elseChunk.kill();
- elseChunk = null;
- docChunks.splice(before_idx, 0, addedChunk);
- });
- },
- removed: function(doc, at_idx) {
- enqueue(function() {
- if (outerChunk) {
- if (docChunks.length === 1) {
- // one item -> else case
- elseChunk = new Chunk(else_func);
- var frag = elseChunk._asFragment();
- outerChunk._range.replace_contents(frag);
- } else {
- // remove item
- var removedChunk = docChunks[at_idx];
- removedChunk._range.extract();
- }
- }
-
- docChunks.splice(at_idx, 1)[0].kill();
- });
- },
- moved: function(doc, old_idx, new_idx) {
- enqueue(function() {
- if (old_idx === new_idx)
- return;
-
- var movedChunk = docChunks[old_idx];
- var frag;
- if (outerChunk) {
- // We know the list has at least two items,
- // at old_idx and new_idx, so `extract` will
- // succeed.
- var frag = movedChunk._range.extract();
- // remove chunk from list at old index
- }
- docChunks.splice(old_idx, 1);
-
- if (outerChunk)
- insertFrag(frag, new_idx);
-
- // insert chunk into list at new index
- docChunks.splice(new_idx, 0, movedChunk);
- });
- },
- changed: function(doc, at_idx) {
- enqueue(function() {
- var chunk = docChunks[at_idx];
- chunk._data = doc;
- if (outerChunk)
- chunk.update();
+ if (handlers) {
+ _.each(handlers.types, function(t) {
+ for(var n = range.firstNode(), after = range.lastNode().nextSibling;
+ n && n !== after;
+ n = n.nextSibling)
+ Meteor.ui._event.registerEventType(t, n);
});
}
- });
- runQueuedUpdates();
-
- outerChunk = new Chunk(function() {
- return _.map(
- (elseChunk ? [elseChunk] : docChunks),
- function(ch) { return ch._asHtml(); }).join('');
- }, options);
-
- outerChunk.onupdate = function() {
- // override the normal behavior (of recalculating
- // and smart-patching the whole contents of the chunk)
- runQueuedUpdates();
- };
-
- outerChunk.onkill = function() {
- handle.stop();
- };
-
- return outerChunk._asHtml();
+ if (! andEnclosing)
+ break;
+ }
};
+ // Convert an event map from the developer into an internal
+ // format for chunk._eventHandlers. The internal format is
+ // an array of objects with properties {type, selector, callback}.
+ // The array has an expando property `types`, which is a list
+ // of all the unique event types used (as an optimization for
+ // code that needs this info).
+ var unpackEventMap = function(events) {
+ var handlers = [];
- Meteor.ui._tag = "_liveui";
+ var eventTypeSet = {};
+
+ // iterate over `spec: callback` map
+ _.each(events, function(callback, spec) {
+ var clauses = spec.split(/,\s+/);
+ // iterate over clauses of spec, e.g. ['click .foo', 'click .bar']
+ _.each(clauses, function (clause) {
+ var parts = clause.split(/\s+/);
+ if (parts.length === 0)
+ return;
+
+ var type = parts.shift();
+ var selector = parts.join(' ');
+
+ handlers.push({type:type, selector:selector, callback:callback});
+ eventTypeSet[type] = true;
+ });
+ });
+
+ handlers.types = _.keys(eventTypeSet);
+ return handlers;
+ };
+
+ // Handle a currently-propagating event on a particular node.
+ // We walk all enclosing liveranges of the node, from the inside out,
+ // looking for matching handlers. If the app calls stopPropagation(),
+ // we still call all handlers in all event maps for the current node.
+ // If the app calls "stopImmediatePropagation()", we don't call any
+ // more handlers.
+ var handleEvent = function(event) {
+ var curNode = event.currentTarget;
+ if (! curNode)
+ return;
+
+ var innerChunk = Meteor.ui._findChunk(curNode);
+
+ var type = event.type;
+
+ for(var chunk = innerChunk; chunk; chunk = chunk.parentChunk()) {
+ var event_handlers = chunk._eventHandlers;
+ if (! event_handlers)
+ continue;
+
+ for(var i=0, N=event_handlers.length; i" + html +
- "";
- }
- };
-
- Chunk.prototype._gainRange = function(range) {
- var self = this;
- self._range = range;
- range.chunk = self;
- self._send("added");
- };
-
- Chunk.prototype._asFragment = function() {
- var self = this;
- var frag = materialize(function() {
- return self._asHtml();
- }, wireEvents);
- self._send("render");
- return frag;
- };
-
- Chunk.prototype.onupdate = function() {
- var self = this;
- var frag = materialize(function() {
- return self._calculate();
- });
-
- // DIFF/PATCH
-
- var range = self._range;
-
- // Table-body fix: if tgtRange is in a table and srcParent
- // contains a TR, wrap fragment in a TBODY on all browsers,
- // so that it will display properly in IE.
- if (range.containerNode().nodeName === "TABLE" &&
- _.any(frag.childNodes,
- function(n) { return n.nodeName === "TR"; })) {
- var tbody = document.createElement("TBODY");
- while (frag.firstChild)
- tbody.appendChild(frag.firstChild);
- frag.appendChild(tbody);
- }
-
- var copyFunc = function(t, s) {
- Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
- };
-
- range.operate(function(start, end) {
- // clear all LiveRanges on target
- // XXX do this in terms of chunks
- cleanup_range(new Meteor.ui._LiveRange(Meteor.ui._tag, start, end));
-
- var patcher = new Meteor.ui._Patcher(
- start.parentNode, frag,
- start.previousSibling, end.nextSibling);
- patcher.diffpatch(copyFunc);
- });
-
- self._send("render");
- };
-
- Chunk.prototype._send = function(message) {
- var self = this;
-
- self._msgs.push(message);
-
- var processMessage = function(msg) {
- if (self._killed)
- return;
-
- // If chunk is not onscreen at flush time, any message
- // is treated like "kill". All future messages will be
- // ignored.
- if (msg === "kill" || (! self._range) || _checkOffscreen(self._range)) {
- // Pronounce this chunk dead. We rely on this finalization to clean
- // up the deps context, which is first created in the constructor.
- // There are many ways for a chunk to die -- never rendered, never
- // added to the DOM, removed as part of an update, removed
- // surreptitiously -- but all roads lead here.
- self._killed = true;
- self._context.invalidate();
- self._context = null;
- self.onkill && self.onkill();
- } else if (msg === "added") {
- // This chunk is part of the document for the first time.
- wireEvents(self);
- self.onadded && self.onadded();
- } else if (msg === "update") {
- // Rerender this chunk in place, in whole or in part.
- self._context.run(function() {
- self.onupdate();
- });
- } else if (msg === "render") {
- // This chunk is the root of a Meteor.ui.render or a reactive
- // update. Its descendent nodes are (most likely) new to the
- // document.
- wireEvents(self, true);
- }
- };
-
- // schedule message to be processed at flush time
- if (! self._msgCx) {
- var cx = new Meteor.deps.Context;
- cx.on_invalidate(function() {
- self._msgCx = null;
- var msgs = self._msgs;
- self._msgs = [];
-
- _.each(msgs, processMessage);
- });
- cx.invalidate();
- self._msgCx = cx;
- };
- };
-
- Chunk.prototype.kill = function() {
- // schedule killing for flush time.
- if (! this._killed)
- this._send("kill");
- };
-
- Chunk.prototype.update = function() {
- // invalidate the context, as if a data dependency changed.
- // we'll get an "update" message at flush time.
- this._context.invalidate();
- };
-
- Chunk.prototype.childChunks = function() {
- if (! this._range)
- throw new Error("Chunk not rendered yet");
-
- var chunks = [];
- this._range.visit(function(is_start, r) {
- if (! is_start)
- return false;
- if (! r.chunk)
- return true; // allow for intervening LiveRanges
- chunks.push(r.chunk);
- return false;
- });
-
- return chunks;
- };
-
- Chunk.prototype.parentChunk = function() {
- if (! this._range)
- throw new Error("Chunk not rendered yet");
-
- for(var r = this._range.findParent(); r; r = r.findParent())
- if (r.chunk)
- return r.chunk;
-
- return null;
- };
-
- Meteor.ui._findChunk = function(node) {
- var range = Meteor.ui._LiveRange.findRange(Meteor.ui._tag, node);
-
- for(var r = range; r; r = r.findParent())
- if (r.chunk)
- return r.chunk;
-
- return null;
- };
-
- // Convert an event map from the developer into an internal
- // format for range._eventhandlers. The internal format is
- // an array of objects with properties {type, selector, callback}.
- // The array has an expando property `types`, which is a list
- // of all the unique event types used (as an optimization for
- // code that needs this info).
- var unpackEventMap = function(events) {
- var handlers = [];
-
- var eventTypeSet = {};
-
- // iterate over `spec: callback` map
- _.each(events, function(callback, spec) {
- var clauses = spec.split(/,\s+/);
- // iterate over clauses of spec, e.g. ['click .foo', 'click .bar']
- _.each(clauses, function (clause) {
- var parts = clause.split(/\s+/);
- if (parts.length === 0)
- return;
-
- var type = parts.shift();
- var selector = parts.join(' ');
-
- handlers.push({type:type, selector:selector, callback:callback});
- eventTypeSet[type] = true;
- });
- });
-
- handlers.types = _.keys(eventTypeSet);
- return handlers;
- };
-
- // Cleans up a range and its descendant ranges by killing
- // any attached chunks (which removes the associated contexts
- // from dependency tracking) and then destroying the LiveRanges
- // (which removes the liverange data from the DOM).
- var cleanup_range = function(range) {
- range.visit(function(is_start, range) {
- if (is_start)
- range.chunk && range.chunk.kill();
- });
- range.destroy(true);
- };
-
- // Handle a currently-propagating event on a particular node.
- // We walk all enclosing liveranges of the node, from the inside out,
- // looking for matching handlers. If the app calls stopPropagation(),
- // we still call all handlers in all event maps for the current node.
- // If the app calls "stopImmediatePropagation()", we don't call any
- // more handlers.
- Meteor.ui._handleEvent = function(event) {
- var curNode = event.currentTarget;
- if (! curNode)
- return;
-
- var innerChunk = Meteor.ui._findChunk(curNode);
-
- var type = event.type;
-
- for(var chunk = innerChunk; chunk; chunk = chunk.parentChunk()) {
- var event_handlers = chunk._eventhandlers;
- if (! event_handlers)
- continue;
-
- for(var i=0, N=event_handlers.length; i
Date: Mon, 25 Jun 2012 20:56:49 -0700
Subject: [PATCH 005/212] wip
---
packages/liveui/liveui.js | 164 +++++++++++---------------------
packages/liveui/liveui_tests.js | 7 +-
2 files changed, 58 insertions(+), 113 deletions(-)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 17b57b81fe..ca061f48c4 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -216,7 +216,12 @@ Meteor.ui = Meteor.ui || {};
self._range = null;
self._calculate = function() {
- return html_func(this._data);
+ var html = html_func(this._data);
+
+ if (typeof html !== "string")
+ throw new Error("Render function must return a string");
+
+ return html;
};
self._msgs = [];
self._msgCx = null;
@@ -250,22 +255,14 @@ Meteor.ui = Meteor.ui || {};
Chunk.prototype._asHtml = function() {
var self = this;
- var html = self._context.run(function() {
- return self._calculate();
- });
-
- if (typeof html !== "string")
- throw new Error("Render function must return a string");
-
if (! Meteor.ui._inRenderMode) {
// no reactivity possible, so kill the chunk (on next flush)
self.kill();
- return html;
+ return self._calculate();
} else {
var id = self.id;
newChunksById[id] = self;
- return "" + html +
- "";
+ return "";
}
};
@@ -274,11 +271,16 @@ Meteor.ui = Meteor.ui || {};
Chunk.prototype._asFragment = function() {
var self = this;
var frag = materialize(
- function() { return self._asHtml(); },
- // Events will be wired at flush time anyway, but the developer might
- // expect them to be present immediately for some reason. Unit tests
- // rely on this.
- wireEvents);
+ function() {
+ return self._context.run(function() {
+ return self._calculate();
+ });
+ });
+ self._gainRange(new Meteor.ui._LiveRange(Meteor.ui._tag, frag));
+ // Events will be wired at flush time anyway, but the developer might
+ // expect them to be present immediately for some reason. Unit tests
+ // rely on this too.
+ wireEvents(self);
// Indicate that we are at the root of a render.
self._send("render");
return frag;
@@ -311,17 +313,7 @@ Meteor.ui = Meteor.ui || {};
var range = self._range;
- // Table-body fix: if tgtRange is in a table and srcParent
- // contains a TR, wrap fragment in a TBODY on all browsers,
- // so that it will display properly in IE.
- if (range.containerNode().nodeName === "TABLE" &&
- _.any(frag.childNodes,
- function(n) { return n.nodeName === "TR"; })) {
- var tbody = document.createElement("TBODY");
- while (frag.firstChild)
- tbody.appendChild(frag.firstChild);
- frag.appendChild(tbody);
- }
+ prepareFrag(frag, range.containerNode());
// Since we are patching from a source DOM with LiveRanges onto
// a clean target DOM, when we decide to keep a node from the
@@ -477,7 +469,7 @@ Meteor.ui = Meteor.ui || {};
// given LiveRanges, we call chunkCallback on each one,
// and then return a DocumentFragment of the materialized
// DOM.
- var materialize = function(calcHtml, chunkCallback) {
+ var materialize = function(calcHtml) {
Meteor.ui._inRenderMode = true;
@@ -488,6 +480,9 @@ Meteor.ui = Meteor.ui || {};
Meteor.ui._inRenderMode = false;
}
+ var chunkMap = newChunksById;
+ newChunksById = {};
+
var frag = Meteor.ui._htmlToFragment(html);
if (! frag.firstChild)
frag.appendChild(document.createComment("empty"));
@@ -508,96 +503,31 @@ Meteor.ui = Meteor.ui || {};
}
};
- // walk comments and create ranges
- var rangeStartNodes = {};
+ // walk comments and insert chunks
each_comment(frag, function(n) {
+ var chunkCommentMatch = /^\s*CHUNK_(\S+)/.exec(n.nodeValue);
- var rangeCommentMatch = /^\s*(START|END)CHUNK_(\S+)/.exec(n.nodeValue);
- if (! rangeCommentMatch)
+ if (! chunkCommentMatch)
return null;
- var which = rangeCommentMatch[1];
- var id = rangeCommentMatch[2];
+ var id = chunkCommentMatch[1];
- if (which === "START") {
- if (rangeStartNodes[id])
- throw new Error("The return value of chunk can only be used once.");
- rangeStartNodes[id] = n;
-
- return null;
- }
- // else: which === "END"
-
- var startNode = rangeStartNodes[id];
- var endNode = n;
- var next = endNode.nextSibling;
-
- // try to remove comments
- var a = startNode, b = endNode;
- if (a.nextSibling && b.previousSibling) {
- if (a.nextSibling === b) {
- // replace two adjacent comments with one
- endNode = startNode;
- b.parentNode.removeChild(b);
- startNode.nodeValue = 'placeholder';
- } else {
- // remove both comments
- startNode = startNode.nextSibling;
- endNode = endNode.previousSibling;
- a.parentNode.removeChild(a);
- b.parentNode.removeChild(b);
- }
- } else {
- /* shouldn't happen; invalid HTML? */
+ var chunk = chunkMap[id];
+ if (chunk === "USED") {
+ // already used this chunk in this walk
+ throw new Error("The return value of chunk can only be used once.");
+ } else if (chunk) {
+ var frag = chunk._asFragment();
+ prepareFrag(frag, n.parentNode);
+ var next = frag.firstChild; // exists
+ n.parentNode.replaceChild(frag, n);
+ chunkMap[id] = "USED"; // mark as already used in this walk
+ return next;
}
- if (startNode.parentNode !== endNode.parentNode) {
- // Try to fix messed-up comment ranges like
- // ... ,
- // which are extremely common with tables. Tests
- // fail in all browsers without this code.
- if (startNode === endNode.parentNode ||
- startNode === endNode.parentNode.previousSibling) {
- startNode = endNode.parentNode.firstChild;
- } else if (endNode === startNode.parentNode ||
- endNode === startNode.parentNode.nextSibling) {
- endNode = startNode.parentNode.lastChild;
- } else {
- var r = new RegExp('', 'g');
- var match = r.exec(html);
- var help = "";
- if (match) {
- var comment_end = r.lastIndex;
- var comment_start = comment_end - match[0].length;
- var stripped_before = html.slice(0, comment_start).replace(
- //g, '');
- var stripped_after = html.slice(comment_end).replace(
- //g, '');
- var context_amount = 50;
- var context = stripped_before.slice(-context_amount) +
- stripped_after.slice(0, context_amount);
- help = " (possible unclosed near: "+context+")";
- }
- throw new Error("Could not create liverange in template. "+
- "Check for unclosed tags in your HTML."+help);
- }
- }
-
- var range = new Meteor.ui._LiveRange(Meteor.ui._tag, startNode, endNode);
- var chunk = newChunksById[id];
- if (chunk) {
- chunk._gainRange(range);
- materializedChunks.push(chunk);
- }
-
- return next;
+ return null;
});
- newChunksById = {};
-
- if (chunkCallback)
- _.each(materializedChunks, chunkCallback);
-
return frag;
};
@@ -813,4 +743,20 @@ Meteor.ui = Meteor.ui || {};
}
};
+ //////////////////// OTHER SUPPORT
+
+ var prepareFrag = function(frag, container) {
+ // Table-body fix: if tgtRange is in a table and srcParent
+ // contains a TR, wrap fragment in a TBODY on all browsers,
+ // so that it will display properly in IE.
+ if (container.nodeName === "TABLE" &&
+ _.any(frag.childNodes,
+ function(n) { return n.nodeName === "TR"; })) {
+ var tbody = document.createElement("TBODY");
+ while (frag.firstChild)
+ tbody.appendChild(frag.firstChild);
+ frag.appendChild(tbody);
+ }
+ };
+
})();
diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js
index 6f108e966e..656bbcec74 100644
--- a/packages/liveui/liveui_tests.js
+++ b/packages/liveui/liveui_tests.js
@@ -713,11 +713,10 @@ Tinytest.add("liveui - chunks", function(test) {
});
return "";
});
- test.equal(Q.numListeners(), 1);
Q.set("bar");
- // flush() should invalidate the unused
- // chunk but not assume it has been wired
- // up with a LiveRange.
+ // might get an error on flush() if implementation
+ // deals poorly with unused chunks, or a listener
+ // still existing after flush.
Meteor.flush();
test.equal(Q.numListeners(), 0);
From aafb5978b48d472580a2b72e0c1c2c7cf8534abb Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Fri, 29 Jun 2012 22:54:23 -0700
Subject: [PATCH 006/212] LiveUI rewrite passes all tests, adds callbacks
---
packages/liveui/liverange.js | 2 +-
packages/liveui/liveui.js | 986 ++++++++++++---------------
packages/liveui/liveui_tests.js | 4 +-
packages/test-helpers/onscreendiv.js | 5 +-
4 files changed, 443 insertions(+), 554 deletions(-)
diff --git a/packages/liveui/liverange.js b/packages/liveui/liverange.js
index c28e52d12c..f326758a4e 100644
--- a/packages/liveui/liverange.js
+++ b/packages/liveui/liverange.js
@@ -243,7 +243,7 @@ Meteor.ui = Meteor.ui || {};
this._remove_entries(this._start, 0, this._start_idx);
this._remove_entries(this._end, 1, 0, this._end_idx + 1);
-
+
if (this._start !== this._end) {
// force-clean the top-level nodes in this, besides _start and _end
for(var n = this._start.nextSibling;
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index ca061f48c4..6e45bed12d 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -11,14 +11,28 @@ Meteor.ui = Meteor.ui || {};
if (Meteor.ui._inRenderMode)
throw new Error("Can't nest Meteor.ui.render.");
- return new Chunk(html_func, options)._asFragment();
+ return renderChunk(html_func, options, "fragment").containerNode();
};
Meteor.ui.chunk = function(html_func, options) {
if (typeof html_func !== "function")
throw new Error("Meteor.ui.chunk() requires a function as its first argument.");
- return new Chunk(html_func, options)._asHtml();
+ if (Materializer.current)
+ return Materializer.current.placeholder(function(comment) {
+ // wrap a new LiveRange around the comment, inside any
+ // existing LiveRanges
+ var range = new Meteor.ui._LiveRange(
+ Meteor.ui._tag, comment, comment, true);
+ // replace, don't patch, the placeholder comment
+ renderChunk(html_func, options, "replace", range);
+ });
+
+ // not inside Meteor.ui.render, just return the full HTML
+ var html = html_func(options && options.data);
+ if (typeof html !== "string")
+ throw new Error("Render function must return a string");
+ return html;
};
Meteor.ui.listChunk = function (observable, doc_func, else_func, options) {
@@ -35,24 +49,18 @@ Meteor.ui = Meteor.ui || {};
else_func = (typeof else_func === "function" ? else_func :
function() { return ""; });
- // State: Keeping track of our child chunks.
- // At any time, if the list is empty, then docChunks is [] and
- // elseChunk is a chunk; otherwise, docChunks is a list with a
- // chunk for each document, and elseChunk is null.
- var docChunks = [];
- var elseChunk = new Chunk(else_func);
- // The outer chunk that contains the other chunks and handles the
- // updates. We don't create the outer chunk until after we have
- // called observable.observe(...) and handled the first wave of
- // updates.
- var outerChunk = null;
-
- // Queue updates due to observe callbacks, to process at flush time
- // when outerChunk's onupdate() fires.
+ var initialDocs = [];
var queuedUpdates = [];
+ var outerRange = null;
+ var itemRanges = null;
+
var enqueue = function(f) {
+ // if we are onscreen and this is the first func
+ // in the queue, schedule runQueuedUpdates.
+ if (outerRange && ! queuedUpdates.length)
+ Sarge.whenOnscreen(outerRange, runQueuedUpdates);
+
queuedUpdates.push(f);
- outerChunk && outerChunk.update();
};
var runQueuedUpdates = function() {
_.each(queuedUpdates, function(qu) { qu(); });
@@ -60,488 +68,457 @@ Meteor.ui = Meteor.ui || {};
};
// Helper to insert a fragment into the document based on
- // document chunk index.
+ // item index.
var insertFrag = function(frag, i) {
- if (i === docChunks.length)
- docChunks[i-1]._range.insert_after(frag);
+ if (i === itemRanges.length)
+ itemRanges[i-1].insert_after(frag);
else
- docChunks[i]._range.insert_before(frag);
+ itemRanges[i].insert_before(frag);
};
- // Register our data callbacks on the observable.
- //
- // The initial state of the list will be set by callbacks that
- // fire right away, typically (or always?) a sequence of "added"
- // calls. Since there is no outerChunk yet, we distinguish
- // this case by outerChunk being falsy.
- //
- // Callbacks are responsible for maintaining the docChunks/elseChunk
- // state, manipulating the DOM as appropriate, and calling kill()
- // on chunks that are removed from the DOM so that they can be
- // cleaned up immediately. Using enqueue(...), they defer action
- // until outerChunk.onupdate() is called.
var handle = observable.observe({
added: function(doc, before_idx) {
- enqueue(function() {
- var addedChunk = new Chunk(doc_func, {data: doc});
-
- if (outerChunk) {
- var frag = addedChunk._asFragment();
- if (elseChunk)
- // else case -> one item
- outerChunk._range.replace_contents(frag);
- else
- insertFrag(frag, before_idx);
+ if (! handle)
+ initialDocs.splice(before_idx, 0, doc);
+ else enqueue(function() {
+ var oldRange, mode;
+ if (itemRanges.length === 0) {
+ oldRange = outerRange;
+ mode = "inside";
+ } else if (before_idx === itemRanges.length) {
+ oldRange = itemRanges[itemRanges.length - 1];
+ mode = "after";
+ } else {
+ oldRange = itemRanges[before_idx];
+ mode = "before";
}
+ var range = renderChunk(doc_func, {data: doc}, mode, oldRange);
- elseChunk && elseChunk.kill();
- elseChunk = null;
- docChunks.splice(before_idx, 0, addedChunk);
+ itemRanges.splice(before_idx, 0, range);
});
},
removed: function(doc, at_idx) {
- enqueue(function() {
- if (outerChunk) {
- if (docChunks.length === 1) {
- // one item -> else case
- elseChunk = new Chunk(else_func);
- var frag = elseChunk._asFragment();
- outerChunk._range.replace_contents(frag);
- } else {
- // remove item
- var removedChunk = docChunks[at_idx];
- removedChunk._range.extract();
- }
- }
+ if (! handle)
+ initialDocs.splice(at_idx, 1);
+ else enqueue(function() {
+ var range;
+ if (itemRanges.length === 1)
+ range = renderChunk(else_func, "inside", outerRange);
+ else
+ Sarge.shuck(itemRanges[at_idx].extract());
- docChunks.splice(at_idx, 1)[0].kill();
+ itemRanges.splice(at_idx, 1);
});
},
moved: function(doc, old_idx, new_idx) {
- enqueue(function() {
- if (old_idx === new_idx)
- return;
+ if (old_idx === new_idx)
+ return;
- var movedChunk = docChunks[old_idx];
- var frag;
- if (outerChunk) {
- // We know the list has at least two items,
- // at old_idx and new_idx, so `extract` will
- // succeed.
- var frag = movedChunk._range.extract();
- // remove chunk from list at old index
- }
- docChunks.splice(old_idx, 1);
+ if (! handle)
+ initialDocs.splice(new_idx, 0,
+ initialDocs.splice(old_idx, 1)[0]);
+ else enqueue(function() {
+ // We know the list has at least two items,
+ // at old_idx and new_idx, so `extract` will
+ // succeed.
+ var frag = itemRanges[old_idx].extract();
+ var range = itemRanges.splice(old_idx, 1)[0];
+ if (new_idx === itemRanges.length)
+ itemRanges[itemRanges.length - 1].insert_after(frag);
+ else
+ itemRanges[new_idx].insert_before(frag);
- if (outerChunk)
- insertFrag(frag, new_idx);
-
- // insert chunk into list at new index
- docChunks.splice(new_idx, 0, movedChunk);
+ itemRanges.splice(new_idx, 0, range);
});
},
changed: function(doc, at_idx) {
- enqueue(function() {
- var chunk = docChunks[at_idx];
- // set the chunk's data, which determines the argument
- // to doc_func.
- chunk._data = doc;
- if (outerChunk)
- chunk.update();
+ if (! handle)
+ initialDocs[at_idx] = doc;
+ else enqueue(function() {
+ renderChunk(doc_func, {data: doc}, "patch", itemRanges[at_idx]);
});
}
});
- // Process the updates generated by the initial observe(...).
- runQueuedUpdates();
-
- // Create the outer chunk by calculating the appropriate HTML
- // and passing in the options we were given.
- outerChunk = new Chunk(function() {
- return _.map(
- (elseChunk ? [elseChunk] : docChunks),
- function(ch) { return ch._asHtml(); }).join('');
- }, options);
-
- // Override the normal behavior on update, which is to
- // recalculate the HTML and diff/patch the DOM.
- // Instead, we just run the incremental update functions
- // we've queued.
- outerChunk.onupdate = function() {
- runQueuedUpdates();
- };
-
- // Finalizer: when chunk is cleaned up, kill the observer handle.
- // This will happen even if the chunk is never used or listChunk
- // wasn't called inside render, as all Chunks are eventually
- // finalized after _asHtml() is called.
- outerChunk.onkill = function() {
+ // if not reactive, release the query handle
+ if (! Materializer.current)
handle.stop();
- };
- return outerChunk._asHtml();
- };
+ // XXX support more/different public callbacks than
+ // the normal created/onscreen/offscreen?
- //////////////////// CHUNK OBJECT
+ var originalOnscreen = options && options.onscreen;
+ var originalOffscreen = options && options.offscreen;
- // A Chunk object ties together the following:
- //
- // - A function returning HTML (html_func -> self._calculate())
- // - A LiveRange (self._range) in the DOM
- // - A data object (options.data -> self._data)
- // - Event handlers (options.events -> self._eventHandlers)
- // - A rolling deps context for invalidation (self._context)
- // - A message queue for taking actions at flush time
- //
- // Since context invalidations are deferred until "flush time" by
- // Meteor.deps, it would be confusing at all levels if we sometimes
- // updated the DOM at other times. Flush time is also the point at
- // which we can kill a chunk that is found to be offscreen (or was
- // never materialized as DOM). Because of this, we defer all actions
- // until flush time via self._send(...).
- //
- // A newly-instantiated Chunk object is in an initial, "uncalculated"
- // state, with no HTML or DOM generated yet, and no LiveRange. The
- // next step is to call either _asHtml() or _asFragment() to get
- // initial HTML or a complete reactive fragment for the chunk.
- // Once one of these methods is called, the chunk is guaranteed to
- // be visited at flush time (via the message queue), when it will
- // either survive, if it received a LiveRange and was added to the
- // document, or be killed.
-
- var Chunk = Meteor.ui._Chunk = function(html_func, options) {
- var self = this;
-
- options = options || {};
-
- self._range = null;
- self._calculate = function() {
- var html = html_func(this._data);
-
- if (typeof html !== "string")
- throw new Error("Render function must return a string");
-
- return html;
- };
- self._msgs = [];
- self._msgCx = null;
- self._data = (options.data || options.event_data || null); // XXX
- self._eventHandlers =
- options.events ? unpackEventMap(options.events) : null;
- self._killed = false;
- self._context = null;
-
- // Allow Meteor.deps to signal us about a data change by
- // invalidating self._context. By the time we see the
- // invalidation, it's flush time. We immediately set up
- // a new context for next time.
- // Always having the latest context in an instance variable
- // makes clean-up easier.
- var ondirty = function() {
- self._send("update");
- self._context = new Meteor.deps.Context;
- self._context.on_invalidate(ondirty);
- };
- self._context = new Meteor.deps.Context;
- self._context.on_invalidate(ondirty);
-
- // use original Context's unique id as our Chunk's unique id
- self.id = self._context.id;
- };
-
- // Returns HTML for this newly-created chunk, annotated with
- // comments containing the chunk's ID if we are in render mode.
- // The HTML is determined by calling self._calculate().
- Chunk.prototype._asHtml = function() {
- var self = this;
-
- if (! Meteor.ui._inRenderMode) {
- // no reactivity possible, so kill the chunk (on next flush)
- self.kill();
- return self._calculate();
- } else {
- var id = self.id;
- newChunksById[id] = self;
- return "";
- }
- };
-
- // Returns a reactive fragment for this newly-created chunk
- // by materializing the result of self._asHtml().
- Chunk.prototype._asFragment = function() {
- var self = this;
- var frag = materialize(
- function() {
- return self._context.run(function() {
- return self._calculate();
- });
- });
- self._gainRange(new Meteor.ui._LiveRange(Meteor.ui._tag, frag));
- // Events will be wired at flush time anyway, but the developer might
- // expect them to be present immediately for some reason. Unit tests
- // rely on this too.
- wireEvents(self);
- // Indicate that we are at the root of a render.
- self._send("render");
- return frag;
- };
-
- // Called upon materialization of the chunk's HTML into DOM,
- // marking the point where we have a LiveRange.
- Chunk.prototype._gainRange = function(range) {
- var self = this;
- self._range = range;
- range.chunk = self;
- // Start the message queue. Handling this message will cause
- // an offscreen check and potentially kill the chunk
- // if it never got used.
- self._send("added");
- };
-
- // Callback to update or re-render this chunk in the DOM.
- // Always called inside the chunk's dependency context.
- Chunk.prototype.onupdate = function() {
- // Default behavior on update is to recalculate the HTML
- // and patch the new DOM into place.
-
- var self = this;
- var frag = materialize(function() {
- return self._calculate();
- });
-
- // DIFF/PATCH
-
- var range = self._range;
-
- prepareFrag(frag, range.containerNode());
-
- // Since we are patching from a source DOM with LiveRanges onto
- // a clean target DOM, when we decide to keep a node from the
- // target DOM we need to "transplant" (copy) the LiveRange data
- // from the source node.
- var copyFunc = function(t, s) {
- Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
- };
-
- range.operate(function(start, end) {
- // clear all LiveRanges on target
- cleanup_range(new Meteor.ui._LiveRange(Meteor.ui._tag, start, end));
-
- var patcher = new Meteor.ui._Patcher(
- start.parentNode, frag,
- start.previousSibling, end.nextSibling);
- patcher.diffpatch(copyFunc);
- });
-
- // Indicate that we are at the root of a re-render.
- self._send("render");
- };
-
- // Internal mechanism to enqueue a named message for this chunk,
- // to be processed at flush time.
- Chunk.prototype._send = function(message) {
- var self = this;
-
- self._msgs.push(message);
-
- var processMessage = function(msg) {
- if (self._killed)
- return;
-
- // If chunk is not onscreen at flush time, any message
- // is treated like "kill". All future messages will be
- // ignored.
- if (msg === "kill" || (! self._range) || _checkOffscreen(self._range)) {
- // Pronounce this chunk dead. We rely on this finalization to clean
- // up the deps context, which is first created in the constructor.
- // There are many ways for a chunk to die -- never rendered, never
- // added to the DOM, removed as part of an update, removed
- // surreptitiously -- but all roads lead here.
- self._range = null; // can't count on LiveRange in onkill handler
- self._killed = true;
- self._context.invalidate();
- self._context = null;
- self.onkill && self.onkill();
- } else if (msg === "added") {
- // This chunk is part of the document for the first time.
- wireEvents(self);
- } else if (msg === "update") {
- // Rerender this chunk in place, in whole or in part.
- self._context.run(function() {
- self.onupdate();
- });
- } else if (msg === "render") {
- // This chunk is the root of a Meteor.ui.render or a reactive
- // update. Its descendent nodes are (most likely) new to the
- // document.
- wireEvents(self, true);
+ return Meteor.ui.chunk(function() {
+ if (initialDocs.length) {
+ return _.map(initialDocs, function(doc) {
+ return Meteor.ui.chunk(doc_func, {data: doc});
+ }).join('');
+ } else {
+ return Meteor.ui.chunk(else_func);
}
- };
-
- // schedule message to be processed at flush time
- if (! self._msgCx) {
- var cx = new Meteor.deps.Context;
- cx.on_invalidate(function() {
- self._msgCx = null;
- var msgs = self._msgs;
- self._msgs = [];
-
- _.each(msgs, processMessage);
- });
- cx.invalidate();
- self._msgCx = cx;
- };
+ }, _.extend({}, options, {
+ onscreen: function (start, end, range) {
+ outerRange = range;
+ itemRanges = [];
+ if (initialDocs.length) {
+ range.visit(function (is_start, r) {
+ is_start && itemRanges.push(r);
+ return false;
+ });
+ }
+ runQueuedUpdates();
+ originalOnscreen && originalOnscreen.call(this, start, end, range);
+ },
+ offscreen: function() {
+ handle.stop();
+ originalOffscreen && originalOffscreen.call(this);
+ }
+ }));
};
- // Kills this chunk. Safe to call at any time from anywhere.
- Chunk.prototype.kill = function() {
- // schedule killing for flush time.
- if (! this._killed)
- this._send("kill");
- };
-
- // Updates this chunk, as if a data dependency changed.
- Chunk.prototype.update = function() {
- // we'll get an "update" message at flush time.
- this._context.invalidate();
- };
-
- // Returns an array of immediate descendent chunks in the chunk
- // hierarchy.
- Chunk.prototype.childChunks = function() {
- if (! this._range)
- throw new Error("Chunk not rendered yet");
-
- var chunks = [];
- this._range.visit(function(is_start, r) {
- if (! is_start)
- return false;
- if (! r.chunk)
- return true; // allow for intervening LiveRanges
- chunks.push(r.chunk);
- return false;
- });
-
- return chunks;
- };
-
- // Returns this chunk's enclosing chunk in the hierarchy, if
- // any, or null.
- Chunk.prototype.parentChunk = function() {
- if (! this._range)
- throw new Error("Chunk not rendered yet");
-
- for(var r = this._range.findParent(); r; r = r.findParent())
- if (r.chunk)
- return r.chunk;
-
- return null;
- };
-
- // Finds the innermost enclosing chunk of a DOM node, if any, or
- // returns null.
- Meteor.ui._findChunk = function(node) {
- var range = Meteor.ui._LiveRange.findRange(Meteor.ui._tag, node);
-
- for(var r = range; r; r = r.findParent())
- if (r.chunk)
- return r.chunk;
-
- return null;
- };
-
- //////////////////// MATERIALIZATION (HTML -> DOM)
+ //////////////////// RENDERCHUNK
Meteor.ui._tag = "_liveui";
- Meteor.ui._inRenderMode = false;
- var newChunksById = {}; // id -> chunk
- // Materializes HTML into DOM nodes and chunks.
- //
- // Calls calcHtml() in "render mode". In render mode,
- // chunks register themselves in the newChunksById map
- // when they are converted into HTML and produce
- // HTML comments marking where the chunks begin and
- // end. We use those comments to create LiveRanges
- // and associate them with the chunks.
- //
- // Once the comments are found and the new chunks are
- // given LiveRanges, we call chunkCallback on each one,
- // and then return a DocumentFragment of the materialized
- // DOM.
- var materialize = function(calcHtml) {
+ var renderChunk = function(html_func, options, mode, oldRange) {
+ if (typeof options === "string") {
+ // support (html_func, mode, oldRange) form of arguments
+ oldRange = mode;
+ mode = options;
+ options = {};
+ }
+ options = options || {};
- Meteor.ui._inRenderMode = true;
+ // XXX temporary backwards compatibility
+ if (options.event_data)
+ options.data = options.event_data;
- var html;
- try {
- html = calcHtml();
- } finally {
- Meteor.ui._inRenderMode = false;
+ var range;
+ var container;
+ if (mode === "inside" || mode === "before" || mode === "after") {
+ range = null;
+ container = oldRange.containerNode();
+ } else if (mode === "replace" || mode === "patch") {
+ range = oldRange;
+ container = oldRange.containerNode();
+ } else if (mode === "fragment") {
+ range = null;
+ container = null;
+ } else {
+ throw new Error("Unknown renderChunk mode "+mode);
}
- var chunkMap = newChunksById;
- newChunksById = {};
+ var cx = new Meteor.deps.Context;
+ var frag = cx.run(function() {
+ return (new Materializer(container)).toDOM(function() {
+ var html = html_func(options.data);
+ if (typeof html !== "string")
+ throw new Error("Render function must return a string");
+ return html;
+ });
+ });
- var frag = Meteor.ui._htmlToFragment(html);
- if (! frag.firstChild)
- frag.appendChild(document.createComment("empty"));
+ var callCreated = function() {
+ range.chunkState = {};
+ if (options.created) {
+ // call options.created with our chunkState in this
+ var ret = options.created.call(range.chunkState);
+ // developer can return their own object to use as
+ // chunkState instead.
+ if (ret)
+ range.chunkState = ret;
+ };
+ };
- var materializedChunks = [];
-
- // Helper that invokes `f` on every comment node under `parent`.
- // If `f` returns a node, visit that node next.
- var each_comment = function(parent, f) {
- for (var n = parent.firstChild; n;) {
- if (n.nodeType === 8) { // comment
- n = f(n) || n.nextSibling;
- continue;
- } else if (n.nodeType === 1) { // element
- each_comment(n, f);
- }
- n = n.nextSibling;
+ var callOnscreen = function() {
+ if (options.onscreen) {
+ var ret = options.onscreen.call(
+ range.chunkState, range.firstNode(), range.lastNode(), range);
+ if (ret)
+ range.chunkState = ret;
}
};
- // walk comments and insert chunks
- each_comment(frag, function(n) {
- var chunkCommentMatch = /^\s*CHUNK_(\S+)/.exec(n.nodeValue);
+ var callOffscreen = function() {
+ if (range.chunkState && options.offscreen)
+ options.offscreen.call(range.chunkState);
+ };
- if (! chunkCommentMatch)
- return null;
+ if (! range)
+ range = new Meteor.ui._LiveRange(Meteor.ui._tag, frag);
- var id = chunkCommentMatch[1];
+ // Table-body fix: if container is a table and frag
+ // contains a TR, wrap fragment in a TBODY on all browsers,
+ // so that it will display properly in IE.
+ if ((container && container.nodeName === "TABLE")
+ && _.any(frag.childNodes,
+ function(n) { return n.nodeName === "TR"; })) {
+ var tbody = document.createElement("TBODY");
+ while (frag.firstChild)
+ tbody.appendChild(frag.firstChild);
+ frag.appendChild(tbody);
+ }
- var chunk = chunkMap[id];
- if (chunk === "USED") {
- // already used this chunk in this walk
- throw new Error("The return value of chunk can only be used once.");
- } else if (chunk) {
- var frag = chunk._asFragment();
- prepareFrag(frag, n.parentNode);
- var next = frag.firstChild; // exists
- n.parentNode.replaceChild(frag, n);
- chunkMap[id] = "USED"; // mark as already used in this walk
- return next;
- }
- return null;
+ if (mode === "patch") {
+ // Rendering top level of the current update, with patching
+ range.operate(function(start, end) {
+ Sarge.shuck(start, end);
+
+ var patcher = new Meteor.ui._Patcher(
+ start.parentNode, frag,
+ start.previousSibling, end.nextSibling);
+ var copyFunc = function(t, s) {
+ Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
+ };
+ patcher.diffpatch(copyFunc);
+ });
+ } else if (mode === "replace") {
+ // Rendering a sub-chunk of the current update
+ Sarge.shuck(range.replace_contents(frag));
+ } else if (mode === "inside") {
+ Sarge.shuck(oldRange.replace_contents(frag));
+ } else if (mode === "before") {
+ oldRange.insert_before(frag);
+ } else if (mode === "after") {
+ oldRange.insert_after(frag);
+ } else if (mode === "fragment") {
+ // Rendering a fragment for Meteor.ui.render
+ }
+
+ Sarge.whenOnscreen(range, function() {
+ if (mode === "fragment")
+ wireEvents(range, true);
+ if (mode !== "patch") // XXX revisit when patching chunks
+ callCreated();
+ callOnscreen();
});
- return frag;
+ range.data = options.data;
+ if (options.events)
+ range.eventHandlers = unpackEventMap(options.events);
+ wireEvents(range, mode !== "fragment");
+
+ // in case we are patching an existing, valid range
+ range.context && range.context.invalidate();
+
+ range.context = cx;
+ range.update = function() {
+ var self = this;
+ Sarge.whenOnscreen(self, function() {
+ renderChunk(html_func, options, "patch", this);
+ });
+ };
+ range.destroy = function() {
+ range.context && range.context.invalidate();
+ callOffscreen();
+ };
+
+ cx.on_invalidate(function() {
+ if (range.context === cx) // make sure not an old cx
+ range.update();
+ });
+
+ return range;
};
- //////////////////// CHUNK EVENT SUPPORT
+ //////////////////// MATERIALIZER
- var wireEvents = function(chunk, andEnclosing) {
- // Attach events to top-level nodes in `chunk` as specified
+ // XXX check order of invalidate at multiple levels?
+
+ var Materializer = function () {
+ this.nextCommentId = 1;
+ this.replaceFuncs = {};
+ };
+ Materializer.current = null;
+
+ _.extend(Materializer.prototype, {
+ // Calls htmlFunc() with the current Materializer used to
+ // record comment placeholders for fragments.
+ toDOM: function (htmlFunc) {
+ var self = this;
+
+ // run htmlFunc with self as Materializer.current
+ var previous = Materializer.current;
+ Materializer.current = self;
+ try { var html = htmlFunc(); }
+ finally { Materializer.current = previous; }
+
+ var frag = Meteor.ui._htmlToFragment(html);
+ // empty frag becomes HTML comment
+ if (! frag.firstChild)
+ frag.appendChild(document.createComment("empty"));
+
+ // Helper that invokes `f` on every comment node under `parent`.
+ // If `f` returns a node, visit that node next.
+ var each_comment = function(parent, f) {
+ for (var n = parent.firstChild; n;) {
+ if (n.nodeType === 8) { // COMMENT
+ n = (f(n) || n.nextSibling);
+ continue;
+ }
+ if (n.nodeType === 1) // ELEMENT
+ each_comment(n, f); // recurse
+ n = n.nextSibling;
+ }
+ };
+
+ var alreadyCalled = function () {
+ throw new Error("Can't include the same chunk in multiple places.");
+ };
+
+ each_comment(frag, function(comment) {
+ var commentValue = comment.nodeValue;
+ var replaceFunc = self.replaceFuncs[commentValue];
+ if (! replaceFunc)
+ return null; // some other
+
+ var precomment = (comment.previousSibling || null);
+ var parent = comment.parentNode;
+
+ replaceFunc(comment);
+ // plant a bomb to catch any duplicate chunk
+ self.replaceFuncs[commentValue] = alreadyCalled;
+
+ return precomment ? precomment.nextSibling : parent.firstChild;
+ });
+
+ return frag;
+ },
+
+ // Returns a new placeholder HTML comment as a string.
+ // When this comment materializes, replaceFunc will be
+ // called on it to replace it.
+ placeholder: function (replaceFunc) {
+ var commentValue = "CHUNK_"+(this.nextCommentId++);
+ this.replaceFuncs[commentValue] = replaceFunc;
+ return "";
+ }
+
+ });
+
+ //////////////////// DOM SARGE (coordinates entry, exit, and signaling)
+
+ var Sarge = Meteor.ui._Sarge = {
+
+ // Call f with (this === range) the next time we see range
+ // alive and onscreen at flush time, if ever.
+ whenOnscreen: function(range, f) {
+ Sarge.atFlushTime(function() {
+ if (! Sarge.checkOffscreen(range))
+ f.call(range);
+ });
+ },
+
+ // Remove all LiveRanges on the range of nodes from start to end,
+ // properly disposing of any referenced chunks and cleaning the
+ // nodes. May be called as shuck(fragment) or shuck(node) as well.
+ shuck: function (start, end) {
+ var wrapper = new Meteor.ui._LiveRange(Meteor.ui._tag, start, end);
+ wrapper.visit(function (is_start, range) {
+ is_start && Sarge.killRange(range);
+ });
+ wrapper.destroy(true);
+ },
+
+ // Mark a single range as killed and call its finalizer.
+ killRange: function(range) {
+ if (! range.killed) {
+ range.killed = true;
+ // only one of these ever scheduled per range:
+ Sarge.atFlushTime(function() {
+ range.destroy && range.destroy();
+ });
+ }
+ },
+
+ // Call f() at next flush time. If it's already flush time,
+ // f will be added to the queue and called later in this
+ // flush.
+ atFlushTime: function (f) {
+ var cx = new Meteor.deps.Context;
+ cx.on_invalidate(function() { return f(); });
+ cx.invalidate();
+ },
+
+ // If range is offscreen, kill it and shuck the whole DOM tree.
+ // Returns true if the range is killed or already dead.
+ checkOffscreen: function(range) {
+ if (range.killed)
+ return true;
+
+ var node = range.firstNode();
+
+ if (node.parentNode &&
+ (Sarge.isNodeOnscreen(node) || Sarge.isNodeHeld(node)))
+ return false;
+
+ while (node.parentNode)
+ node = node.parentNode;
+
+ Sarge.shuck(node.firstChild, node.lastChild);
+
+ return true;
+ },
+
+ // Check whether a node is contained in the document.
+ isNodeOnscreen: function (node) {
+ // http://jsperf.com/is-element-in-the-dom
+
+ if (document.compareDocumentPosition)
+ return document.compareDocumentPosition(node) & 16;
+ else {
+ if (node.nodeType !== 1 /* Element */)
+ /* contains() doesn't work reliably on non-Elements. Fine on
+ Chrome, not so much on Safari and IE. */
+ node = node.parentNode;
+ if (node.nodeType === 11 /* DocumentFragment */ ||
+ node.nodeType === 9 /* Document */)
+ /* contains() chokes on DocumentFragments on IE8 */
+ return node === document;
+ /* contains() exists on document on Chrome, but only on
+ document.body on some other browsers. */
+ return document.body.contains(node);
+ }
+ },
+
+ // Internal facility, only used by tests, for holding onto
+ // DocumentFragments across flush(). Does ref-counting
+ // using hold() and release().
+ holdFrag: function (frag) {
+ frag._liveui_refs = (frag._liveui_refs || 0) + 1;
+ },
+ releaseFrag: function (frag) {
+ // Clean up on flush, if hits 0. Wait to decrement
+ // so no one else cleans it up first.
+ Sarge.atFlushTime(function() {
+ // now decrement
+ --frag._liveui_refs;
+ if (! frag._liveui_refs)
+ Sarge.shuck(frag);
+ });
+ },
+ isNodeHeld: function (node) {
+ while (node.parentNode)
+ node = node.parentNode;
+
+ return node.nodeType !== 3 /*TEXT_NODE*/ && node._liveui_refs;
+ }
+ };
+
+ //////////////////// EVENT SUPPORT
+
+ var wireEvents = function(range, andEnclosing) {
+ // Attach events to top-level nodes in `range` as specified
// by its event handlers.
//
- // If `andEnclosing` is true, we also walk up the chunk
+ // If `andEnclosing` is true, we also walk up the range
// hierarchy looking for event types we need to handle
- // based on handlers in ancestor chunks. This is necessary
- // when a chunk is updated or a rendered fragment is added
- // to the DOM -- basically, when a chunk acquires ancestors.
+ // based on handlers in ancestor ranges. This is necessary
+ // when a range is updated or a rendered fragment is added
+ // to the DOM -- basically, when a range acquires ancestors.
//
// In modern browsers (all except IE <= 8), this level of
// subtlety is not actually required, because the implementation
@@ -549,14 +526,14 @@ Meteor.ui = Meteor.ui || {};
// per type globally on the document. However, the Old IE impl
// takes advantage of it.
- var range = chunk._range;
-
- for(var c = chunk; c; c = c.parentChunk()) {
- var handlers = c._eventHandlers;
+ var innerRange = range;
+ for(range = innerRange; range; range = range.findParent()) {
+ var handlers = range.eventHandlers;
if (handlers) {
_.each(handlers.types, function(t) {
- for(var n = range.firstNode(), after = range.lastNode().nextSibling;
+ for(var n = innerRange.firstNode(),
+ after = innerRange.lastNode().nextSibling;
n && n !== after;
n = n.nextSibling)
Meteor.ui._event.registerEventType(t, n);
@@ -569,7 +546,7 @@ Meteor.ui = Meteor.ui || {};
};
// Convert an event map from the developer into an internal
- // format for chunk._eventHandlers. The internal format is
+ // format for range.eventHandlers. The internal format is
// an array of objects with properties {type, selector, callback}.
// The array has an expando property `types`, which is a list
// of all the unique event types used (as an optimization for
@@ -611,12 +588,12 @@ Meteor.ui = Meteor.ui || {};
if (! curNode)
return;
- var innerChunk = Meteor.ui._findChunk(curNode);
+ var innerRange = Meteor.ui._LiveRange.findRange(Meteor.ui._tag, curNode);
var type = event.type;
- for(var chunk = innerChunk; chunk; chunk = chunk.parentChunk()) {
- var event_handlers = chunk._eventHandlers;
+ for(var range = innerRange; range; range = range.findParent()) {
+ var event_handlers = range.eventHandlers;
if (! event_handlers)
continue;
@@ -628,7 +605,7 @@ Meteor.ui = Meteor.ui || {};
var selector = h.selector;
if (selector) {
- var contextNode = chunk._range.containerNode();
+ var contextNode = range.containerNode();
var results = $(contextNode).find(selector);
if (! _.contains(results, curNode))
continue;
@@ -638,10 +615,10 @@ Meteor.ui = Meteor.ui || {};
continue;
}
- var event_data = findEventData(event.currentTarget);
+ var eventData = findEventData(event.currentTarget);
// Call the app's handler/callback
- var returnValue = h.callback.call(event_data, event);
+ var returnValue = h.callback.call(eventData, event);
// allow app to `return false` from event handler, just like
// you can in a jquery event handler
@@ -658,11 +635,11 @@ Meteor.ui = Meteor.ui || {};
// find the innermost enclosing liverange that has event data
var findEventData = function(node) {
- var innerChunk = Meteor.ui._findChunk(node);
+ var innerRange = Meteor.ui._LiveRange.findRange(Meteor.ui._tag, node);
- for(var chunk = innerChunk; chunk; chunk = chunk.parentChunk())
- if (chunk._data)
- return chunk._data;
+ for(var range = innerRange; range; range = range.findParent())
+ if (range.data)
+ return range.data;
return null;
};
@@ -670,93 +647,4 @@ Meteor.ui = Meteor.ui || {};
Meteor.ui._event.setHandler(handleEvent);
- //////////////////// OFFSCREEN CHECKING AND CLEANUP
-
- // Cleans up a range and its descendant ranges by killing
- // any attached chunks (which removes the associated contexts
- // from dependency tracking) and then destroying the LiveRanges
- // (which removes the liverange data from the DOM).
- var cleanup_range = function(range) {
- range.visit(function(is_start, range) {
- if (is_start)
- range.chunk && range.chunk.kill();
- });
- range.destroy(true);
- };
-
- var _checkOffscreen = function(range) {
- var node = range.firstNode();
-
- if (node.parentNode &&
- (Meteor.ui._onscreen(node) || Meteor.ui._is_held(node)))
- return false;
-
- cleanup_range(range);
-
- return true;
- };
-
- // Internal facility, only used by tests, for holding onto
- // DocumentFragments across flush(). Reference counts
- // using hold() and release().
- Meteor.ui._is_held = function(node) {
- while (node.parentNode)
- node = node.parentNode;
-
- return node.nodeType !== 3 /*TEXT_NODE*/ && node._liveui_refs;
- };
- Meteor.ui._hold = function(frag) {
- frag._liveui_refs = (frag._liveui_refs || 0) + 1;
- };
- Meteor.ui._release = function(frag) {
- // Clean up on flush, if hits 0.
- // Don't want to decrement
- // _liveui_refs to 0 now because someone else might
- // clean it up if it's not held.
- var cx = new Meteor.deps.Context;
- cx.on_invalidate(function() {
- --frag._liveui_refs;
- if (! frag._liveui_refs)
- // wrap the frag in a new LiveRange that will be destroyed
- cleanup_range(new Meteor.ui._LiveRange(Meteor.ui._tag, frag));
- });
- cx.invalidate();
- };
-
- Meteor.ui._onscreen = function (node) {
- // http://jsperf.com/is-element-in-the-dom
-
- if (document.compareDocumentPosition)
- return document.compareDocumentPosition(node) & 16;
- else {
- if (node.nodeType !== 1 /* Element */)
- /* contains() doesn't work reliably on non-Elements. Fine on
- Chrome, not so much on Safari and IE. */
- node = node.parentNode;
- if (node.nodeType === 11 /* DocumentFragment */ ||
- node.nodeType === 9 /* Document */)
- /* contains() chokes on DocumentFragments on IE8 */
- return node === document;
- /* contains() exists on document on Chrome, but only on
- document.body on some other browsers. */
- return document.body.contains(node);
- }
- };
-
- //////////////////// OTHER SUPPORT
-
- var prepareFrag = function(frag, container) {
- // Table-body fix: if tgtRange is in a table and srcParent
- // contains a TR, wrap fragment in a TBODY on all browsers,
- // so that it will display properly in IE.
- if (container.nodeName === "TABLE" &&
- _.any(frag.childNodes,
- function(n) { return n.nodeName === "TR"; })) {
- var tbody = document.createElement("TBODY");
- while (frag.firstChild)
- tbody.appendChild(frag.firstChild);
- frag.appendChild(tbody);
- }
- };
-
})();
diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js
index 656bbcec74..84a4780170 100644
--- a/packages/liveui/liveui_tests.js
+++ b/packages/liveui/liveui_tests.js
@@ -51,10 +51,10 @@ WrappedFrag.prototype.html = function() {
return canonicalizeHtml(this.rawHtml());
};
WrappedFrag.prototype.hold = function() {
- return Meteor.ui._hold(this.frag), this;
+ return Meteor.ui._Sarge.holdFrag(this.frag), this;
};
WrappedFrag.prototype.release = function() {
- return Meteor.ui._release(this.frag), this;
+ return Meteor.ui._Sarge.releaseFrag(this.frag), this;
};
WrappedFrag.prototype.node = function() {
return this.frag;
diff --git a/packages/test-helpers/onscreendiv.js b/packages/test-helpers/onscreendiv.js
index 5500e9f072..b2b0629e44 100644
--- a/packages/test-helpers/onscreendiv.js
+++ b/packages/test-helpers/onscreendiv.js
@@ -50,8 +50,9 @@ OnscreenDiv.prototype.kill = function() {
var frag = document.createDocumentFragment();
frag.appendChild(this.div);
// instigate clean-up on next flush()
- Meteor.ui._hold(frag);
- Meteor.ui._release(frag);
+ Meteor.ui._Sarge.atFlushTime(function() {
+ Meteor.ui._Sarge.shuck(frag);
+ });
};
// remove the DIV from the document
From 0b1384473d2ed702b066f2984bdbccabcc586111 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 2 Jul 2012 18:53:43 -0700
Subject: [PATCH 007/212] tests and comments
---
packages/liveui/liveui.js | 4 +-
packages/liveui/liveui_tests.js | 65 ++++++++++++++++++++++++++++++---
2 files changed, 61 insertions(+), 8 deletions(-)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 6e45bed12d..049ba40791 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -20,8 +20,8 @@ Meteor.ui = Meteor.ui || {};
if (Materializer.current)
return Materializer.current.placeholder(function(comment) {
- // wrap a new LiveRange around the comment, inside any
- // existing LiveRanges
+ // Wrap a new LiveRange around the comment, which becomes
+ // the chunk's LiveRange.
var range = new Meteor.ui._LiveRange(
Meteor.ui._tag, comment, comment, true);
// replace, don't patch, the placeholder comment
diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js
index 84a4780170..445ebe83bf 100644
--- a/packages/liveui/liveui_tests.js
+++ b/packages/liveui/liveui_tests.js
@@ -25,6 +25,11 @@ ReactiveVar.prototype.get = function() {
};
ReactiveVar.prototype.set = function(newValue) {
+ // detect equality and don't invalidate dependers
+ // when value is a primitive.
+ if ((typeof newValue !== 'object') && this._value === newValue)
+ return;
+
this._value = newValue;
for(var id in this._deps)
@@ -736,6 +741,54 @@ Tinytest.add("liveui - chunks", function(test) {
test.equal(div.html(), "xhi");
div.kill();
Meteor.flush();
+
+ // more nesting
+
+ var num1 = ReactiveVar(false);
+ var num2 = ReactiveVar(false);
+ var num3 = ReactiveVar(false);
+ var numset = function(n) {
+ _.each([num1, num2, num3], function(v, i) {
+ v.set((i+1) === n);
+ });
+ };
+ numset(1);
+
+ var div = OnscreenDiv(Meteor.ui.render(function() {
+ return Meteor.ui.chunk(function() {
+ // This test was twiddled to try to catch a theorized bug
+ // around nested chunks with no surrounding nodes, but
+ // there was no bug. Feel free to twiddle again.
+ return (num1.get() ? '' : '')+
+ Meteor.ui.chunk(function() {
+ return (num2.get() ? '' : '')+
+ Meteor.ui.chunk(function() {
+ return (num3.get() ? '3' : '')+'x';
+ });
+ });
+ });
+ }));
+ test.equal(div.html(), "x");
+ numset(2);
+ Meteor.flush();
+ test.equal(div.html(), "x");
+ numset(3);
+ Meteor.flush();
+ test.equal(div.html(), "3x");
+ numset(1);
+ Meteor.flush();
+ test.equal(div.html(), "x");
+ numset(3);
+ Meteor.flush();
+ test.equal(div.html(), "3x");
+ numset(2);
+ Meteor.flush();
+ test.equal(div.html(), "x");
+ div.kill();
+ Meteor.flush();
+ test.equal(num1.numListeners(), 0);
+ test.equal(num2.numListeners(), 0);
+ test.equal(num3.numListeners(), 0);
});
Tinytest.add("liveui - repeated chunk", function(test) {
@@ -1980,10 +2033,10 @@ Tinytest.add("liveui - controls", function(test) {
// Textarea
- R = ReactiveVar("test");
+ R = ReactiveVar({x:"test"});
div = OnscreenDiv(Meteor.ui.render(function() {
return '';
+ R.get().x+'';
}));
div.show(true);
@@ -1992,13 +2045,13 @@ Tinytest.add("liveui - controls", function(test) {
test.equal(textarea.value, "This is a test");
// value updates reactively
- R.set("fridge");
+ R.set({x:"fridge"});
Meteor.flush();
test.equal(textarea.value, "This is a fridge");
// ...unless focused
focusElement(textarea);
- R.set("frog");
+ R.set({x:"frog"});
Meteor.flush();
test.equal(textarea.value, "This is a fridge");
@@ -2006,14 +2059,14 @@ Tinytest.add("liveui - controls", function(test) {
blurElement(textarea);
Meteor.flush();
test.equal(textarea.value, "This is a fridge");
- R.set("frog");
+ R.set({x:"frog"});
Meteor.flush();
test.equal(textarea.value, "This is a frog");
// Setting a value (similar to user typing) should
// not prevent value from being updated reactively.
textarea.value = "foobar";
- R.set("photograph");
+ R.set({x:"photograph"});
Meteor.flush();
test.equal(textarea.value, "This is a photograph");
From 096f8e078ac64d48de9fa32be08540c5e4f31a44 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 2 Jul 2012 19:01:35 -0700
Subject: [PATCH 008/212] rename smartpatch.js -> patcher.js
---
packages/liveui/package.js | 2 +-
packages/liveui/{smartpatch.js => patcher.js} | 0
2 files changed, 1 insertion(+), 1 deletion(-)
rename packages/liveui/{smartpatch.js => patcher.js} (100%)
diff --git a/packages/liveui/package.js b/packages/liveui/package.js
index 4037d01350..4893de698f 100644
--- a/packages/liveui/package.js
+++ b/packages/liveui/package.js
@@ -15,7 +15,7 @@ Package.on_use(function (api) {
api.add_files(['liveevents_w3c.js', 'liveevents_now3c.js'], 'client');
api.add_files(['liveevents.js'], 'client');
- api.add_files(['liverange.js', 'liveui.js', 'innerhtml.js', 'smartpatch.js'],
+ api.add_files(['liverange.js', 'liveui.js', 'innerhtml.js', 'patcher.js'],
'client');
});
diff --git a/packages/liveui/smartpatch.js b/packages/liveui/patcher.js
similarity index 100%
rename from packages/liveui/smartpatch.js
rename to packages/liveui/patcher.js
From bca449879b9090e0a1d6f0a4ca265b516b121ff5 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 2 Jul 2012 19:19:34 -0700
Subject: [PATCH 009/212] move diffpatch out of patcher into liveui
---
packages/liveui/liveui.js | 103 ++++++++++++++++++++++++++++++++++---
packages/liveui/patcher.js | 98 ++---------------------------------
2 files changed, 100 insertions(+), 101 deletions(-)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 049ba40791..6d13ad3dd4 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -267,13 +267,8 @@ Meteor.ui = Meteor.ui || {};
range.operate(function(start, end) {
Sarge.shuck(start, end);
- var patcher = new Meteor.ui._Patcher(
- start.parentNode, frag,
- start.previousSibling, end.nextSibling);
- var copyFunc = function(t, s) {
- Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
- };
- patcher.diffpatch(copyFunc);
+ diffpatch(start.parentNode, frag,
+ start.previousSibling, end.nextSibling);
});
} else if (mode === "replace") {
// Rendering a sub-chunk of the current update
@@ -646,5 +641,99 @@ Meteor.ui = Meteor.ui || {};
Meteor.ui._event.setHandler(handleEvent);
+ //////////////////// PATCHER
+
+ // Perform a complete patching where nodes with the same `id` or `name`
+ // are matched.
+ //
+ // Any node that has either an "id" or "name" attribute is considered a
+ // "labeled" node, and these labeled nodes are candidates for preservation.
+ // For two labeled nodes, old and new, to match, they first must have the same
+ // label (that is, they have the same id, or neither has an id and they have
+ // the same name). Labels are considered relative to the nearest enclosing
+ // labeled ancestor, and must be unique among the labeled nodes that share
+ // this nearest labeled ancestor. Labeled nodes are also expected to stay
+ // in the same order, or else some of them won't be matched.
+
+ var diffpatch = function(tgtParent, srcParent, tgtBefore, tgtAfter) {
+ var copyFunc = function(t, s) {
+ Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
+ };
+
+ var each_labeled_node = function(parent, before, after, func) {
+ for(var n = before ? before.nextSibling : parent.firstChild;
+ n && n !== after;
+ n = n.nextSibling) {
+
+ var label = null;
+
+ if (n.nodeType === 1) {
+ if (n.id) {
+ label = '#'+n.id;
+ } else if (n.getAttribute("name")) {
+ label = n.getAttribute("name");
+ // Radio button special case: radio buttons
+ // in a group all have the same name. Their value
+ // determines their identity.
+ // Checkboxes with the same name and different
+ // values are also sometimes used in apps, so
+ // we treat them similarly.
+ if (n.nodeName === 'INPUT' &&
+ (n.type === 'radio' || n.type === 'checkbox') &&
+ n.value)
+ label = label + ':' + n.value;
+ }
+ }
+
+ if (label)
+ func(label, n);
+ else
+ // not a labeled node; recurse
+ each_labeled_node(n, null, null, func);
+ }
+ };
+
+
+ var targetNodes = {};
+ var targetNodeOrder = {};
+ var targetNodeCounter = 0;
+
+ each_labeled_node(
+ tgtParent, tgtBefore, tgtAfter,
+ function(label, node) {
+ targetNodes[label] = node;
+ targetNodeOrder[label] = targetNodeCounter++;
+ });
+
+ var patcher = new Meteor.ui._Patcher(
+ tgtParent, srcParent, tgtBefore, tgtAfter);
+
+ var lastPos = -1;
+ each_labeled_node(
+ srcParent, null, null,
+ function(label, node) {
+ var tgt = targetNodes[label];
+ var src = node;
+ if (tgt && targetNodeOrder[label] > lastPos) {
+ if (patcher.match(tgt, src, copyFunc)) {
+ // match succeeded
+ if (tgt.firstChild || src.firstChild) {
+ // Don't patch contents of TEXTAREA tag,
+ // which are only the initial contents but
+ // may affect the tag's .value in IE.
+ if (tgt.nodeName !== "TEXTAREA") {
+ // recurse!
+ diffpatch(tgt, src);
+ }
+ }
+ }
+ lastPos = targetNodeOrder[label];
+ }
+ });
+
+ patcher.finish();
+
+ };
+
})();
diff --git a/packages/liveui/patcher.js b/packages/liveui/patcher.js
index df54b36cba..ea52a8a60e 100644
--- a/packages/liveui/patcher.js
+++ b/packages/liveui/patcher.js
@@ -10,17 +10,15 @@ Meteor.ui = Meteor.ui || {};
// of srcParent, which may be a DocumentFragment.
//
// To use a new Patcher, call `match` zero or more times followed by
-// `finish`. Alternatively, just call `diffpatch` to use the standard
-// matching strategy.
+// `finish`.
//
// A match is a correspondence between an old node in the target region
// and a new node in the source region that will replace it. Based on
// this correspondence, the target node is preserved and the attributes
// and children of the source node are copied over it. The `match`
-// method declares such a correspondence. The `diffpatch` method
-// determines the correspondences and calls `match` and `finish`.
-// A Patcher that makes no matches just removes the target nodes
-// and inserts the source nodes in their place.
+// method declares such a correspondence. A Patcher that makes no matches,
+// for example, just removes the target nodes and inserts the source nodes
+// in their place.
//
// Constructor:
Meteor.ui._Patcher = function(tgtParent, srcParent, tgtBefore, tgtAfter) {
@@ -34,94 +32,6 @@ Meteor.ui._Patcher = function(tgtParent, srcParent, tgtBefore, tgtAfter) {
this.lastKeptSrcNode = null;
};
-// Perform a complete patching where nodes with the same `id` or `name`
-// are matched.
-//
-// Any node that has either an "id" or "name" attribute is considered a
-// "labeled" node, and these labeled nodes are candidates for preservation.
-// For two labeled nodes, old and new, to match, they first must have the same
-// label (that is, they have the same id, or neither has an id and they have
-// the same name). Labels are considered relative to the nearest enclosing
-// labeled ancestor, and must be unique among the labeled nodes that share
-// this nearest labeled ancestor. Labeled nodes are also expected to stay
-// in the same order, or else some of them won't be matched.
-
-Meteor.ui._Patcher.prototype.diffpatch = function(copyCallback) {
- var self = this;
-
- var each_labeled_node = function(parent, before, after, func) {
- for(var n = before ? before.nextSibling : parent.firstChild;
- n && n !== after;
- n = n.nextSibling) {
-
- var label = null;
-
- if (n.nodeType === 1) {
- if (n.id) {
- label = '#'+n.id;
- } else if (n.getAttribute("name")) {
- label = n.getAttribute("name");
- // Radio button special case: radio buttons
- // in a group all have the same name. Their value
- // determines their identity.
- // Checkboxes with the same name and different
- // values are also sometimes used in apps, so
- // we treat them similarly.
- if (n.nodeName === 'INPUT' &&
- (n.type === 'radio' || n.type === 'checkbox') &&
- n.value)
- label = label + ':' + n.value;
- }
- }
-
- if (label)
- func(label, n);
- else
- // not a labeled node; recurse
- each_labeled_node(n, null, null, func);
- }
- };
-
-
- var targetNodes = {};
- var targetNodeOrder = {};
- var targetNodeCounter = 0;
-
- each_labeled_node(
- self.tgtParent, self.tgtBefore, self.tgtAfter,
- function(label, node) {
- targetNodes[label] = node;
- targetNodeOrder[label] = targetNodeCounter++;
- });
-
- var lastPos = -1;
- each_labeled_node(
- self.srcParent, null, null,
- function(label, node) {
- var tgt = targetNodes[label];
- var src = node;
- if (tgt && targetNodeOrder[label] > lastPos) {
- if (self.match(tgt, src, copyCallback)) {
- // match succeeded
- if (tgt.firstChild || src.firstChild) {
- // Don't patch contents of TEXTAREA tag,
- // which are only the initial contents but
- // may affect the tag's .value in IE.
- if (tgt.nodeName !== "TEXTAREA") {
- // recurse with a new Patcher!
- var patcher = new Meteor.ui._Patcher(tgt, src);
- patcher.diffpatch(copyCallback);
- }
- }
- }
- lastPos = targetNodeOrder[label];
- }
- });
-
- self.finish();
-
-};
-
// Advances the patching process up to tgtNode in the target tree,
// and srcNode in the source tree. tgtNode will be preserved, with
From 4edb813e7166072cd3f5b5967e7d38b1ee740661 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 2 Jul 2012 19:50:57 -0700
Subject: [PATCH 010/212] prepare for chunk matching
---
packages/liveui/liveui.js | 16 ++++------------
1 file changed, 4 insertions(+), 12 deletions(-)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 6d13ad3dd4..0f6710264e 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -265,6 +265,8 @@ Meteor.ui = Meteor.ui || {};
if (mode === "patch") {
// Rendering top level of the current update, with patching
range.operate(function(start, end) {
+ // XXX match chunks before shuck
+
Sarge.shuck(start, end);
diffpatch(start.parentNode, frag,
@@ -641,21 +643,11 @@ Meteor.ui = Meteor.ui || {};
Meteor.ui._event.setHandler(handleEvent);
- //////////////////// PATCHER
- // Perform a complete patching where nodes with the same `id` or `name`
- // are matched.
- //
- // Any node that has either an "id" or "name" attribute is considered a
- // "labeled" node, and these labeled nodes are candidates for preservation.
- // For two labeled nodes, old and new, to match, they first must have the same
- // label (that is, they have the same id, or neither has an id and they have
- // the same name). Labels are considered relative to the nearest enclosing
- // labeled ancestor, and must be unique among the labeled nodes that share
- // this nearest labeled ancestor. Labeled nodes are also expected to stay
- // in the same order, or else some of them won't be matched.
+ //////////////////// DIFF / PATCH
var diffpatch = function(tgtParent, srcParent, tgtBefore, tgtAfter) {
+
var copyFunc = function(t, s) {
Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
};
From b809f4086753ad3c07cf047fb0c27ba9ce817028 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 2 Jul 2012 21:17:29 -0700
Subject: [PATCH 011/212] chunk matching test that fails
---
packages/liveui/liveui.js | 2 +
packages/liveui/liveui_tests.js | 81 +++++++++++++++++++++++++++++++++
2 files changed, 83 insertions(+)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 0f6710264e..7e38e632a8 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -313,6 +313,8 @@ Meteor.ui = Meteor.ui || {};
callOffscreen();
};
+ range.branch = options.branch;
+
cx.on_invalidate(function() {
if (range.context === cx) // make sure not an old cx
range.update();
diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js
index 445ebe83bf..a43a95d031 100644
--- a/packages/liveui/liveui_tests.js
+++ b/packages/liveui/liveui_tests.js
@@ -2074,4 +2074,85 @@ Tinytest.add("liveui - controls", function(test) {
div.kill();
});
+Tinytest.add("liveui - chunk matching", function(test) {
+
+ // basic created / onscreen / offscreen callback flow
+
+ var buf;
+ var counts;
+
+ var testCallbacks = function(theNum /*, extend opts*/) {
+ return _.extend.apply(_, [{
+ created: function() {
+ this.num = String(theNum);
+ var howManyBefore = counts[this.num] || 0;
+ counts[this.num] = howManyBefore + 1;
+ for(var i=0;i"+Meteor.ui.chunk(function() {
+ return "HI";
+ }, testCallbacks(1, {branch: "foo"}))+"";
+ }, testCallbacks(0)));
+
+ test.equal(buf, []);
+ Meteor.flush();
+ // what order of chunks {0,1} is preferable??
+ // should be consistent but I'm not sure what makes most sense.
+ test.equal(buf, "c1,on1,c0,on0".split(','));
+ buf.length = 0;
+
+ R.set("B");
+ Meteor.flush();
+ test.equal(buf, "on1,on0".split(','));
+ buf.length = 0;
+
+ div.kill();
+ Meteor.flush();
+ buf.sort();
+ test.equal(buf, "off0,off1".split(','));
+});
+
+
})();
From 2379b0c1010e13a872fca845318fd82d8a403c5f Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Tue, 3 Jul 2012 11:06:55 -0700
Subject: [PATCH 012/212] comments
---
packages/liveui/liveui.js | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 7e38e632a8..4e8f975e01 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -298,7 +298,8 @@ Meteor.ui = Meteor.ui || {};
range.eventHandlers = unpackEventMap(options.events);
wireEvents(range, mode !== "fragment");
- // in case we are patching an existing, valid range
+ // in case we are rendering on top of an existing range
+ // with context, but not due to that context's invalidation.
range.context && range.context.invalidate();
range.context = cx;
@@ -316,8 +317,13 @@ Meteor.ui = Meteor.ui || {};
range.branch = options.branch;
cx.on_invalidate(function() {
- if (range.context === cx) // make sure not an old cx
- range.update();
+ // if range has a newer context than cx, then cx
+ // is just being invalidated in order to clean
+ // up its other dependencies.
+ if (range.context !== cx)
+ return;
+
+ range.update();
});
return range;
@@ -402,6 +408,10 @@ Meteor.ui = Meteor.ui || {};
var Sarge = Meteor.ui._Sarge = {
+ // XXX This object could keep state like lists of ranges to notify
+ // are onscreen or offscreen, instead of just using individual atFlushTime
+ // calls for everything.
+
// Call f with (this === range) the next time we see range
// alive and onscreen at flush time, if ever.
whenOnscreen: function(range, f) {
From 02284a672c578b8c1cb0add30930075970f22881 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Tue, 3 Jul 2012 18:15:21 -0700
Subject: [PATCH 013/212] first cut of chunk matching
---
packages/liveui/liveui.js | 59 +++++++++++++++++++++++++++++++++++----
1 file changed, 53 insertions(+), 6 deletions(-)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 4e8f975e01..9d1eb24164 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -264,12 +264,11 @@ Meteor.ui = Meteor.ui || {};
if (mode === "patch") {
// Rendering top level of the current update, with patching
+ copyChunkState(range, frag);
range.operate(function(start, end) {
- // XXX match chunks before shuck
-
Sarge.shuck(start, end);
- diffpatch(start.parentNode, frag,
+ diffPatch(start.parentNode, frag,
start.previousSibling, end.nextSibling);
});
} else if (mode === "replace") {
@@ -288,7 +287,8 @@ Meteor.ui = Meteor.ui || {};
Sarge.whenOnscreen(range, function() {
if (mode === "fragment")
wireEvents(range, true);
- if (mode !== "patch") // XXX revisit when patching chunks
+
+ if (! range.chunkState)
callCreated();
callOnscreen();
});
@@ -658,7 +658,54 @@ Meteor.ui = Meteor.ui || {};
//////////////////// DIFF / PATCH
- var diffpatch = function(tgtParent, srcParent, tgtBefore, tgtAfter) {
+ // Match branch keys and copy chunkState from liveranges in the
+ // interior of oldRange onto matching liveranges in newFrag.
+ var copyChunkState = function(oldRange, newFrag) {
+ if (! newFrag.firstChild)
+ return; // allow empty newFrag
+
+ var oldChunks = {};
+ var currentPath = [];
+
+ // visit the interior of outerRange and call
+ // `func(r, path)` on every range with a branch key,
+ // where `path` is a string representation of the
+ // branch key path
+ var eachKeyedChunk = function(outerRange, func) {
+ outerRange.visit(function(is_start, r) {
+ if (r.branch) {
+ if (is_start) {
+ currentPath.push(r.branch);
+ func(r, currentPath.join('\u0000'));
+ } else {
+ currentPath.pop();
+ }
+ }
+ });
+ };
+
+ // collect old chunks keyed by their branch key paths
+ eachKeyedChunk(oldRange, function(r, path) {
+ oldChunks[path] = r;
+ });
+
+ // create a temporary range around newFrag in order
+ // to visit it.
+ var tempRange = new Meteor.ui._LiveRange(Meteor.ui._tag, newFrag);
+ eachKeyedChunk(tempRange, function(r, path) {
+ var oldR = oldChunks[path];
+ if (oldR) {
+ // copy over chunkState
+ r.chunkState = oldR.chunkState;
+ oldR.chunkState = null; // don't call offscreen() on old range
+ // any second occurrence of `path` is ignored (not matched)
+ delete oldChunks[path];
+ }
+ });
+ tempRange.destroy();
+ };
+
+ var diffPatch = function(tgtParent, srcParent, tgtBefore, tgtAfter) {
var copyFunc = function(t, s) {
Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
@@ -727,7 +774,7 @@ Meteor.ui = Meteor.ui || {};
// may affect the tag's .value in IE.
if (tgt.nodeName !== "TEXTAREA") {
// recurse!
- diffpatch(tgt, src);
+ diffPatch(tgt, src);
}
}
}
From 9a5bde6e0e247653a9676921a42b4ea8044b4981 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Thu, 5 Jul 2012 16:50:32 -0700
Subject: [PATCH 014/212] branch labeling on partial invocation and #each
(completely untested)
---
packages/handlebars/evaluate.js | 64 ++++++++++++++++++++++--------
packages/liveui/liveui.js | 5 +++
packages/templating/deftemplate.js | 13 ++++--
3 files changed, 62 insertions(+), 20 deletions(-)
diff --git a/packages/handlebars/evaluate.js b/packages/handlebars/evaluate.js
index 68a01ddbba..587a2ce8c9 100644
--- a/packages/handlebars/evaluate.js
+++ b/packages/handlebars/evaluate.js
@@ -31,9 +31,14 @@ Handlebars._default_helpers = {
},
'each': function (data, options) {
if (data && data.length > 0)
- return _.map(data, options.fn).join('');
+ return _.map(data, function(x, i) {
+ // infer a branch key from the data
+ var branch = options.branch + '/' +
+ (x._id || (typeof x === 'string' ? x : null) || i);
+ return options.fn(x, branch);
+ }).join('');
else
- return options.inverse(this);
+ return options.inverse(this, options.branch+'/else');
},
'if': function (data, options) {
if (!data || (data instanceof Array && !data.length))
@@ -239,7 +244,7 @@ Handlebars.evaluate = function (ast, data, options) {
return apply(values, extra);
};
- var template = function (stack, elts) {
+ var template = function (stack, elts, basePCKey) {
var buf = [];
var toString = function (x) {
@@ -250,11 +255,12 @@ Handlebars.evaluate = function (ast, data, options) {
return x.toString();
};
- // wrap `fn` and `inverse` blocks in liveranges
- // having event_data, if the data is different
- // from the enclosing data.
+ // wrap `fn` and `inverse` blocks in chunks having `data`, if the data
+ // is different from the enclosing data, so that the data is available
+ // at runtime for events. Also accept an optional second argument
+ // for supplying a branch key, like partials have.
var decorateBlockFn = function(fn, old_data) {
- return function(data) {
+ return function(data, branch) {
var result = fn(data);
// don't create spurious ranges when data is same as before
// (or when transitioning between e.g. `window` and `undefined`)
@@ -263,8 +269,8 @@ Handlebars.evaluate = function (ast, data, options) {
return result;
} else {
return Meteor.ui.chunk(
- function() { return result; },
- { event_data: data });
+ function() { return result; }, {
+ data: data, branch: branch });
}
};
};
@@ -280,7 +286,15 @@ Handlebars.evaluate = function (ast, data, options) {
return Handlebars._escape(toString(x));
};
- _.each(elts, function (elt) {
+ // Construct a unique key for the current position
+ // in the AST. Since template(...) is invoked recursively,
+ // the "PC" (program counter) key is hierarchical, consisting
+ // of one or more numbers, for example '0' or '1.3.0.1'.
+ var getPCKey = function(index) {
+ return (basePCKey ? basePCKey+'.' : '') + index;
+ };
+
+ _.each(elts, function (elt, index) {
if (typeof(elt) === "string")
buf.push(elt);
else if (elt[0] === '{')
@@ -293,19 +307,31 @@ Handlebars.evaluate = function (ast, data, options) {
// {{#block helper}}
var block = decorateBlockFn(
function (data) {
- return template({parent: stack, data: data}, elt[2]);
+ return template({parent: stack, data: data}, elt[2],
+ getPCKey(index));
}, stack.data);
block.fn = block;
block.inverse = decorateBlockFn(
function (data) {
- return template({parent: stack, data: data}, elt[3] || []);
+ return template({parent: stack, data: data}, elt[3] || [],
+ getPCKey(index));
}, stack.data);
+ // this .branch becomes an option to the helper, and is only
+ // used if the helper uses it.
+ block.branch = elt[1]+"@"+getPCKey(index);
buf.push(toString(invoke(stack, elt[1], block, true)));
} else if (elt[0] === '>') {
// {{> partial}}
- if (!(elt[1] in partials))
- throw new Error("No such partial '" + elt[1] + "'");
- buf.push(toString(partials[elt[1]](stack.data)));
+ var partialName = elt[1];
+ if (!(partialName in partials))
+ throw new Error("No such partial '" + partialName + "'");
+ // Construct a unique branch identifier based on what partial
+ // we're in, what partial we're calling, and our index
+ // into the template AST (essentially the program counter).
+ // If "foo" calls "bar" at index 3, it looks like: bar@foo#3.
+ var branchKey = partialName+"@"+getPCKey(index);
+ // call the partial
+ buf.push(toString(partials[partialName](stack.data, branchKey)));
} else
throw new Error("bad element in template");
});
@@ -313,7 +339,13 @@ Handlebars.evaluate = function (ast, data, options) {
return buf.join('');
};
- return template({data: data, parent: null}, ast);
+ // Set the prefix for PC keys, which identify call sites in the AST
+ // for the purpose of chunk matching.
+ // `options.name` will be null in the body, but otherwise have a value,
+ // assuming `options` was assembled in templating/deftemplate.js.
+ var rootPCKey = (options.name||"")+"#";
+
+ return template({data: data, parent: null}, ast, rootPCKey);
};
Handlebars.SafeString = function(string) {
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 9d1eb24164..186a281718 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -1,5 +1,10 @@
Meteor.ui = Meteor.ui || {};
+// TODO:
+//
+// - Match DOM elements in chunks based on "preserve"
+// - {constant:true} chunk options
+
(function() {
//////////////////// PUBLIC API
diff --git a/packages/templating/deftemplate.js b/packages/templating/deftemplate.js
index 6f69f97405..ec8b703000 100644
--- a/packages/templating/deftemplate.js
+++ b/packages/templating/deftemplate.js
@@ -20,18 +20,23 @@
window.Template = window.Template || {};
- var partial = function(data) {
+ // Define the function assigned to Template..
+ // First argument is Handlebars data, second argument is the
+ // branch key, which is calculated by the caller based
+ // on which invocation of the partial this is.
+ var partial = function(data, branch) {
var getHtml = function() {
return raw_func(data, {
helpers: partial,
- partials: Meteor._partials
+ partials: Meteor._partials,
+ name: name
});
};
var react_data = { events: (name ? Template[name].events : {}),
- event_data: data,
- template_name: name };
+ data: data,
+ branch: branch };
return Meteor.ui.chunk(getHtml, react_data);
};
From e7501784580ac1fe220ec938105780aee710639b Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Fri, 6 Jul 2012 09:39:37 -0700
Subject: [PATCH 015/212] wip
---
packages/liveui/liveui.js | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 186a281718..f0a1e90cc8 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -320,6 +320,7 @@ Meteor.ui = Meteor.ui || {};
};
range.branch = options.branch;
+ range.preserve = normalizePreserveOption(options.preserve);
cx.on_invalidate(function() {
// if range has a newer context than cx, then cx
@@ -334,6 +335,17 @@ Meteor.ui = Meteor.ui || {};
return range;
};
+ var normalizePreserveOption = function(preserve) {
+ if (preserve && _.isArray(preserve)) {
+ var newPreserve = {};
+ _.each(preserve, function(sel) {
+ newPreserve[sel] = 1; // any constant
+ });
+ preserve = newPreserve;
+ }
+ return preserve;
+ };
+
//////////////////// MATERIALIZER
// XXX check order of invalidate at multiple levels?
@@ -677,6 +689,8 @@ Meteor.ui = Meteor.ui || {};
// where `path` is a string representation of the
// branch key path
var eachKeyedChunk = function(outerRange, func) {
+ // XXX call func on outerRange to support top-level unkeyed
+ // chunks, like frag resulting from Template.foo()??
outerRange.visit(function(is_start, r) {
if (r.branch) {
if (is_start) {
@@ -692,11 +706,14 @@ Meteor.ui = Meteor.ui || {};
// collect old chunks keyed by their branch key paths
eachKeyedChunk(oldRange, function(r, path) {
oldChunks[path] = r;
+
+ // XXX preserve
});
// create a temporary range around newFrag in order
// to visit it.
var tempRange = new Meteor.ui._LiveRange(Meteor.ui._tag, newFrag);
+ // visit new frag
eachKeyedChunk(tempRange, function(r, path) {
var oldR = oldChunks[path];
if (oldR) {
From a1e90afd98bb0c16623c33ad328eebfa98500ddb Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Fri, 6 Jul 2012 12:21:05 -0700
Subject: [PATCH 016/212] find matching preserved nodes by selector (in theory)
---
packages/liveui/domutils.js | 94 +++++++++++++++++++++++++++++++++++++
packages/liveui/liveui.js | 91 +++++++++++++++++++++++------------
packages/liveui/package.js | 1 +
packages/liveui/patcher.js | 16 +------
4 files changed, 157 insertions(+), 45 deletions(-)
create mode 100644 packages/liveui/domutils.js
diff --git a/packages/liveui/domutils.js b/packages/liveui/domutils.js
new file mode 100644
index 0000000000..cbc1578b3f
--- /dev/null
+++ b/packages/liveui/domutils.js
@@ -0,0 +1,94 @@
+Meteor.ui = Meteor.ui || {};
+
+// returns true if element a properly contains element b
+Meteor.ui._elementContains = function(a, b) {
+ // Note: Some special-casing would be required to implement this method
+ // where a and b aren't necessarily elements, e.g. b is a text node,
+ // because contains() doesn't seem to work reliably on some browsers
+ // including IE.
+ if (a.nodeType !== 1 || b.nodeType !== 1) {
+ return false; // a and b are not both elements
+ }
+ if (a.compareDocumentPosition) {
+ return a.compareDocumentPosition(b) & 0x10;
+ } else {
+ // Should be only old IE and maybe other old browsers here.
+ // Modern Safari has both methods but seems to get contains() wrong.
+ return a !== b && a.contains(b);
+ }
+};
+
+// Returns an array of element nodes matching `selector`, where
+// the selector is interpreted as rooted at `contextNode`.
+// This means that all nodes that participate in the selector
+// must be descendents on contextNode.
+//
+// jQuery dependency to eventually replace with querySelectorAll
+// backed up by Sizzle in Old IE. Note that querySelectorAll doesn't
+// provide the needed semantics for scoping the selector to contextNode;
+// for example, myDiv.querySelectorAll("body *") will match all of myDiv's
+// descendents, while $(myDiv).find("body *") won't match any. The latter
+// behavior is definitely better, and the way to implement it is to temporarily
+// assign an ID to contextNode (if it doesn't have one).
+Meteor.ui._findElement = function(contextNode, selector) {
+ return $(contextNode).find(selector);
+};
+
+// Requires: `a` and `b` are element nodes in the same document tree.
+// Returns 0 if the nodes are the same or either one contains the other;
+// otherwise, 1 if (a,b) are in order and -1 if they are in the opposite order.
+Meteor.ui._elementOrder = function(a, b) {
+ // See http://ejohn.org/blog/comparing-document-position/
+ if (a === b)
+ return 0;
+ if (a.compareDocumentPosition) {
+ var n = a.compareDocumentPosition(b);
+ return ((n & 0x18) ? 0 : ((n & 0x4) ? 1 : -1));
+ } else {
+ // Only old IE is known to not have compareDocumentPosition (though Safari
+ // originally lacked it). Thankfully, IE gives us a way of comparing elements
+ // via the "sourceIndex" property.
+ if (a.contains(b) || b.contains(a))
+ return 0;
+ return (a.sourceIndex < b.sourceIndex ? 1 : -1);
+ }
+};
+
+// Like `findElement` but uses a hypothetical LiveRange wrapping start..end
+// as the context.
+Meteor.ui._findElementInRange = function(start, end, selector) {
+ end = (end || start);
+
+ var container = start.parentNode;
+ if (! container) {
+ if (start === end && (start.nodeType === 9 /* Document */ ||
+ start.nodeType === 11 /* DocumentFragment */))
+ return Meteor.ui._findElement(start, selector);
+ throw new Error("Can't find element in range on detached node");
+ }
+ if (end.parentNode !== container)
+ throw new Error("Bad range");
+
+ // narrow the range to exclude top-level non-elements (which can't be
+ // or contain matches) by moving the `start` pointer forward and `end`
+ // backward.
+ while (start !== end && start.nodeType !== 1)
+ start = start.nextSibling;
+ while (start !== end && end.nodeType !== 1)
+ end = end.previousSibling;
+ if (start.nodeType !== 1)
+ return []; // no top-level elements! start === end and it's not an element
+
+ // resultsPlus includes matches that are contained by the range's
+ // parent, but are outside of start..end, i.e. are descended from
+ // (or are) a different sibling.
+ var resultsPlus = Meteor.ui._findElement(container, selector);
+
+ // Filter the list of nodes to remove nodes that occur before start
+ // or after end.
+ return _.reject(resultsPlus, function(n) {
+ // reject node if (n,start) are in order or (end,n) are in order
+ return (Meteor.ui._elementOrder(n, start) > 0) ||
+ (Meteor.ui._elementOrder(end, n) > 0);
+ });
+};
\ No newline at end of file
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index f0a1e90cc8..c33900725c 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -2,7 +2,7 @@ Meteor.ui = Meteor.ui || {};
// TODO:
//
-// - Match DOM elements in chunks based on "preserve"
+// - Perform DOM patching based on "preserve"
// - {constant:true} chunk options
(function() {
@@ -269,7 +269,10 @@ Meteor.ui = Meteor.ui || {};
if (mode === "patch") {
// Rendering top level of the current update, with patching
- copyChunkState(range, frag);
+
+ var nodeMatches = matchChunks(range, frag);
+ // XXX use nodeMatches
+
range.operate(function(start, end) {
Sarge.shuck(start, end);
@@ -491,23 +494,19 @@ Meteor.ui = Meteor.ui || {};
// Check whether a node is contained in the document.
isNodeOnscreen: function (node) {
- // http://jsperf.com/is-element-in-the-dom
+ // Deal with all cases where node is not an element
+ // node descending from the body first...
+ if (node === document)
+ return true;
- if (document.compareDocumentPosition)
- return document.compareDocumentPosition(node) & 16;
- else {
- if (node.nodeType !== 1 /* Element */)
- /* contains() doesn't work reliably on non-Elements. Fine on
- Chrome, not so much on Safari and IE. */
- node = node.parentNode;
- if (node.nodeType === 11 /* DocumentFragment */ ||
- node.nodeType === 9 /* Document */)
- /* contains() chokes on DocumentFragments on IE8 */
- return node === document;
- /* contains() exists on document on Chrome, but only on
- document.body on some other browsers. */
- return document.body.contains(node);
- }
+ if (node.nodeType !== 1 /* Element */)
+ node = node.parentNode;
+ if (! (node && node.nodeType === 1))
+ return false;
+ if (node === document.body)
+ return true;
+
+ return Meteor.ui._elementContains(document.body, node);
},
// Internal facility, only used by tests, for holding onto
@@ -632,7 +631,7 @@ Meteor.ui = Meteor.ui || {};
var selector = h.selector;
if (selector) {
var contextNode = range.containerNode();
- var results = $(contextNode).find(selector);
+ var results = Meteor.ui._findElement(contextNode, selector);
if (! _.contains(results, curNode))
continue;
} else {
@@ -677,12 +676,13 @@ Meteor.ui = Meteor.ui || {};
// Match branch keys and copy chunkState from liveranges in the
// interior of oldRange onto matching liveranges in newFrag.
- var copyChunkState = function(oldRange, newFrag) {
+ // Return pairs of matching DOM nodes to preserve.
+ var matchChunks = function(oldRange, newFrag) {
if (! newFrag.firstChild)
- return; // allow empty newFrag
+ return []; // allow empty newFrag
- var oldChunks = {};
- var currentPath = [];
+ var oldChunks = {}; // { path -> range }
+ var currentPath = []; // list of branch keys (path segments)
// visit the interior of outerRange and call
// `func(r, path)` on every range with a branch key,
@@ -706,25 +706,57 @@ Meteor.ui = Meteor.ui || {};
// collect old chunks keyed by their branch key paths
eachKeyedChunk(oldRange, function(r, path) {
oldChunks[path] = r;
-
- // XXX preserve
});
+ // Run the selectors from preserveMap over the nodes
+ // in range and create a map { label -> node }.
+ var collectLabeledNodes = function(range, preserveMap) {
+ var labeledNodes = {};
+ _.each(preserveMap, function(labelFunc, sel) {
+ var matchingNodes = Meteor.ui._findElementInRange(
+ range.firstNode(), range.lastNode(), sel);
+ _.each(matchingNodes, function(n) {
+ // labelFunc can be a function or a constant,
+ // the latter for single-match selectors {'.foo': 1}
+ var pernodeLabel = (
+ typeof labelFunc === 'function' ? labelFunc(n) : labelFunc);
+ var fullLabel = sel+'/'+pernodeLabel;
+ // in case of duplicates, we ignore the second node (this one).
+ // eventually, the developer might want to get debug info.
+ if (! labeledNodes[fullLabel])
+ labeledNodes[fullLabel] = n;
+ });
+ });
+ return labeledNodes;
+ };
+
+ var nodeMatches = []; // [[oldNode, newNode], ...]
+
// create a temporary range around newFrag in order
// to visit it.
var tempRange = new Meteor.ui._LiveRange(Meteor.ui._tag, newFrag);
// visit new frag
eachKeyedChunk(tempRange, function(r, path) {
- var oldR = oldChunks[path];
- if (oldR) {
+ var oldRange = oldChunks[path];
+ if (oldRange) {
// copy over chunkState
- r.chunkState = oldR.chunkState;
- oldR.chunkState = null; // don't call offscreen() on old range
+ r.chunkState = oldRange.chunkState;
+ oldRange.chunkState = null; // don't call offscreen() on old range
// any second occurrence of `path` is ignored (not matched)
delete oldChunks[path];
+
+ var oldLabeledNodes = collectLabeledNodes(oldRange, r.preserve);
+ var newLabeledNodes = collectLabeledNodes(r, r.preserve);
+ _.each(newLabeledNodes, function(newNode, label) {
+ var oldNode = oldLabeledNodes[label];
+ if (oldNode)
+ nodeMatches.push([oldNode, newNode]);
+ });
}
});
tempRange.destroy();
+
+ return nodeMatches;
};
var diffPatch = function(tgtParent, srcParent, tgtBefore, tgtAfter) {
@@ -808,5 +840,4 @@ Meteor.ui = Meteor.ui || {};
};
-
})();
diff --git a/packages/liveui/package.js b/packages/liveui/package.js
index 4893de698f..02387ba287 100644
--- a/packages/liveui/package.js
+++ b/packages/liveui/package.js
@@ -13,6 +13,7 @@ Package.on_use(function (api) {
// you still want the event object normalization that jquery provides?)
api.use('jquery');
+ api.add_files(['domutils.js'], 'client');
api.add_files(['liveevents_w3c.js', 'liveevents_now3c.js'], 'client');
api.add_files(['liveevents.js'], 'client');
api.add_files(['liverange.js', 'liveui.js', 'innerhtml.js', 'patcher.js'],
diff --git a/packages/liveui/patcher.js b/packages/liveui/patcher.js
index ea52a8a60e..dff3b4f95b 100644
--- a/packages/liveui/patcher.js
+++ b/packages/liveui/patcher.js
@@ -92,7 +92,7 @@ Meteor.ui._Patcher.prototype.match = function(tgtNode, srcNode, copyCallback) {
var starting = ! lastKeptTgt;
var finishing = ! tgt;
- var elementContains = Meteor.ui._Patcher._elementContains;
+ var elementContains = Meteor.ui._elementContains;
if (! starting) {
// move lastKeptTgt/lastKeptSrc forward and out,
@@ -377,17 +377,3 @@ Meteor.ui._Patcher._copyAttributes = function(tgt, src) {
}
};
-
-// returns true if element a properly contains element b
-Meteor.ui._Patcher._elementContains = function(a, b) {
- if (a.nodeType !== 1 || b.nodeType !== 1) {
- return false;
- }
- if (a.compareDocumentPosition) {
- return a.compareDocumentPosition(b) & 0x10;
- } else {
- // Should be only old IE and maybe other old browsers here.
- // Modern Safari has both methods but seems to get contains() wrong.
- return a !== b && a.contains(b);
- }
-};
From 01e0a84e63e07746bd2931e7465771c51beac9cb Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 9 Jul 2012 11:41:05 -0700
Subject: [PATCH 017/212] use `preserve` option for patching (totally broken)
---
packages/liveui/liveui.js | 87 +++++++++++++++----
packages/liveui/liveui_tests.js | 31 ++++++-
packages/liveui/package.js | 2 +-
.../{smartpatch_tests.js => patcher_tests.js} | 5 +-
4 files changed, 101 insertions(+), 24 deletions(-)
rename packages/liveui/{smartpatch_tests.js => patcher_tests.js} (98%)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index c33900725c..cd48ad326c 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -2,7 +2,6 @@ Meteor.ui = Meteor.ui || {};
// TODO:
//
-// - Perform DOM patching based on "preserve"
// - {constant:true} chunk options
(function() {
@@ -266,18 +265,19 @@ Meteor.ui = Meteor.ui || {};
frag.appendChild(tbody);
}
+ range.preserve = normalizePreserveOption(options.preserve);
if (mode === "patch") {
// Rendering top level of the current update, with patching
var nodeMatches = matchChunks(range, frag);
- // XXX use nodeMatches
range.operate(function(start, end) {
Sarge.shuck(start, end);
diffPatch(start.parentNode, frag,
- start.previousSibling, end.nextSibling);
+ start.previousSibling, end.nextSibling,
+ nodeMatches);
});
} else if (mode === "replace") {
// Rendering a sub-chunk of the current update
@@ -323,7 +323,6 @@ Meteor.ui = Meteor.ui || {};
};
range.branch = options.branch;
- range.preserve = normalizePreserveOption(options.preserve);
cx.on_invalidate(function() {
// if range has a newer context than cx, then cx
@@ -689,8 +688,10 @@ Meteor.ui = Meteor.ui || {};
// where `path` is a string representation of the
// branch key path
var eachKeyedChunk = function(outerRange, func) {
- // XXX call func on outerRange to support top-level unkeyed
+ // call func on outerRange to support top-level unkeyed
// chunks, like frag resulting from Template.foo()??
+ func(outerRange, '');
+ // visit interior of outerRange
outerRange.visit(function(is_start, r) {
if (r.branch) {
if (is_start) {
@@ -720,11 +721,14 @@ Meteor.ui = Meteor.ui || {};
// the latter for single-match selectors {'.foo': 1}
var pernodeLabel = (
typeof labelFunc === 'function' ? labelFunc(n) : labelFunc);
- var fullLabel = sel+'/'+pernodeLabel;
- // in case of duplicates, we ignore the second node (this one).
- // eventually, the developer might want to get debug info.
- if (! labeledNodes[fullLabel])
- labeledNodes[fullLabel] = n;
+ // falsy pernodeLabel is not considered a label
+ if (pernodeLabel) {
+ var fullLabel = sel+'/'+pernodeLabel;
+ // in case of duplicates, we ignore the second node (this one).
+ // eventually, the developer might want to get debug info.
+ if (! labeledNodes[fullLabel])
+ labeledNodes[fullLabel] = n;
+ }
});
});
return labeledNodes;
@@ -739,9 +743,15 @@ Meteor.ui = Meteor.ui || {};
eachKeyedChunk(tempRange, function(r, path) {
var oldRange = oldChunks[path];
if (oldRange) {
- // copy over chunkState
- r.chunkState = oldRange.chunkState;
- oldRange.chunkState = null; // don't call offscreen() on old range
+ if (r === tempRange) {
+ // top level; don't copy chunkState to tempRange!
+ // use oldRange.preserve for preservation
+ r = oldRange;
+ } else {
+ // copy over chunkState
+ r.chunkState = oldRange.chunkState;
+ oldRange.chunkState = null; // don't call offscreen() on old range
+ }
// any second occurrence of `path` is ignored (not matched)
delete oldChunks[path];
@@ -759,13 +769,13 @@ Meteor.ui = Meteor.ui || {};
return nodeMatches;
};
- var diffPatch = function(tgtParent, srcParent, tgtBefore, tgtAfter) {
+ var diffPatch = function(tgtParent, srcParent, tgtBefore, tgtAfter, nodeMatches) {
var copyFunc = function(t, s) {
Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
};
- var each_labeled_node = function(parent, before, after, func) {
+ /*var each_labeled_node = function(parent, before, after, func) {
for(var n = before ? before.nextSibling : parent.firstChild;
n && n !== after;
n = n.nextSibling) {
@@ -808,12 +818,53 @@ Meteor.ui = Meteor.ui || {};
function(label, node) {
targetNodes[label] = node;
targetNodeOrder[label] = targetNodeCounter++;
- });
+ });*/
+
var patcher = new Meteor.ui._Patcher(
tgtParent, srcParent, tgtBefore, tgtAfter);
- var lastPos = -1;
+
+ var visitNodes = function(parent, before, after, func) {
+ for(var n = before ? before.nextSibling : parent.firstChild;
+ n && n !== after;
+ n = n.nextSibling) {
+ if (func(n) !== false && n.firstChild)
+ visitNodes(n, null, null, func);
+ }
+ };
+
+ var lastTgtMatch = null;
+
+ visitNodes(srcParent, null, null, function(src) {
+ // XXX inefficient to scan for match for every node!
+ var pair = _.find(nodeMatches, function(p) {
+ return p[1] === src;
+ });
+ if (pair) {
+ var tgt = pair[0];
+ if (! lastTgtMatch ||
+ Meteor.ui._elementOrder(lastTgtMatch, tgt) > 0) {
+ if (patcher.match(tgt, src, copyFunc)) {
+ // match succeeded
+ lastTgtMatch = tgt;
+ if (tgt.firstChild || src.firstChild) {
+ // Don't patch contents of TEXTAREA tag,
+ // which are only the initial contents but
+ // may affect the tag's .value in IE.
+ if (tgt.nodeName !== "TEXTAREA") {
+ // recurse!
+ diffPatch(tgt, src, null, null, nodeMatches);
+ }
+ }
+ return false; // tell visitNodes not to recurse
+ }
+ }
+ }
+ return true;
+ });
+
+ /*var lastPos = -1;
each_labeled_node(
srcParent, null, null,
function(label, node) {
@@ -834,7 +885,7 @@ Meteor.ui = Meteor.ui || {};
}
lastPos = targetNodeOrder[label];
}
- });
+ });*/
patcher.finish();
diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js
index a43a95d031..f8c38f2592 100644
--- a/packages/liveui/liveui_tests.js
+++ b/packages/liveui/liveui_tests.js
@@ -65,6 +65,33 @@ WrappedFrag.prototype.node = function() {
return this.frag;
};
+///// MISC /////
+
+var legacyLabels = {
+ '*': function(n) {
+ var label = null;
+
+ if (n.nodeType === 1) {
+ if (n.id) {
+ label = '#'+n.id;
+ } else if (n.getAttribute("name")) {
+ label = n.getAttribute("name");
+ // Radio button special case: radio buttons
+ // in a group all have the same name. Their value
+ // determines their identity.
+ // Checkboxes with the same name and different
+ // values are also sometimes used in apps, so
+ // we treat them similarly.
+ if (n.nodeName === 'INPUT' &&
+ (n.type === 'radio' || n.type === 'checkbox') &&
+ n.value)
+ label = label + ':' + n.value;
+ }
+ }
+
+ return label;
+ }
+};
///// TESTS /////
@@ -412,7 +439,7 @@ Tinytest.add("liveui - preserved nodes (diff/patch)", function(test) {
var structure = randomNodeList(null, 6);
var frag = WrappedFrag(Meteor.ui.render(function() {
return nodeListToHtml(structure, R.get());
- })).hold();
+ }, {preserve: legacyLabels})).hold();
test.equal(frag.html(), nodeListToHtml(structure, false) || "");
fillInElementIdentities(structure, frag.node());
var labeledNodes = collectLabeledNodeData(structure);
@@ -440,7 +467,7 @@ Tinytest.add("liveui - copied attributes", function(test) {
var frag = WrappedFrag(Meteor.ui.render(function() {
return '
';
- })).hold();
+ }, { preserve: legacyLabels })).hold();
var node1 = frag.node().firstChild;
var node2 = frag.node().firstChild.getElementsByTagName("input")[0];
test.equal(node1.nodeName, "DIV");
diff --git a/packages/liveui/package.js b/packages/liveui/package.js
index 02387ba287..3086be6849 100644
--- a/packages/liveui/package.js
+++ b/packages/liveui/package.js
@@ -31,7 +31,7 @@ Package.on_test(function (api) {
'liveui_tests.js',
'liveui_tests.html',
'liverange_tests.js',
- 'smartpatch_tests.js',
+ 'patcher_tests.js',
'liveevents_tests.js'
], 'client');
});
diff --git a/packages/liveui/smartpatch_tests.js b/packages/liveui/patcher_tests.js
similarity index 98%
rename from packages/liveui/smartpatch_tests.js
rename to packages/liveui/patcher_tests.js
index e10faccfe8..35748c0aef 100644
--- a/packages/liveui/smartpatch_tests.js
+++ b/packages/liveui/patcher_tests.js
@@ -1,4 +1,4 @@
-Tinytest.add("smartpatch - basic", function(test) {
+Tinytest.add("patcher - basic", function(test) {
var Patcher = Meteor.ui._Patcher;
@@ -121,7 +121,7 @@ Tinytest.add("smartpatch - basic", function(test) {
_.each([["aaa","zzz"], ["",""], ["aaa",""], ["","zzz"]], rangeTest);
});
-Tinytest.add("smartpatch - copyAttributes", function(test) {
+Tinytest.add("patcher - copyAttributes", function(test) {
var attrTester = function(tagName, initial) {
var node;
@@ -239,4 +239,3 @@ Tinytest.add("smartpatch - copyAttributes", function(test) {
});
-
From 19ada8ad1681ad5f7add06ac186e8da5dad0113b Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 9 Jul 2012 16:06:01 -0700
Subject: [PATCH 018/212] old tests pass
---
packages/liveui/domutils.js | 33 ++++++++-
packages/liveui/innerhtml.js | 14 ++--
packages/liveui/liveui.js | 117 ++++++--------------------------
packages/liveui/liveui_tests.js | 56 ++++++++-------
4 files changed, 92 insertions(+), 128 deletions(-)
diff --git a/packages/liveui/domutils.js b/packages/liveui/domutils.js
index cbc1578b3f..f0ffd554c7 100644
--- a/packages/liveui/domutils.js
+++ b/packages/liveui/domutils.js
@@ -31,7 +31,19 @@ Meteor.ui._elementContains = function(a, b) {
// behavior is definitely better, and the way to implement it is to temporarily
// assign an ID to contextNode (if it doesn't have one).
Meteor.ui._findElement = function(contextNode, selector) {
- return $(contextNode).find(selector);
+ if (contextNode.nodeType === 11 /* DocumentFragment */) {
+ // Sizzle doesn't work on a DocumentFragment, but it does work on
+ // a descendent of one.
+ var frag = contextNode;
+ var container = Meteor.ui._fragmentToContainer(frag);
+ var results = $(container).find(selector);
+ // put nodes back into frag
+ while (container.firstChild)
+ frag.appendChild(container.firstChild);
+ return results;
+ } else {
+ return $(contextNode).find(selector);
+ }
};
// Requires: `a` and `b` are element nodes in the same document tree.
@@ -91,4 +103,21 @@ Meteor.ui._findElementInRange = function(start, end, selector) {
return (Meteor.ui._elementOrder(n, start) > 0) ||
(Meteor.ui._elementOrder(end, n) > 0);
});
-};
\ No newline at end of file
+};
+
+// Check whether a node is contained in the document.
+Meteor.ui._isNodeOnscreen = function (node) {
+ // Deal with all cases where node is not an element
+ // node descending from the body first...
+ if (node === document)
+ return true;
+
+ if (node.nodeType !== 1 /* Element */)
+ node = node.parentNode;
+ if (! (node && node.nodeType === 1))
+ return false;
+ if (node === document.body)
+ return true;
+
+ return Meteor.ui._elementContains(document.body, node);
+};
diff --git a/packages/liveui/innerhtml.js b/packages/liveui/innerhtml.js
index edafecbbf8..62289ea77e 100644
--- a/packages/liveui/innerhtml.js
+++ b/packages/liveui/innerhtml.js
@@ -110,9 +110,10 @@ _.extend(Meteor.ui, (function() {
return frag;
},
- _fragmentToHtml: function(frag) {
- frag = frag.cloneNode(true); // deep copy, don't touch original!
-
+ // Put children of frag into a suitable DOM node, e.g. a DIV
+ // or a TABLE as appropriate, for the purpose of using
+ // innerHTML or a Sizzle/jQuery selectors.
+ _fragmentToContainer: function(frag) {
var doc = document; // node factory
var firstElement = frag.firstChild;
@@ -138,7 +139,12 @@ _.extend(Meteor.ui, (function() {
container.appendChild(frag);
}
- return container.innerHTML;
+ return container;
+ },
+ _fragmentToHtml: function(frag) {
+ frag = frag.cloneNode(true); // deep copy, don't touch original!
+
+ return Meteor.ui._fragmentToContainer(frag).innerHTML;
},
_rangeToHtml: function(liverange) {
var frag = document.createDocumentFragment();
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index cd48ad326c..e342f3c62b 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -53,6 +53,13 @@ Meteor.ui = Meteor.ui || {};
else_func = (typeof else_func === "function" ? else_func :
function() { return ""; });
+ var itemOpts = {};
+ if (options) {
+ // for `preserve` to affect item updates, must be set on each item
+ itemOpts.preserve = options.preserve;
+ }
+
+
var initialDocs = [];
var queuedUpdates = [];
var outerRange = null;
@@ -96,7 +103,8 @@ Meteor.ui = Meteor.ui || {};
oldRange = itemRanges[before_idx];
mode = "before";
}
- var range = renderChunk(doc_func, {data: doc}, mode, oldRange);
+ var range = renderChunk(doc_func, _.extend({data: doc}, itemOpts),
+ mode, oldRange);
itemRanges.splice(before_idx, 0, range);
});
@@ -107,7 +115,7 @@ Meteor.ui = Meteor.ui || {};
else enqueue(function() {
var range;
if (itemRanges.length === 1)
- range = renderChunk(else_func, "inside", outerRange);
+ range = renderChunk(else_func, itemOpts, "inside", outerRange);
else
Sarge.shuck(itemRanges[at_idx].extract());
@@ -139,7 +147,8 @@ Meteor.ui = Meteor.ui || {};
if (! handle)
initialDocs[at_idx] = doc;
else enqueue(function() {
- renderChunk(doc_func, {data: doc}, "patch", itemRanges[at_idx]);
+ renderChunk(doc_func, _.extend({data: doc}, itemOpts),
+ "patch", itemRanges[at_idx]);
});
}
});
@@ -275,7 +284,7 @@ Meteor.ui = Meteor.ui || {};
range.operate(function(start, end) {
Sarge.shuck(start, end);
- diffPatch(start.parentNode, frag,
+ patch(start.parentNode, frag,
start.previousSibling, end.nextSibling,
nodeMatches);
});
@@ -480,7 +489,7 @@ Meteor.ui = Meteor.ui || {};
var node = range.firstNode();
if (node.parentNode &&
- (Sarge.isNodeOnscreen(node) || Sarge.isNodeHeld(node)))
+ (Meteor.ui._isNodeOnscreen(node) || Sarge.isNodeHeld(node)))
return false;
while (node.parentNode)
@@ -491,23 +500,6 @@ Meteor.ui = Meteor.ui || {};
return true;
},
- // Check whether a node is contained in the document.
- isNodeOnscreen: function (node) {
- // Deal with all cases where node is not an element
- // node descending from the body first...
- if (node === document)
- return true;
-
- if (node.nodeType !== 1 /* Element */)
- node = node.parentNode;
- if (! (node && node.nodeType === 1))
- return false;
- if (node === document.body)
- return true;
-
- return Meteor.ui._elementContains(document.body, node);
- },
-
// Internal facility, only used by tests, for holding onto
// DocumentFragments across flush(). Does ref-counting
// using hold() and release().
@@ -743,20 +735,22 @@ Meteor.ui = Meteor.ui || {};
eachKeyedChunk(tempRange, function(r, path) {
var oldRange = oldChunks[path];
if (oldRange) {
+ var preserveMap;
if (r === tempRange) {
// top level; don't copy chunkState to tempRange!
// use oldRange.preserve for preservation
- r = oldRange;
+ preserveMap = oldRange.preserve;
} else {
// copy over chunkState
r.chunkState = oldRange.chunkState;
oldRange.chunkState = null; // don't call offscreen() on old range
+ preserveMap = r.preserve;
}
// any second occurrence of `path` is ignored (not matched)
delete oldChunks[path];
- var oldLabeledNodes = collectLabeledNodes(oldRange, r.preserve);
- var newLabeledNodes = collectLabeledNodes(r, r.preserve);
+ var oldLabeledNodes = collectLabeledNodes(oldRange, preserveMap);
+ var newLabeledNodes = collectLabeledNodes(r, preserveMap);
_.each(newLabeledNodes, function(newNode, label) {
var oldNode = oldLabeledNodes[label];
if (oldNode)
@@ -769,58 +763,12 @@ Meteor.ui = Meteor.ui || {};
return nodeMatches;
};
- var diffPatch = function(tgtParent, srcParent, tgtBefore, tgtAfter, nodeMatches) {
+ var patch = function(tgtParent, srcParent, tgtBefore, tgtAfter, nodeMatches) {
var copyFunc = function(t, s) {
Meteor.ui._LiveRange.transplant_tag(Meteor.ui._tag, t, s);
};
- /*var each_labeled_node = function(parent, before, after, func) {
- for(var n = before ? before.nextSibling : parent.firstChild;
- n && n !== after;
- n = n.nextSibling) {
-
- var label = null;
-
- if (n.nodeType === 1) {
- if (n.id) {
- label = '#'+n.id;
- } else if (n.getAttribute("name")) {
- label = n.getAttribute("name");
- // Radio button special case: radio buttons
- // in a group all have the same name. Their value
- // determines their identity.
- // Checkboxes with the same name and different
- // values are also sometimes used in apps, so
- // we treat them similarly.
- if (n.nodeName === 'INPUT' &&
- (n.type === 'radio' || n.type === 'checkbox') &&
- n.value)
- label = label + ':' + n.value;
- }
- }
-
- if (label)
- func(label, n);
- else
- // not a labeled node; recurse
- each_labeled_node(n, null, null, func);
- }
- };
-
-
- var targetNodes = {};
- var targetNodeOrder = {};
- var targetNodeCounter = 0;
-
- each_labeled_node(
- tgtParent, tgtBefore, tgtAfter,
- function(label, node) {
- targetNodes[label] = node;
- targetNodeOrder[label] = targetNodeCounter++;
- });*/
-
-
var patcher = new Meteor.ui._Patcher(
tgtParent, srcParent, tgtBefore, tgtAfter);
@@ -854,7 +802,7 @@ Meteor.ui = Meteor.ui || {};
// may affect the tag's .value in IE.
if (tgt.nodeName !== "TEXTAREA") {
// recurse!
- diffPatch(tgt, src, null, null, nodeMatches);
+ patch(tgt, src, null, null, nodeMatches);
}
}
return false; // tell visitNodes not to recurse
@@ -864,29 +812,6 @@ Meteor.ui = Meteor.ui || {};
return true;
});
- /*var lastPos = -1;
- each_labeled_node(
- srcParent, null, null,
- function(label, node) {
- var tgt = targetNodes[label];
- var src = node;
- if (tgt && targetNodeOrder[label] > lastPos) {
- if (patcher.match(tgt, src, copyFunc)) {
- // match succeeded
- if (tgt.firstChild || src.firstChild) {
- // Don't patch contents of TEXTAREA tag,
- // which are only the initial contents but
- // may affect the tag's .value in IE.
- if (tgt.nodeName !== "TEXTAREA") {
- // recurse!
- diffPatch(tgt, src);
- }
- }
- }
- lastPos = targetNodeOrder[label];
- }
- });*/
-
patcher.finish();
};
diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js
index f8c38f2592..dfe158affc 100644
--- a/packages/liveui/liveui_tests.js
+++ b/packages/liveui/liveui_tests.js
@@ -68,7 +68,7 @@ WrappedFrag.prototype.node = function() {
///// MISC /////
var legacyLabels = {
- '*': function(n) {
+ '*[id], #[name]': function(n) {
var label = null;
if (n.nodeType === 1) {
@@ -155,7 +155,7 @@ Tinytest.add("liveui - one render", function(test) {
R.get()+'
"));
R.set(3);
div.node().getElementsByTagName("tr")[0].appendChild(Meteor.ui.render(
function() {
diff --git a/packages/liveui/patcher_tests.js b/packages/liveui/patcher_tests.js
index 28a69efad6..44a50a639e 100644
--- a/packages/liveui/patcher_tests.js
+++ b/packages/liveui/patcher_tests.js
@@ -137,7 +137,7 @@ Tinytest.add("patcher - copyAttributes", function(test) {
});
buf.push('>', tagName, '>');
var nodeHtml = buf.join('');
- var frag = Meteor.ui._htmlToFragment(nodeHtml);
+ var frag = DomUtils.htmlToFragment(nodeHtml);
var n = frag.firstChild;
if (! node) {
node = n;
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 0de3634cc0..451370fc92 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -50,7 +50,6 @@ Spark.render = function (htmlFunc) {
// HERE
//
- // - Move LiveRange to global scope
// - Create DomUtils package (or something like that)
// - First thing in DomUtils is htmlToFragment from innerhtml.js
// - Later, will add stuff from domutils.js
diff --git a/packages/templating/package.js b/packages/templating/package.js
index 2c2fc6c5fc..476de9c603 100644
--- a/packages/templating/package.js
+++ b/packages/templating/package.js
@@ -83,7 +83,7 @@ Package.register_extension(
Package.on_test(function (api) {
api.use('tinytest');
api.use('htmljs');
- api.use('test-helpers', 'client');
+ api.use(['test-helpers', 'domutils'], 'client');
api.add_files([
'templating_tests.js',
'templating_tests.html'
diff --git a/packages/templating/templating_tests.js b/packages/templating/templating_tests.js
index bb28fe0bab..2fb1f8e96d 100644
--- a/packages/templating/templating_tests.js
+++ b/packages/templating/templating_tests.js
@@ -4,7 +4,7 @@ Tinytest.add("templating - assembly", function (test) {
// Test for a bug that made it to production -- after a replacement,
// we need to also check the newly replaced node for replacements
var frag = Meteor.ui.render(Template.test_assembly_a0);
- test.equal(canonicalizeHtml(Meteor.ui._fragmentToHtml(frag)),
+ test.equal(canonicalizeHtml(DomUtils.fragmentToHtml(frag)),
"Hi");
// Another production bug -- we must use LiveRange to replace the
diff --git a/packages/test-helpers/onscreendiv.js b/packages/test-helpers/onscreendiv.js
index b2b0629e44..6834e953df 100644
--- a/packages/test-helpers/onscreendiv.js
+++ b/packages/test-helpers/onscreendiv.js
@@ -13,7 +13,7 @@ var OnscreenDiv = function(optFrag) {
if (! (this instanceof OnscreenDiv))
return new OnscreenDiv(optFrag);
- this.div = Meteor.ui._htmlToFragment(
+ this.div = DomUtils.htmlToFragment(
'').firstChild;
document.body.appendChild(this.div);
diff --git a/packages/test-helpers/package.js b/packages/test-helpers/package.js
index e8e352a05a..eb67a98302 100644
--- a/packages/test-helpers/package.js
+++ b/packages/test-helpers/package.js
@@ -6,6 +6,11 @@ Package.describe({
Package.on_use(function (api, where) {
where = where || ["client", "server"];
+ // XXX These files have various dependencies on other packages
+ // that aren't specified here. :(
+ // This package should probably get split into several packages,
+ // each with correct dependencies.
+
api.add_files('try_all_permutations.js', where);
api.add_files('async_multi.js', where);
api.add_files('event_simulation.js', where);
diff --git a/packages/test-helpers/wrappedfrag.js b/packages/test-helpers/wrappedfrag.js
index 0694c51fcf..5778dfc0e3 100644
--- a/packages/test-helpers/wrappedfrag.js
+++ b/packages/test-helpers/wrappedfrag.js
@@ -13,7 +13,7 @@ WrappedFrag = function(frag) {
};
WrappedFrag.prototype.rawHtml = function() {
- return Meteor.ui._fragmentToHtml(this.frag);
+ return DomUtils.fragmentToHtml(this.frag);
};
WrappedFrag.prototype.html = function() {
From 3dbccc131cfb2cc3a888703b4fa695d370df77ff Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Fri, 27 Jul 2012 15:32:51 -0700
Subject: [PATCH 044/212] move liveui/domutils utilities into DomUtils
---
packages/domutils/domutils.js | 166 +++++++++++++++++++++++++++++-
packages/liveui/domutils.js | 23 ++++-
packages/liveui/livedocument.js | 4 +-
packages/liveui/liveevents_w3c.js | 2 +-
packages/liveui/liveui.js | 10 +-
packages/liveui/patcher.js | 3 +-
6 files changed, 192 insertions(+), 16 deletions(-)
diff --git a/packages/domutils/domutils.js b/packages/domutils/domutils.js
index e4eef364ec..3da6d8c03d 100644
--- a/packages/domutils/domutils.js
+++ b/packages/domutils/domutils.js
@@ -152,4 +152,168 @@ DomUtils = {};
return container;
};
-})();
\ No newline at end of file
+ // Returns true if element a properly contains element b.
+ // Only works on element nodes (e.g. not text nodes).
+ DomUtils.elementContains = function(a, b) {
+ // Note: Some special-casing would be required to implement this method
+ // where a and b aren't necessarily elements, e.g. b is a text node,
+ // because contains() doesn't seem to work reliably on some browsers
+ // including IE.
+ if (a.nodeType !== 1 || b.nodeType !== 1) {
+ return false; // a and b are not both elements
+ }
+ if (a.compareDocumentPosition) {
+ return a.compareDocumentPosition(b) & 0x10;
+ } else {
+ // Should be only old IE and maybe other old browsers here.
+ // Modern Safari has both methods but seems to get contains() wrong.
+ return a !== b && a.contains(b);
+ }
+ };
+
+ // Returns an array containing the children of contextNode that
+ // match `selector`. Unlike querySelectorAll, `selector` is
+ // interpreted as if the document were rooted at `contextNode` --
+ // the only nodes that can be used to match components of the
+ // selector are the descendents of `contextNode`. `contextNode`
+ // itself is not included (it can't be used to match a component of
+ // the selector, and it can never be included in the returned
+ // array.)
+ //
+ // `contextNode` may be either a node, a document, or a DocumentFragment.
+ DomUtils.findElement = function(contextNode, selector) {
+ // Eventually, we will remove the dependency on jQuery ($) and
+ // implement this in terms of querySelectorAll on modern browsers
+ // and Sizzle in old IE. We'll use jQuery's trick for scoped
+ // querySelectorAll which involves temporarily assigning an ID to
+ // contextNode (if it doesn't have one) and prepending the ID to
+ // the selector.
+ if (contextNode.nodeType === 11 /* DocumentFragment */) {
+ // Sizzle doesn't work on a DocumentFragment, but it does work on
+ // a descendent of one.
+ var frag = contextNode;
+ var container = DomUtils.fragmentToContainer(frag);
+ var results = $(container).find(selector);
+ // put nodes back into frag
+ while (container.firstChild)
+ frag.appendChild(container.firstChild);
+ return results;
+ } else {
+ return $(contextNode).find(selector);
+ }
+ };
+
+ // Like `findElement` but searches the nodes from `start` to `end`
+ // inclusive. `start` and `end` must be siblings, and they participate
+ // in the search (they can be used to match selector components, and
+ // they can appear in the returned results). It's as if the parent of
+ // `start` and `end` serves as contextNode, but matches from children
+ // that aren't between `start` and `end` (inclusive) are ignored.
+ //
+ // If `selector` involves sibling selectors, child index selectors, or
+ // the like, the results are undefined.
+ DomUtils.findElementInRange = function(start, end, selector) {
+ end = (end || start);
+
+ var container = start.parentNode;
+ if (! container) {
+ if (start === end && (start.nodeType === 9 /* Document */ ||
+ start.nodeType === 11 /* DocumentFragment */))
+ return DomUtils.findElement(start, selector);
+ throw new Error("Can't find element in range on detached node");
+ }
+ if (end.parentNode !== container)
+ throw new Error("Bad range");
+
+ // narrow the range to exclude top-level non-elements (which can't be
+ // or contain matches) by moving the `start` pointer forward and `end`
+ // backward.
+ while (start !== end && start.nodeType !== 1)
+ start = start.nextSibling;
+ while (start !== end && end.nodeType !== 1)
+ end = end.previousSibling;
+ if (start.nodeType !== 1)
+ return []; // no top-level elements! start === end and it's not an element
+
+ // resultsPlus includes matches that are contained by the range's
+ // parent, but are outside of start..end, i.e. are descended from
+ // (or are) a different sibling.
+ var resultsPlus = DomUtils.findElement(container, selector);
+
+ // Filter the list of nodes to remove nodes that occur before start
+ // or after end.
+ return _.reject(resultsPlus, function(n) {
+ // reject node if (n,start) are in order or (end,n) are in order
+ return (DomUtils.elementOrder(n, start) > 0) ||
+ (DomUtils.elementOrder(end, n) > 0);
+ });
+ };
+
+
+ // Returns 0 if the nodes are the same or either one contains the other;
+ // otherwise, 1 if a comes before b, or else -1 if b comes before a in
+ // document order.
+ // Requires: `a` and `b` are element nodes in the same document tree.
+ DomUtils.elementOrder = function(a, b) {
+ // See http://ejohn.org/blog/comparing-document-position/
+ if (a === b)
+ return 0;
+ if (a.compareDocumentPosition) {
+ var n = a.compareDocumentPosition(b);
+ return ((n & 0x18) ? 0 : ((n & 0x4) ? 1 : -1));
+ } else {
+ // Only old IE is known to not have compareDocumentPosition (though Safari
+ // originally lacked it). Thankfully, IE gives us a way of comparing elements
+ // via the "sourceIndex" property.
+ if (a.contains(b) || b.contains(a))
+ return 0;
+ return (a.sourceIndex < b.sourceIndex ? 1 : -1);
+ }
+ };
+
+ // Wrap `frag` as necessary to prepare it for insertion in
+ // `container`. For example, if `frag` has TR nodes at top level,
+ // and `container` is a TABLE, then it's necessary to wrap `frag` in
+ // a TBODY to avoid IE quirks.
+ //
+ // `frag` is a DocumentFragment and will be modified in
+ // place. `container` is a DOM element.
+ DomUtils.wrapFragmentForContainer = function(frag, container) {
+ if (container && container.nodeName === "TABLE" &&
+ _.any(frag.childNodes,
+ function(n) { return n.nodeName === "TR"; })) {
+ // Avoid putting a TR directly in a TABLE without an
+ // intervening TBODY, because it doesn't work in IE. We do
+ // the same thing on all browsers for ease of testing
+ // and debugging.
+ var tbody = document.createElement("TBODY");
+ tbody.appendChild(frag);
+ frag.appendChild(tbody);
+ }
+ };
+
+ // Return true if `node` is part of the global DOM document. Like
+ // elementContains(document, node), except (1) it works for any node
+ // (eg, text nodes), not just elements; (2) it works around browser
+ // quirks that would otherwise come up when passing 'document' as
+ // the first argument to elementContains.
+ //
+ // Returns true if node === document.
+ DomUtils.isInDocument = function (node) {
+ // Deal with all cases where node is not an element
+ // node descending from the body first...
+ if (node === document)
+ return true;
+
+ if (node.nodeType !== 1 /* Element */)
+ node = node.parentNode;
+ if (! (node && node.nodeType === 1))
+ return false;
+ if (node === document.body)
+ return true;
+
+ return DomUtils.elementContains(document.body, node);
+ };
+
+
+})();
diff --git a/packages/liveui/domutils.js b/packages/liveui/domutils.js
index 0c8c460f48..16cc1479f4 100644
--- a/packages/liveui/domutils.js
+++ b/packages/liveui/domutils.js
@@ -21,7 +21,7 @@ Meteor.ui._elementContains = function(a, b) {
// Returns an array of element nodes matching `selector`, where
// the selector is interpreted as rooted at `contextNode`.
// This means that all nodes that participate in the selector
-// must be descendents on contextNode.
+// must be descendents of contextNode.
//
// jQuery dependency to eventually replace with querySelectorAll
// backed up by Sizzle in Old IE. Note that querySelectorAll doesn't
@@ -46,9 +46,10 @@ Meteor.ui._findElement = function(contextNode, selector) {
}
};
-// Requires: `a` and `b` are element nodes in the same document tree.
// Returns 0 if the nodes are the same or either one contains the other;
-// otherwise, 1 if (a,b) are in order and -1 if they are in the opposite order.
+// otherwise, 1 if a comes before b, or else -1 if b comes before a in
+// document order.
+// Requires: `a` and `b` are element nodes in the same document tree.
Meteor.ui._elementOrder = function(a, b) {
// See http://ejohn.org/blog/comparing-document-position/
if (a === b)
@@ -66,8 +67,15 @@ Meteor.ui._elementOrder = function(a, b) {
}
};
-// Like `findElement` but uses a hypothetical LiveRange wrapping start..end
-// as the context.
+// Like `findElement` but searches the nodes from `start` to `end`
+// inclusive. `start` and `end` must be siblings, and they participate
+// in the search (they can be used to match selector components, and
+// they can appear in the returned results). It's as if the parent of
+// `start` and `end` serves as contextNode, but matches from children
+// that aren't between `start` and `end` (inclusive) are ignored.
+//
+// If `selector` involves sibling selectors, child index selectors, or
+// the like, the results are undefined.
Meteor.ui._findElementInRange = function(start, end, selector) {
end = (end || start);
@@ -122,6 +130,11 @@ Meteor.ui._isNodeOnscreen = function (node) {
return Meteor.ui._elementContains(document.body, node);
};
+// Wraps the contents of `frag`, a DocumentFragment, if necessary
+// to insert the fragment into `container`, a DOM element.
+// For example, if `frag` has TR nodes as children and container
+// is a TABLE, the children of `frag` will be wrapped with a
+// TBODY in place to work around IE quirks.
Meteor.ui._wrapFragmentForContainer = function(frag, container) {
if (container && container.nodeName === "TABLE" &&
_.any(frag.childNodes,
diff --git a/packages/liveui/livedocument.js b/packages/liveui/livedocument.js
index 790b1b226e..4262af18b2 100644
--- a/packages/liveui/livedocument.js
+++ b/packages/liveui/livedocument.js
@@ -126,7 +126,7 @@ Meteor.ui._doc = Meteor.ui._doc || {};
var next = comment.nextSibling;
- Meteor.ui._wrapFragmentForContainer(subFrag, comment.parentNode);
+ DomUtils.wrapFragmentForContainer(subFrag, comment.parentNode);
comment.parentNode.replaceChild(subFrag, comment);
return next;
@@ -182,7 +182,7 @@ Meteor.ui._doc = Meteor.ui._doc || {};
throw new Error("Double-GCed range: "+range.id);
if (! (node.parentNode &&
- (Meteor.ui._isNodeOnscreen(node) ||
+ (DomUtils.isInDocument(node) ||
Meteor.ui._doc._isNodeHeld(node)))) {
// range is offscreen!
// kill all ranges in this fragment or detached DOM tree,
diff --git a/packages/liveui/liveevents_w3c.js b/packages/liveui/liveevents_w3c.js
index d251536bb6..c7d3442ac7 100644
--- a/packages/liveui/liveevents_w3c.js
+++ b/packages/liveui/liveevents_w3c.js
@@ -128,7 +128,7 @@ Meteor.ui._event._loadW3CImpl = function() {
// relatedTarget is present and a descendent).
(! event.relatedTarget ||
(event.currentTarget !== event.relatedTarget &&
- ! Meteor.ui._elementContains(
+ ! DomUtils.elementContains(
event.currentTarget, event.relatedTarget)))) {
if (event.type === 'mouseover'){
sendUIEvent('mouseenter', event.currentTarget, false);
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index f7a045bcd8..839d46ad00 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -61,7 +61,7 @@ Meteor.ui = Meteor.ui || {};
Meteor.ui._inRender = false;
}
- Meteor.ui._wrapFragmentForContainer(frag, range.containerNode());
+ DomUtils.wrapFragmentForContainer(frag, range.containerNode());
// Perform patching
var nodeMatches = matchChunks(range, frag);
@@ -540,7 +540,7 @@ Meteor.ui = Meteor.ui || {};
var node = range.firstNode();
if (node.parentNode &&
- (Meteor.ui._isNodeOnscreen(node) || Sarge.isNodeHeld(node)))
+ (DomUtils.isInDocument(node) || Sarge.isNodeHeld(node)))
return false;
while (node.parentNode)
@@ -673,7 +673,7 @@ Meteor.ui = Meteor.ui || {};
var selector = h.selector;
if (selector) {
var contextNode = range.containerNode();
- var results = Meteor.ui._findElement(contextNode, selector);
+ var results = DomUtils.findElement(contextNode, selector);
if (! _.contains(results, curNode))
continue;
} else {
@@ -757,7 +757,7 @@ Meteor.ui = Meteor.ui || {};
var collectLabeledNodes = function(range, preserveMap) {
var labeledNodes = {};
_.each(preserveMap, function(labelFunc, sel) {
- var matchingNodes = Meteor.ui._findElementInRange(
+ var matchingNodes = DomUtils.findElementInRange(
range.firstNode(), range.lastNode(), sel);
_.each(matchingNodes, function(n) {
// labelFunc can be a function or a constant,
@@ -857,7 +857,7 @@ Meteor.ui = Meteor.ui || {};
if (pair) {
var tgt = pair[0];
if (! lastTgtMatch ||
- Meteor.ui._elementOrder(lastTgtMatch, tgt) > 0) {
+ DomUtils.elementOrder(lastTgtMatch, tgt) > 0) {
if (pair.rangeMatch) {
// range match! for constant chunk
if (patcher.match(pair[0], pair[1], null, true)) {
diff --git a/packages/liveui/patcher.js b/packages/liveui/patcher.js
index 5ef3bcc534..ec22f67270 100644
--- a/packages/liveui/patcher.js
+++ b/packages/liveui/patcher.js
@@ -93,7 +93,6 @@ Meteor.ui._Patcher.prototype.match = function(
var starting = ! lastKeptTgt;
var finishing = ! tgt;
- var elementContains = Meteor.ui._elementContains;
if (! starting) {
// move lastKeptTgt/lastKeptSrc forward and out,
@@ -101,7 +100,7 @@ Meteor.ui._Patcher.prototype.match = function(
// replacing as we go. If tgt/src is falsy, we make it to the
// top level.
while (lastKeptTgt.parentNode !== this.tgtParent &&
- ! (tgt && elementContains(lastKeptTgt.parentNode, tgt))) {
+ ! (tgt && DomUtils.elementContains(lastKeptTgt.parentNode, tgt))) {
// Last-kept nodes are inside parents that are not
// parents of the newly matched nodes. Must finish
// replacing their contents and back out.
From 8d769cc54631bc570764e117f152b966bdada96d Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Fri, 27 Jul 2012 15:34:09 -0700
Subject: [PATCH 045/212] remove old files with moved functions
---
packages/liveui/domutils.js | 150 --------------------------------
packages/liveui/innerhtml.js | 160 -----------------------------------
packages/liveui/package.js | 3 +-
3 files changed, 1 insertion(+), 312 deletions(-)
delete mode 100644 packages/liveui/domutils.js
delete mode 100644 packages/liveui/innerhtml.js
diff --git a/packages/liveui/domutils.js b/packages/liveui/domutils.js
deleted file mode 100644
index 16cc1479f4..0000000000
--- a/packages/liveui/domutils.js
+++ /dev/null
@@ -1,150 +0,0 @@
-Meteor.ui = Meteor.ui || {};
-
-// returns true if element a properly contains element b
-Meteor.ui._elementContains = function(a, b) {
- // Note: Some special-casing would be required to implement this method
- // where a and b aren't necessarily elements, e.g. b is a text node,
- // because contains() doesn't seem to work reliably on some browsers
- // including IE.
- if (a.nodeType !== 1 || b.nodeType !== 1) {
- return false; // a and b are not both elements
- }
- if (a.compareDocumentPosition) {
- return a.compareDocumentPosition(b) & 0x10;
- } else {
- // Should be only old IE and maybe other old browsers here.
- // Modern Safari has both methods but seems to get contains() wrong.
- return a !== b && a.contains(b);
- }
-};
-
-// Returns an array of element nodes matching `selector`, where
-// the selector is interpreted as rooted at `contextNode`.
-// This means that all nodes that participate in the selector
-// must be descendents of contextNode.
-//
-// jQuery dependency to eventually replace with querySelectorAll
-// backed up by Sizzle in Old IE. Note that querySelectorAll doesn't
-// provide the needed semantics for scoping the selector to contextNode;
-// for example, myDiv.querySelectorAll("body *") will match all of myDiv's
-// descendents, while $(myDiv).find("body *") won't match any. The latter
-// behavior is definitely better, and the way to implement it is to temporarily
-// assign an ID to contextNode (if it doesn't have one).
-Meteor.ui._findElement = function(contextNode, selector) {
- if (contextNode.nodeType === 11 /* DocumentFragment */) {
- // Sizzle doesn't work on a DocumentFragment, but it does work on
- // a descendent of one.
- var frag = contextNode;
- var container = DomUtils.fragmentToContainer(frag);
- var results = $(container).find(selector);
- // put nodes back into frag
- while (container.firstChild)
- frag.appendChild(container.firstChild);
- return results;
- } else {
- return $(contextNode).find(selector);
- }
-};
-
-// Returns 0 if the nodes are the same or either one contains the other;
-// otherwise, 1 if a comes before b, or else -1 if b comes before a in
-// document order.
-// Requires: `a` and `b` are element nodes in the same document tree.
-Meteor.ui._elementOrder = function(a, b) {
- // See http://ejohn.org/blog/comparing-document-position/
- if (a === b)
- return 0;
- if (a.compareDocumentPosition) {
- var n = a.compareDocumentPosition(b);
- return ((n & 0x18) ? 0 : ((n & 0x4) ? 1 : -1));
- } else {
- // Only old IE is known to not have compareDocumentPosition (though Safari
- // originally lacked it). Thankfully, IE gives us a way of comparing elements
- // via the "sourceIndex" property.
- if (a.contains(b) || b.contains(a))
- return 0;
- return (a.sourceIndex < b.sourceIndex ? 1 : -1);
- }
-};
-
-// Like `findElement` but searches the nodes from `start` to `end`
-// inclusive. `start` and `end` must be siblings, and they participate
-// in the search (they can be used to match selector components, and
-// they can appear in the returned results). It's as if the parent of
-// `start` and `end` serves as contextNode, but matches from children
-// that aren't between `start` and `end` (inclusive) are ignored.
-//
-// If `selector` involves sibling selectors, child index selectors, or
-// the like, the results are undefined.
-Meteor.ui._findElementInRange = function(start, end, selector) {
- end = (end || start);
-
- var container = start.parentNode;
- if (! container) {
- if (start === end && (start.nodeType === 9 /* Document */ ||
- start.nodeType === 11 /* DocumentFragment */))
- return Meteor.ui._findElement(start, selector);
- throw new Error("Can't find element in range on detached node");
- }
- if (end.parentNode !== container)
- throw new Error("Bad range");
-
- // narrow the range to exclude top-level non-elements (which can't be
- // or contain matches) by moving the `start` pointer forward and `end`
- // backward.
- while (start !== end && start.nodeType !== 1)
- start = start.nextSibling;
- while (start !== end && end.nodeType !== 1)
- end = end.previousSibling;
- if (start.nodeType !== 1)
- return []; // no top-level elements! start === end and it's not an element
-
- // resultsPlus includes matches that are contained by the range's
- // parent, but are outside of start..end, i.e. are descended from
- // (or are) a different sibling.
- var resultsPlus = Meteor.ui._findElement(container, selector);
-
- // Filter the list of nodes to remove nodes that occur before start
- // or after end.
- return _.reject(resultsPlus, function(n) {
- // reject node if (n,start) are in order or (end,n) are in order
- return (Meteor.ui._elementOrder(n, start) > 0) ||
- (Meteor.ui._elementOrder(end, n) > 0);
- });
-};
-
-// Check whether a node is contained in the document.
-Meteor.ui._isNodeOnscreen = function (node) {
- // Deal with all cases where node is not an element
- // node descending from the body first...
- if (node === document)
- return true;
-
- if (node.nodeType !== 1 /* Element */)
- node = node.parentNode;
- if (! (node && node.nodeType === 1))
- return false;
- if (node === document.body)
- return true;
-
- return Meteor.ui._elementContains(document.body, node);
-};
-
-// Wraps the contents of `frag`, a DocumentFragment, if necessary
-// to insert the fragment into `container`, a DOM element.
-// For example, if `frag` has TR nodes as children and container
-// is a TABLE, the children of `frag` will be wrapped with a
-// TBODY in place to work around IE quirks.
-Meteor.ui._wrapFragmentForContainer = function(frag, container) {
- if (container && container.nodeName === "TABLE" &&
- _.any(frag.childNodes,
- function(n) { return n.nodeName === "TR"; })) {
- // Avoid putting a TR directly in a TABLE without an
- // intervening TBODY, because it doesn't work in IE. We do
- // the same thing on all browsers for ease of testing
- // and debugging.
- var tbody = document.createElement("TBODY");
- tbody.appendChild(frag);
- frag.appendChild(tbody);
- }
-};
diff --git a/packages/liveui/innerhtml.js b/packages/liveui/innerhtml.js
deleted file mode 100644
index 62289ea77e..0000000000
--- a/packages/liveui/innerhtml.js
+++ /dev/null
@@ -1,160 +0,0 @@
-Meteor.ui = Meteor.ui || {};
-
-// Define Meteor.ui._htmlToFragment and Meteor.ui._fragmentToHtml.
-// Adapted from jquery's html() and "clean" routines.
-//
-// _fragmentToHtml is only used in test code and could be moved
-// into a non-core package.
-_.extend(Meteor.ui, (function() {
-
- // --- One-time set-up:
-
- var testDiv = document.createElement("div");
- testDiv.innerHTML = "
";
-
- // Tests that, if true, indicate browser quirks present.
- var quirks = {
- // IE loses initial whitespace when setting innerHTML.
- leadingWhitespaceKilled: (testDiv.firstChild.nodeType !== 3),
-
- // IE may insert an empty tbody tag in a table.
- tbodyInsertion: testDiv.getElementsByTagName("tbody").length > 0,
-
- // IE loses some tags in some environments (requiring extra wrapper).
- tagsLost: testDiv.getElementsByTagName("link").length === 0
- };
-
- // Set up map of wrappers for different nodes.
- var wrapMap = {
- option: [ 1, "" ],
- legend: [ 1, "" ],
- thead: [ 1, "
", "
" ],
- tr: [ 2, "
", "
" ],
- td: [ 3, "
", "
" ],
- col: [ 2, "
", "
" ],
- area: [ 1, "" ],
- _default: [ 0, "", "" ]
- };
- _.extend(wrapMap, {
- optgroup: wrapMap.option,
- tbody: wrapMap.thead,
- tfoot: wrapMap.thead,
- colgroup: wrapMap.thead,
- caption: wrapMap.thead,
- th: wrapMap.td
- });
- if (quirks.tagsLost) {
- // trick from jquery. initial text is ignored when we take lastChild.
- wrapMap._default = [ 1, "div
", "
" ];
- }
-
- var rleadingWhitespace = /^\s+/,
- rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/ig,
- rtagName = /<([\w:]+)/,
- rtbody = /$2>");
- // Use first tag to determine wrapping needed.
- var firstTagMatch = rtagName.exec(html);
- var firstTag = (firstTagMatch ? firstTagMatch[1].toLowerCase() : "");
- var wrapData = wrapMap[firstTag] || wrapMap._default;
-
- var container = doc.createElement("div");
- // insert wrapped HTML into a DIV
- container.innerHTML = wrapData[1] + html + wrapData[2];
- // set "container" to inner node of wrapper
- var unwraps = wrapData[0];
- while (unwraps--) {
- container = container.lastChild;
- }
-
- if (quirks.tbodyInsertion && ! rtbody.test(html)) {
- // Any tbody we find was created by the browser.
- var tbodies = container.getElementsByTagName("tbody");
- _.each(tbodies, function(n) {
- if (! n.firstChild) {
- // spurious empty tbody
- n.parentNode.removeChild(n);
- }
- });
- }
-
- if (quirks.leadingWhitespaceKilled) {
- var wsMatch = rleadingWhitespace.exec(html);
- if (wsMatch) {
- container.insertBefore(doc.createTextNode(wsMatch[0]),
- container.firstChild);
- }
- }
-
- // Reparent children of container to frag.
- while (container.firstChild)
- frag.appendChild(container.firstChild);
- }
-
- return frag;
- },
- // Put children of frag into a suitable DOM node, e.g. a DIV
- // or a TABLE as appropriate, for the purpose of using
- // innerHTML or a Sizzle/jQuery selectors.
- _fragmentToContainer: function(frag) {
- var doc = document; // node factory
-
- var firstElement = frag.firstChild;
- while (firstElement && firstElement.nodeType !== 1) {
- firstElement = firstElement.nextSibling;
- }
-
- var container = doc.createElement("div");
-
- if (! firstElement) {
- // no tags!
- container.appendChild(frag);
- } else {
- var firstTag = firstElement.nodeName;
- var wrapData = wrapMap[firstTag] || wrapMap._default;
-
- container.innerHTML = wrapData[1] + wrapData[2];
- var unwraps = wrapData[0];
- while (unwraps--) {
- container = container.lastChild;
- }
-
- container.appendChild(frag);
- }
-
- return container;
- },
- _fragmentToHtml: function(frag) {
- frag = frag.cloneNode(true); // deep copy, don't touch original!
-
- return Meteor.ui._fragmentToContainer(frag).innerHTML;
- },
- _rangeToHtml: function(liverange) {
- var frag = document.createDocumentFragment();
- for(var n = liverange.firstNode(),
- after = liverange.lastNode().nextSibling;
- n && n !== after;
- n = n.nextSibling)
- frag.appendChild(n.cloneNode(true)); // deep copy
- return Meteor.ui._fragmentToHtml(frag);
- }
- };
-})());
-
diff --git a/packages/liveui/package.js b/packages/liveui/package.js
index a02b2cf6f9..2ba701351d 100644
--- a/packages/liveui/package.js
+++ b/packages/liveui/package.js
@@ -13,11 +13,10 @@ Package.on_use(function (api) {
// you still want the event object normalization that jquery provides?)
api.use('jquery');
- api.add_files(['domutils.js'], 'client');
api.add_files(['liveevents_w3c.js', 'liveevents_now3c.js'], 'client');
api.add_files(['liveevents.js'], 'client');
api.add_files(['livedocument.js'], 'client');
- api.add_files(['liveui.js', 'innerhtml.js', 'patcher.js'],
+ api.add_files(['liveui.js', 'patcher.js'],
'client');
});
From e6de65be763116f29b0541543b32da1e19a30a21 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Fri, 27 Jul 2012 17:46:56 -0700
Subject: [PATCH 046/212] Spark.render with tests!
---
packages/liverange/liverange_test_helpers.js | 11 ---
packages/spark/package.js | 18 +---
packages/spark/spark.js | 96 ++++++++++++++++----
packages/spark/spark_tests.js | 66 ++++++++++++++
packages/test-helpers/liverange_helpers.js | 10 ++
packages/test-helpers/package.js | 3 +-
6 files changed, 158 insertions(+), 46 deletions(-)
create mode 100644 packages/spark/spark_tests.js
create mode 100644 packages/test-helpers/liverange_helpers.js
diff --git a/packages/liverange/liverange_test_helpers.js b/packages/liverange/liverange_test_helpers.js
index 7def8cd046..a42f31075b 100644
--- a/packages/liverange/liverange_test_helpers.js
+++ b/packages/liverange/liverange_test_helpers.js
@@ -40,14 +40,3 @@ var check_liverange_integrity = function (range) {
if (stack.length)
throw new Error("integrity check failed - missing close tags");
};
-
-// Dump out the contents of a LiveRange as an HTML string.
-var rangeToHtml = function(liverange) {
- var frag = document.createDocumentFragment();
- for(var n = liverange.firstNode(),
- after = liverange.lastNode().nextSibling;
- n && n !== after;
- n = n.nextSibling)
- frag.appendChild(n.cloneNode(true)); // deep copy
- return DomUtils.fragmentToHtml(frag);
-};
\ No newline at end of file
diff --git a/packages/spark/package.js b/packages/spark/package.js
index 2f78e7e823..1210312f7a 100644
--- a/packages/spark/package.js
+++ b/packages/spark/package.js
@@ -5,7 +5,7 @@ Package.describe({
Package.on_use(function (api) {
api.use('livedata');
- api.use(['underscore', 'session'], 'client');
+ api.use(['underscore', 'session', 'domutils'], 'client');
// XXX Depends on jquery because we need a selector engine to resolve
// event maps. What would be nice is, if you've included jquery or
@@ -24,20 +24,10 @@ Package.on_use(function (api) {
});
Package.on_test(function (api) {
-/*
- api.use(['tinytest', 'templating', 'htmljs']);
- api.use(['liveui', 'test-helpers'], 'client');
-
- api.add_files('form_responder.js', 'server');
+ api.use('tinytest');
+ api.use(['spark', 'test-helpers'], 'client');
api.add_files([
- 'livedocument_tests.js',
- 'liverange_test_helpers.js',
- 'liveui_tests.js',
- 'liveui_tests.html',
- 'liverange_tests.js',
- 'patcher_tests.js',
- 'liveevents_tests.js'
+ 'spark_tests.js'
], 'client');
-*/
});
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 451370fc92..312ac73e66 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -2,7 +2,7 @@
// in liverange-land they should probably start with "_"?
-Spark = Spark || {};
+Spark = {};
Spark._currentRenderer = new Meteor.EnvironmentVariable;
@@ -18,66 +18,122 @@ _.extend(Spark._Renderer.prototype, {
var chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
for (var i = 0; i < 8; i++) {
- id += hexDigits.substr(Math.floor(Meteor.random() * 64), 1);
+ id += chars.substr(Math.floor(Meteor.random() * 64), 1);
}
return id;
},
// what can be a function that takes a LiveRange, or just a set of
- // attributes to add to the liverange.
+ // attributes to add to the liverange. tag and what are optional.
+ // if no tag is passed, no liverange will be created.
annotate: function (html, tag, what) {
var id = tag + "-" + this.createId();
this.annotations[id] = function (start, end) {
+ if (! tag)
+ return;
var range = new LiveRange(tag, start, end);
if (what instanceof Function)
what(range);
else
_.extend(range, what);
- }
+ };
return "<$" + id + ">" + html + "$" + id + ">";
}
});
Spark.render = function (htmlFunc) {
- var renderer = new Spark.Renderer;
- var html = Spark.currentRenderer.withValue(renderer, function () {
- return Spark.barrier(htmlFunc);
+ var renderer = new Spark._Renderer;
+ var html = Spark._currentRenderer.withValue(renderer, function () {
+ return renderer.annotate(htmlFunc());
});
- // XXX turn html into DOM and attach liveranges
+ var fragById = {};
+ var replaceInclusions = function (container) {
+ var n = container.firstChild;
+ while (n) {
+ var next = n.nextSibling;
+ if (n.nodeType === 8) { // COMMENT
+ var frag = fragById[n.nodeValue];
+ if (frag === false) {
+ // id already used!
+ throw new Error("Spark HTML fragments may only be used once. " +
+ "Second use in " +
+ DomUtils.fragmentToHtml(container));
+ } else if (frag) {
+ fragById[n.nodeValue] = false; // mark as used
+ DomUtils.wrapFragmentForContainer(frag, n.parentNode);
+ n.parentNode.replaceChild(frag, n);
+ }
+ } else if (n.nodeType === 1) { // ELEMENT
+ replaceInclusions(n);
+ }
+ n = next;
+ }
+ };
- // HERE
- //
- // - Create DomUtils package (or something like that)
- // - First thing in DomUtils is htmlToFragment from innerhtml.js
- // - Later, will add stuff from domutils.js
- // - _rangeToHtml will go in LiveRange (possibly the test helpers)
+ var bufferStack = [[]];
+ var idStack = [];
+ var regex = /<(\/?)\$([^<>]+)>|<|[^<]+/g;
+ regex.lastIndex = 0;
+ var parts;
+ while ((parts = regex.exec(html))) {
+ var isOpen = ! parts[1];
+ var id = parts[2];
+ var annotationFunc = renderer.annotations[id];
+ if (! annotationFunc) {
+ bufferStack[bufferStack.length - 1].push(parts[0]);
+ } else if (isOpen) {
+ idStack.push(id);
+ bufferStack.push([]);
+ } else {
+ var idOnStack = idStack.pop();
+ if (idOnStack !== id)
+ throw new Error("Range mismatch: " + idOnStack + " / " + id);
+ var frag = DomUtils.htmlToFragment(bufferStack.pop().join(''));
+ replaceInclusions(frag);
+ // empty frag becomes HTML comment so we have start/end
+ // nodes to pass to the annotation function
+ if (! frag.firstChild)
+ frag.appendChild(document.createComment("empty"));
+ annotationFunc(frag.firstChild, frag.lastChild);
+ if (! idStack.length)
+ // we're done; we just rendered the contents of the top-level
+ // annotation that we wrapped around htmlFunc ourselves.
+ // there may be unused fragments in fragById that include
+ // LiveRanges, but only if the user broke the rules by including
+ // an annotation somewhere besides element level, like inside
+ // an attribute (which is not allowed).
+ return frag;
+ fragById[id] = frag;
+ bufferStack[bufferStack.length - 1].push('');
+ }
+ }
};
-Spark.setContext = function (html, context) {
+Spark.setDataContext = function (html, context) {
var renderer = Spark._currentRenderer.get();
if (!renderer)
return html;
- return renderer.annotate(html, "_context", { context: context });
+ return renderer.annotate(html, "_data", { context: context });
};
-Spark.getContext = function (node) {
- var range = LiveRange.findRange("_context", node);
+Spark.getDataContext = function (node) {
+ var range = LiveRange.findRange("_data", node);
return range && range.context;
}
-Spark.barrier = function (htmlFunc) {
+Spark.isolate = function (htmlFunc) {
var renderer = Spark._currentRenderer.get();
if (!renderer)
return htmlFunc();
var ctx = new Meteor.deps.Context;
var html =
- renderer.annotate(ctx.run(htmlFunc), "_barrier", function (range) {
+ renderer.annotate(ctx.run(htmlFunc), "_isolate", function (range) {
ctx.on_invalidate(function () {
// XXX update with patching
});
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
new file mode 100644
index 0000000000..38ad86b69c
--- /dev/null
+++ b/packages/spark/spark_tests.js
@@ -0,0 +1,66 @@
+
+Tinytest.add("spark - assembly", function (test) {
+
+ var doTest = function(calc) {
+ var frag = Spark.render(function() {
+ return calc(function(str, expected) {
+ return Spark.setDataContext(str, null);
+ });
+ });
+ var groups = [];
+ var html = calc(function(str, expected, noRange) {
+ if (arguments.length > 1)
+ str = expected;
+ if (! noRange)
+ groups.push(str);
+ return str;
+ });
+ var f = WrappedFrag(frag);
+ test.equal(f.html(), html);
+
+ var actualGroups = [];
+ var tempRange = new LiveRange("_data", frag);
+ tempRange.visit(function(isStart, rng) {
+ if (! isStart)
+ actualGroups.push(rangeToHtml(rng));
+ });
+ test.equal(actualGroups.join(','), groups.join(','));
+ };
+
+ doTest(function(A) { return "
');
+
+});
\ No newline at end of file
From 13dbbc7a554b3dfc31aef8e7ef656922b182ecf0 Mon Sep 17 00:00:00 2001
From: Geoff Schmidt
Date: Fri, 27 Jul 2012 18:46:29 -0700
Subject: [PATCH 048/212] Fast and slow GC for Spark.isolate (untested)
---
packages/spark/spark.js | 51 +++++++++++++++++++++++++++++++++--------
1 file changed, 42 insertions(+), 9 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index ad41e97b31..4321ec977d 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -113,17 +113,17 @@ Spark.render = function (htmlFunc) {
}
};
-Spark.setDataContext = function (html, context) {
+Spark.setDataContext = function (html, dataContext) {
var renderer = Spark._currentRenderer.get();
if (!renderer)
return html;
- return renderer.annotate(html, "_data", { context: context });
+ return renderer.annotate(html, "_data", { data: dataContext });
};
Spark.getDataContext = function (node) {
var range = LiveRange.findRange("_data", node);
- return range && range.context;
+ return range && range.data;
}
Spark.isolate = function (htmlFunc) {
@@ -132,21 +132,54 @@ Spark.isolate = function (htmlFunc) {
return htmlFunc();
var ctx = new Meteor.deps.Context;
+ var slain = false;
var html =
renderer.annotate(ctx.run(htmlFunc), "_isolate", function (range) {
+ range.finalize = function () {
+ // "Fast" GC path -- someone called _finalize on a document
+ // fragment that includes us, so we're cleaning up our
+ // invalidation context and going away.
+ slain = true;
+ ctx.invalidate();
+ };
+
ctx.on_invalidate(function () {
- // XXX update with patching
+ if (slain)
+ return; // killed by finalize
+
+ if (!DomUtils.isInDocument(range.firstNode())) {
+ // "Slow" GC path -- Evidently the user took some DOM nodes
+ // offscreen without telling us. Finalize them.
+ var node = range.firstNode();
+ while (node.parentNode)
+ node = node.parentNode;
+ Spark._finalize(node);
+ return;
+ }
+
+ // htmlFunc changed its mind about what it returns. Rerender it.
var frag = Spark.render(function () {
return Spark.isolate(htmlFunc);
});
- var oldContents = range.replace_contents(frag); // (should patch)
+ var oldContents = range.replace_contents(frag); // XXX should patch
+ Spark._finalize(oldContents);
range.destroy();
- // (GC oldContents)
-
- // later:
- // GC, rewire, patching, etc.
});
});
return html;
};
+
+
+// Delete all of the liveranges in the range of nodes between `start`
+// and `end`, and call their 'finalize' function if any. Or instead of
+// `start` and `end` you may pass a fragment in `start`.
+Spark._finalize = function (start, end) {
+ _.each(["_data", "_isolate"], function (tag) {
+ var wrapper = new LiveRange(tag, start, end);
+ wrapper.visit(function (isStart, range) {
+ isStart && range.finalize && finalize();
+ });
+ wrapper.destroy(true /* recursive */);
+ });
+};
\ No newline at end of file
From 17a1d972092a5ae5a3e89a7ae2db463e7bd56086 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Fri, 27 Jul 2012 19:41:27 -0700
Subject: [PATCH 049/212] Meteor.render and some liveui tests
---
packages/spark/convenience.js | 9 +++
packages/spark/package.js | 2 +-
packages/spark/spark.js | 24 +++++---
packages/spark/spark_tests.js | 86 +++++++++++++++++++++++++++-
packages/test-helpers/wrappedfrag.js | 17 +++++-
5 files changed, 124 insertions(+), 14 deletions(-)
create mode 100644 packages/spark/convenience.js
diff --git a/packages/spark/convenience.js b/packages/spark/convenience.js
new file mode 100644
index 0000000000..21e60bc2d7
--- /dev/null
+++ b/packages/spark/convenience.js
@@ -0,0 +1,9 @@
+Meteor.render = function (htmlFunc) {
+ return Spark.render(function () {
+ return Spark.isolate(
+ typeof htmlFunc === 'function' ? htmlFunc : function() {
+ // non-function argument becomes a constant (non-reactive) string
+ return String(htmlFunc);
+ });
+ });
+};
diff --git a/packages/spark/package.js b/packages/spark/package.js
index 1210312f7a..9a4e32c50a 100644
--- a/packages/spark/package.js
+++ b/packages/spark/package.js
@@ -13,7 +13,7 @@ Package.on_use(function (api) {
// you still want the event object normalization that jquery provides?)
api.use('jquery');
- api.add_files('spark.js', 'client');
+ api.add_files(['spark.js', 'convenience.js'], 'client');
/*
api.add_files(['liveevents_w3c.js', 'liveevents_now3c.js'], 'client');
api.add_files(['liveevents.js'], 'client');
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 4321ec977d..373f59a97c 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -1,7 +1,6 @@
// XXX figure out good liverange tag names. should they be symbolic constants?
// in liverange-land they should probably start with "_"?
-
Spark = {};
Spark._currentRenderer = new Meteor.EnvironmentVariable;
@@ -42,6 +41,8 @@ _.extend(Spark._Renderer.prototype, {
}
});
+////////// PUBLIC API
+
Spark.render = function (htmlFunc) {
var renderer = new Spark._Renderer;
var html = Spark._currentRenderer.withValue(renderer, function () {
@@ -136,7 +137,7 @@ Spark.isolate = function (htmlFunc) {
var html =
renderer.annotate(ctx.run(htmlFunc), "_isolate", function (range) {
range.finalize = function () {
- // "Fast" GC path -- someone called _finalize on a document
+ // "Fast" GC path -- someone called finalize on a document
// fragment that includes us, so we're cleaning up our
// invalidation context and going away.
slain = true;
@@ -145,7 +146,7 @@ Spark.isolate = function (htmlFunc) {
ctx.on_invalidate(function () {
if (slain)
- return; // killed by finalize
+ return; // killed by finalize. range has already been destroyed.
if (!DomUtils.isInDocument(range.firstNode())) {
// "Slow" GC path -- Evidently the user took some DOM nodes
@@ -153,8 +154,13 @@ Spark.isolate = function (htmlFunc) {
var node = range.firstNode();
while (node.parentNode)
node = node.parentNode;
- Spark._finalize(node);
- return;
+ if (node["_protect"]) {
+ // test code can use this property to mark a root-level node
+ // (such as a DocumentFragment) as immune from slow-path GC
+ } else {
+ Spark.finalize(node);
+ return;
+ }
}
// htmlFunc changed its mind about what it returns. Rerender it.
@@ -162,7 +168,7 @@ Spark.isolate = function (htmlFunc) {
return Spark.isolate(htmlFunc);
});
var oldContents = range.replace_contents(frag); // XXX should patch
- Spark._finalize(oldContents);
+ Spark.finalize(oldContents);
range.destroy();
});
});
@@ -174,12 +180,12 @@ Spark.isolate = function (htmlFunc) {
// Delete all of the liveranges in the range of nodes between `start`
// and `end`, and call their 'finalize' function if any. Or instead of
// `start` and `end` you may pass a fragment in `start`.
-Spark._finalize = function (start, end) {
+Spark.finalize = function (start, end) {
_.each(["_data", "_isolate"], function (tag) {
var wrapper = new LiveRange(tag, start, end);
wrapper.visit(function (isStart, range) {
- isStart && range.finalize && finalize();
+ isStart && range.finalize && range.finalize();
});
wrapper.destroy(true /* recursive */);
});
-};
\ No newline at end of file
+};
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index cf6ed33c2c..02fdb2b805 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -84,5 +84,87 @@ Tinytest.add("spark - basic isolate", function (test) {
R.set('baz');
Meteor.flush();
test.equal(div.html(), '
baz
');
-
-});
\ No newline at end of file
+
+});
+
+Tinytest.add("liveui - one render", function(test) {
+
+ var R = ReactiveVar("foo");
+
+ var frag = WrappedFrag(Meteor.render(function() {
+ return R.get();
+ })).hold();
+
+ test.equal(R.numListeners(), 1);
+
+ // frag should be "foo" initially
+ test.equal(frag.html(), "foo");
+ R.set("bar");
+ // haven't flushed yet, so update won't have happened
+ test.equal(frag.html(), "foo");
+ Meteor.flush();
+ // flushed now, frag should say "bar"
+ test.equal(frag.html(), "bar");
+ frag.release(); // frag is now considered offscreen
+ Meteor.flush();
+ R.set("baz");
+ Meteor.flush();
+ // no update should have happened, offscreen range dep killed
+ test.equal(frag.html(), "bar");
+
+ // should be back to no listeners
+ test.equal(R.numListeners(), 0);
+
+ // empty return value should work, and show up as a comment
+ frag = WrappedFrag(Meteor.render(function() {
+ return "";
+ }));
+ test.equal(frag.html(), "");
+
+ // nodes coming and going at top level of fragment
+ R.set(true);
+ frag = WrappedFrag(Meteor.render(function() {
+ return R.get() ? "
underlined");
- test.equal(R.numListeners(), 1);
-
- div.remove();
- R.set(789); // update should force div dependency to be GCed when div is updated
- Meteor.flush();
- test.equal(R.numListeners(), 0);
-});
Tinytest.add("liveui - tables", function(test) {
var R = ReactiveVar(0);
@@ -547,211 +436,6 @@ Tinytest.add("liveui - bad labels", function(test) {
'
');
-
- frag.release();
-
- // calling chunk() outside of render mode
- test.equal(Meteor.ui.chunk(function() { return "foo"; }), "foo");
-
- // caller violating preconditions
-
- test.throws(function() {
- Meteor.ui.render(function() {
- return Meteor.ui.chunk("foo");
- });
- });
-
- test.throws(function() {
- Meteor.ui.render(function() {
- return Meteor.ui.chunk(function() {
- return {};
- });
- });
- });
-
-
- // unused chunk
-
- var Q = ReactiveVar("foo");
- Meteor.ui.render(function() {
- // create a chunk, in render mode,
- // but don't use it.
- Meteor.ui.chunk(function() {
- return Q.get();
- });
- return "";
- });
- Q.set("bar");
- // might get an error on flush() if implementation
- // deals poorly with unused chunks, or a listener
- // still existing after flush.
- Meteor.flush();
- test.equal(Q.numListeners(), 0);
-
- // nesting
-
- var stuff = ReactiveVar(true);
- var div = OnscreenDiv(Meteor.ui.render(function() {
- return Meteor.ui.chunk(function() {
- return "x"+(stuff.get() ? 'y' : '') + Meteor.ui.chunk(function() {
- return "hi";
- });
- });
- }));
- test.equal(div.html(), "xyhi");
- stuff.set(false);
- Meteor.flush();
- test.equal(div.html(), "xhi");
- div.kill();
- Meteor.flush();
-
- // more nesting
-
- var num1 = ReactiveVar(false);
- var num2 = ReactiveVar(false);
- var num3 = ReactiveVar(false);
- var numset = function(n) {
- _.each([num1, num2, num3], function(v, i) {
- v.set((i+1) === n);
- });
- };
- numset(1);
-
- var div = OnscreenDiv(Meteor.ui.render(function() {
- return Meteor.ui.chunk(function() {
- // This test was twiddled to try to catch a theorized bug
- // around nested chunks with no surrounding nodes, but
- // there was no bug. Feel free to twiddle again.
- return (num1.get() ? '' : '')+
- Meteor.ui.chunk(function() {
- return (num2.get() ? '' : '')+
- Meteor.ui.chunk(function() {
- return (num3.get() ? '3' : '')+'x';
- });
- });
- });
- }));
- test.equal(div.html(), "x");
- numset(2);
- Meteor.flush();
- test.equal(div.html(), "x");
- numset(3);
- Meteor.flush();
- test.equal(div.html(), "3x");
- numset(1);
- Meteor.flush();
- test.equal(div.html(), "x");
- numset(3);
- Meteor.flush();
- test.equal(div.html(), "3x");
- numset(2);
- Meteor.flush();
- test.equal(div.html(), "x");
- div.kill();
- Meteor.flush();
- test.equal(num1.numListeners(), 0);
- test.equal(num2.numListeners(), 0);
- test.equal(num3.numListeners(), 0);
-});
-
Tinytest.add("liveui - repeated chunk", function(test) {
test.throws(function() {
var frag = Meteor.ui.render(function() {
From 6dcef0ccac73ebb2659ae0dbd42cf566a1def4c9 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Fri, 27 Jul 2012 20:33:44 -0700
Subject: [PATCH 052/212] minor
---
packages/spark/spark.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index ac71db66ff..5f086991ac 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -125,7 +125,7 @@ Spark.setDataContext = function (html, dataContext) {
Spark.getDataContext = function (node) {
var range = LiveRange.findRange("_data", node);
return range && range.data;
-}
+};
Spark.isolate = function (htmlFunc) {
var renderer = Spark._currentRenderer.get();
@@ -182,7 +182,7 @@ Spark.isolate = function (htmlFunc) {
// `start` and `end` you may pass a fragment in `start`.
Spark.finalize = function (start, end) {
if (! start.parentNode && start.nodeType !== 11 /* DocumentFragment */) {
- // Workaround for LiveRange's current lack of ability to contain
+ // Workaround for LiveRanges' current inability to contain
// a node with no parentNode.
var frag = document.createDocumentFragment();
frag.appendChild(start);
From 58ff74bfe7e7e573c0a9f03d4385dbe1f3305c24 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Sun, 29 Jul 2012 20:57:37 -0700
Subject: [PATCH 053/212] symbolic names for annotations
---
packages/spark/spark.js | 87 ++++++++++++++++++++---------------
packages/spark/spark_tests.js | 2 +-
2 files changed, 52 insertions(+), 37 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 5f086991ac..64de543419 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -5,13 +5,24 @@ Spark = {};
Spark._currentRenderer = new Meteor.EnvironmentVariable;
+Spark._ANNOTATION_DATA = "_spark_data";
+Spark._ANNOTATION_ISOLATE = "_spark_isolate";
+Spark._ANNOTATIONS = [Spark._ANNOTATION_DATA, Spark._ANNOTATION_ISOLATE];
+
Spark._Renderer = function () {
// Map from annotation ID to an annotation function, which is called
- // at render time and receives (startNode, endNode.)
+ // at render time and receives (startNode, endNode).
this.annotations = {};
};
_.extend(Spark._Renderer.prototype, {
+ // The annotation tags that we insert into HTML strings must be
+ // unguessable in order to not create potential cross-site scripting
+ // attack vectors, so we use random strings. Even a well-written app
+ // that avoids XSS vulnerabilities might, for example, put
+ // unescaped < and > in HTML attribute values, where they are normally
+ // safe. We can't assume that a string like '<1>' came from us
+ // and not arbitrary user-entered data.
createId: function () {
var id = "";
var chars =
@@ -22,7 +33,7 @@ _.extend(Spark._Renderer.prototype, {
return id;
},
- // what can be a function that takes a LiveRange, or just a set of
+ // `what` can be a function that takes a LiveRange, or just a set of
// attributes to add to the liverange. tag and what are optional.
// if no tag is passed, no liverange will be created.
annotate: function (html, tag, what) {
@@ -119,11 +130,13 @@ Spark.setDataContext = function (html, dataContext) {
if (!renderer)
return html;
- return renderer.annotate(html, "_data", { data: dataContext });
+ return renderer.annotate(
+ html, Spark._ANNOTATION_DATA, { data: dataContext });
};
Spark.getDataContext = function (node) {
- var range = LiveRange.findRange("_data", node);
+ var range = LiveRange.findRange(
+ Spark._ANNOTATION_DATA, node);
return range && range.data;
};
@@ -135,43 +148,45 @@ Spark.isolate = function (htmlFunc) {
var ctx = new Meteor.deps.Context;
var slain = false;
var html =
- renderer.annotate(ctx.run(htmlFunc), "_isolate", function (range) {
- range.finalize = function () {
- // "Fast" GC path -- someone called finalize on a document
- // fragment that includes us, so we're cleaning up our
- // invalidation context and going away.
- slain = true;
- ctx.invalidate();
- };
+ renderer.annotate(
+ ctx.run(htmlFunc), Spark._ANNOTATION_ISOLATE,
+ function (range) {
+ range.finalize = function () {
+ // "Fast" GC path -- someone called finalize on a document
+ // fragment that includes us, so we're cleaning up our
+ // invalidation context and going away.
+ slain = true;
+ ctx.invalidate();
+ };
- ctx.on_invalidate(function () {
- if (slain)
- return; // killed by finalize. range has already been destroyed.
+ ctx.on_invalidate(function () {
+ if (slain)
+ return; // killed by finalize. range has already been destroyed.
- if (!DomUtils.isInDocument(range.firstNode())) {
- // "Slow" GC path -- Evidently the user took some DOM nodes
- // offscreen without telling us. Finalize them.
- var node = range.firstNode();
- while (node.parentNode)
- node = node.parentNode;
- if (node["_protect"]) {
- // test code can use this property to mark a root-level node
- // (such as a DocumentFragment) as immune from slow-path GC
- } else {
- Spark.finalize(node);
- return;
+ if (!DomUtils.isInDocument(range.firstNode())) {
+ // "Slow" GC path -- Evidently the user took some DOM nodes
+ // offscreen without telling us. Finalize them.
+ var node = range.firstNode();
+ while (node.parentNode)
+ node = node.parentNode;
+ if (node["_protect"]) {
+ // test code can use this property to mark a root-level node
+ // (such as a DocumentFragment) as immune from slow-path GC
+ } else {
+ Spark.finalize(node);
+ return;
+ }
}
- }
- // htmlFunc changed its mind about what it returns. Rerender it.
- var frag = Spark.render(function () {
- return Spark.isolate(htmlFunc);
+ // htmlFunc changed its mind about what it returns. Rerender it.
+ var frag = Spark.render(function () {
+ return Spark.isolate(htmlFunc);
+ });
+ var oldContents = range.replace_contents(frag); // XXX should patch
+ Spark.finalize(oldContents);
+ range.destroy();
});
- var oldContents = range.replace_contents(frag); // XXX should patch
- Spark.finalize(oldContents);
- range.destroy();
});
- });
return html;
};
@@ -189,7 +204,7 @@ Spark.finalize = function (start, end) {
start = frag;
end = null;
}
- _.each(["_data", "_isolate"], function (tag) {
+ _.each(Spark._ANNOTATIONS, function (tag) {
var wrapper = new LiveRange(tag, start, end);
wrapper.visit(function (isStart, range) {
isStart && range.finalize && range.finalize();
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index 81ef6a76a9..6a339580b4 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -19,7 +19,7 @@ Tinytest.add("spark - assembly", function (test) {
test.equal(f.html(), html);
var actualGroups = [];
- var tempRange = new LiveRange("_data", frag);
+ var tempRange = new LiveRange(Spark._ANNOTATION_DATA, frag);
tempRange.visit(function(isStart, rng) {
if (! isStart)
actualGroups.push(rangeToHtml(rng));
From 00f192b895494e09f4153bd43b84ffded30fb021 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 30 Jul 2012 10:51:23 -0700
Subject: [PATCH 054/212] removed comment
---
packages/spark/spark.js | 3 ---
1 file changed, 3 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 64de543419..5cc956d4c5 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -1,6 +1,3 @@
-// XXX figure out good liverange tag names. should they be symbolic constants?
-// in liverange-land they should probably start with "_"?
-
Spark = {};
Spark._currentRenderer = new Meteor.EnvironmentVariable;
From 787b469e096d951da0368f3b12ea53ec57050eae Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 30 Jul 2012 11:08:39 -0700
Subject: [PATCH 055/212] DomUtils.findElement -> findAll
---
packages/domutils/domutils.js | 22 +++++++++++++++++-----
1 file changed, 17 insertions(+), 5 deletions(-)
diff --git a/packages/domutils/domutils.js b/packages/domutils/domutils.js
index 3da6d8c03d..b136993288 100644
--- a/packages/domutils/domutils.js
+++ b/packages/domutils/domutils.js
@@ -181,7 +181,7 @@ DomUtils = {};
// array.)
//
// `contextNode` may be either a node, a document, or a DocumentFragment.
- DomUtils.findElement = function(contextNode, selector) {
+ DomUtils.findAll = function(contextNode, selector) {
// Eventually, we will remove the dependency on jQuery ($) and
// implement this in terms of querySelectorAll on modern browsers
// and Sizzle in old IE. We'll use jQuery's trick for scoped
@@ -203,7 +203,13 @@ DomUtils = {};
}
};
- // Like `findElement` but searches the nodes from `start` to `end`
+ // Like `findAll` but finds one element (or returns null).
+ DomUtils.find = function(contextNode, selector) {
+ var results = DomUtils.findAll(contextNode, selector);
+ return (results.length ? results[0] : null);
+ };
+
+ // Like `findAll` but searches the nodes from `start` to `end`
// inclusive. `start` and `end` must be siblings, and they participate
// in the search (they can be used to match selector components, and
// they can appear in the returned results). It's as if the parent of
@@ -212,14 +218,14 @@ DomUtils = {};
//
// If `selector` involves sibling selectors, child index selectors, or
// the like, the results are undefined.
- DomUtils.findElementInRange = function(start, end, selector) {
+ DomUtils.findAllInRange = function(start, end, selector) {
end = (end || start);
var container = start.parentNode;
if (! container) {
if (start === end && (start.nodeType === 9 /* Document */ ||
start.nodeType === 11 /* DocumentFragment */))
- return DomUtils.findElement(start, selector);
+ return DomUtils.findAll(start, selector);
throw new Error("Can't find element in range on detached node");
}
if (end.parentNode !== container)
@@ -238,7 +244,7 @@ DomUtils = {};
// resultsPlus includes matches that are contained by the range's
// parent, but are outside of start..end, i.e. are descended from
// (or are) a different sibling.
- var resultsPlus = DomUtils.findElement(container, selector);
+ var resultsPlus = DomUtils.findAll(container, selector);
// Filter the list of nodes to remove nodes that occur before start
// or after end.
@@ -249,6 +255,12 @@ DomUtils = {};
});
};
+ // Like `findAllInRange` but finds one element (or returns null).
+ DomUtils.findInRange = function(start, end, selector) {
+ var results = DomUtils.findAllInRange(start, end, selector);
+ return (results.length ? results[0] : null);
+ };
+
// Returns 0 if the nodes are the same or either one contains the other;
// otherwise, 1 if a comes before b, or else -1 if b comes before a in
From 3d945ab22691533871b63580ce2d13d054acc9a3 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 30 Jul 2012 11:09:30 -0700
Subject: [PATCH 056/212] change references to findElement
---
packages/liveui/liveui.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index 839d46ad00..c87c2f6c41 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -673,7 +673,7 @@ Meteor.ui = Meteor.ui || {};
var selector = h.selector;
if (selector) {
var contextNode = range.containerNode();
- var results = DomUtils.findElement(contextNode, selector);
+ var results = DomUtils.findAll(contextNode, selector);
if (! _.contains(results, curNode))
continue;
} else {
@@ -757,7 +757,7 @@ Meteor.ui = Meteor.ui || {};
var collectLabeledNodes = function(range, preserveMap) {
var labeledNodes = {};
_.each(preserveMap, function(labelFunc, sel) {
- var matchingNodes = DomUtils.findElementInRange(
+ var matchingNodes = DomUtils.findAllInRange(
range.firstNode(), range.lastNode(), sel);
_.each(matchingNodes, function(n) {
// labelFunc can be a function or a constant,
From b3229fc4c2341ca22a44a788eb3d96fbec3dfb41 Mon Sep 17 00:00:00 2001
From: Geoff Schmidt
Date: Mon, 30 Jul 2012 11:33:32 -0700
Subject: [PATCH 057/212] setDataContext: tests and API change - 'html'
argument now goes last, for better indenting
---
packages/spark/spark.js | 2 +-
packages/spark/spark_tests.js | 91 ++++++++++++++++++++++++++++++++++-
2 files changed, 90 insertions(+), 3 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 5cc956d4c5..e1c706bcf9 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -122,7 +122,7 @@ Spark.render = function (htmlFunc) {
}
};
-Spark.setDataContext = function (html, dataContext) {
+Spark.setDataContext = function (dataContext, html) {
var renderer = Spark._currentRenderer.get();
if (!renderer)
return html;
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index 6a339580b4..1e49316497 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -4,7 +4,7 @@ Tinytest.add("spark - assembly", function (test) {
var doTest = function(calc) {
var frag = Spark.render(function() {
return calc(function(str, expected) {
- return Spark.setDataContext(str, null);
+ return Spark.setDataContext(null, str);
});
});
var groups = [];
@@ -55,7 +55,7 @@ Tinytest.add("spark - assembly", function (test) {
var frag = Spark.render(function() {
return '
Hello
';
});
var div = frag.firstChild;
@@ -390,3 +390,90 @@ Tinytest.add("spark - isolate", function(test) {
test.equal(num2.numListeners(), 0);
test.equal(num3.numListeners(), 0);
});
+
+Tinytest.add("spark - data context", function (test) {
+ var d1 = {x: 1};
+ var d2 = {x: 2};
+ var d3 = {x: 3};
+ var d4 = {x: 4};
+ var d5 = {x: 5};
+
+ var traverse = function (frag) {
+ var out = '';
+ var walkChildren = function (parent) {
+ for (var node = parent.firstChild; node; node = node.nextSibling) {
+ if (node.nodeType !== 8 /* COMMENT */) {
+ var data = Spark.getDataContext(node);
+ out += (data === null) ? "_" : data.x;
+ }
+ if (node.nodeType === 1 /* ELEMENT */)
+ walkChildren(node);
+ }
+ };
+ walkChildren(frag);
+ return out;
+ };
+
+ var testData = function (serialized, htmlFunc) {
+ test.equal(traverse(Spark.render(htmlFunc)), serialized);
+ }
+
+ testData("_", function () {
+ return "hi";
+ });
+
+ testData("__", function () {
+ return "
");
+ });
+});
From 8e61005f3e38ad8fe78f2f14ba076df75945a5f7 Mon Sep 17 00:00:00 2001
From: Geoff Schmidt
Date: Mon, 30 Jul 2012 15:00:08 -0700
Subject: [PATCH 058/212] WIP - liveevents => universal-events package not yet
run or tested
---
packages/liveui/liveevents_w3c.js | 178 -----------
.../event_tests.js} | 3 +
.../events-ie.js} | 161 ++++++----
packages/universal-events/events-w3c.js | 206 +++++++++++++
.../listener.js} | 287 +++++++++++-------
packages/universal-events/package.js | 21 ++
6 files changed, 499 insertions(+), 357 deletions(-)
delete mode 100644 packages/liveui/liveevents_w3c.js
rename packages/{liveui/liveevents_tests.js => universal-events/event_tests.js} (96%)
rename packages/{liveui/liveevents_now3c.js => universal-events/events-ie.js} (73%)
create mode 100644 packages/universal-events/events-w3c.js
rename packages/{liveui/liveevents.js => universal-events/listener.js} (72%)
create mode 100644 packages/universal-events/package.js
diff --git a/packages/liveui/liveevents_w3c.js b/packages/liveui/liveevents_w3c.js
deleted file mode 100644
index c7d3442ac7..0000000000
--- a/packages/liveui/liveevents_w3c.js
+++ /dev/null
@@ -1,178 +0,0 @@
-Meteor.ui = Meteor.ui || {};
-Meteor.ui._event = Meteor.ui._event || {};
-
-// LiveEvents implementation that depends on the W3C event model,
-// i.e. addEventListener and capturing. It's intended for all
-// browsers except IE <= 8.
-//
-// We take advantage of the fact that event handlers installed during
-// the capture phase are live during the bubbling phase. By installing
-// a capturing listener on the document, we bind the handler to the
-// event target and its ancestors "just in time".
-
-Meteor.ui._event._loadW3CImpl = function() {
- var SIMULATE_NEITHER = 0;
- var SIMULATE_FOCUS_BLUR = 1;
- var SIMULATE_FOCUSIN_FOCUSOUT = 2;
-
- // Focusin/focusout are the bubbling versions of focus/blur, and are
- // part of the W3C spec, but are absent from Firefox as of today
- // (v11), so we supply them.
- //
- // In addition, while most browsers fire these events sync in
- // response to a programmatic action (like .focus()), not all do.
- // IE 9+ fires focusin/focusout sync but focus/blur async. Opera
- // fires them all async. We don't do anything about this right now,
- // but simulating focus/blur on IE would make them sync.
- //
- // We have the capabiilty here to simulate focusin/focusout from
- // focus/blur, vice versa, or neither.
- //
- // We do a browser check that fails in old Firefox (3.6) but will
- // succeed if Firefox ever implements focusin/focusout. Old Firefox
- // fails all tests of the form ('onfoo' in node), while new Firefox
- // and all other known browsers will pass if 'foo' is a known event.
- var focusBlurMode = ('onfocusin' in document.createElement("DIV")) ?
- SIMULATE_NEITHER : SIMULATE_FOCUSIN_FOCUSOUT;
-
- // mouseenter/mouseleave is non-bubbling mouseover/mouseout. It's
- // standard but only IE and Opera seem to support it,
- // so we simulate it (which works in IE but not in Opera for some reason).
- var simulateMouseEnterLeave = (! window.opera);
-
- var universalCapturer = function(event) {
- if (event.target.nodeType === 3) // fix text-node target
- event.target = event.target.parentNode;
-
- var type = event.type;
- var bubbles = event.bubbles;
- var target = event.target;
-
- target.addEventListener(type, universalHandler, false);
-
- // According to the DOM event spec, if the DOM is mutated during
- // event handling, the original bubbling order still applies.
- // So we can determine the chain of nodes that could possibly
- // be bubbled to right now.
- var ancestors;
- if (bubbles) {
- ancestors = [];
- for(var n = target.parentNode; n; n = n.parentNode) {
- n.addEventListener(type, universalHandler, false);
- ancestors.push(n);
- };
- }
-
- // Unbind the handlers later.
- Meteor.defer(function() {
- target.removeEventListener(type, universalHandler);
- if (bubbles) {
- _.each(ancestors, function(n) {
- n.removeEventListener(type, universalHandler);
- });
- };
- });
- };
-
- var sendUIEvent = function(type, target, bubbles, cancelable, detail) {
- var event = document.createEvent("UIEvents");
- event.initUIEvent(type, bubbles, cancelable, window, detail);
- event.synthetic = true;
- target.dispatchEvent(event);
- };
-
- var universalHandler = function(event) {
- // fire synthetic focusin/focusout on blur/focus or vice versa
- if (event.currentTarget === event.target) {
- if (focusBlurMode === SIMULATE_FOCUS_BLUR) {
- if (event.type === 'focusin')
- sendUIEvent('focus', event.target, false);
- else if (event.type === 'focusout')
- sendUIEvent('blur', event.target, false);
- } else if (focusBlurMode === SIMULATE_FOCUSIN_FOCUSOUT) {
- if (event.type === 'focus')
- sendUIEvent('focusin', event.target, true);
- else if (event.type === 'blur')
- sendUIEvent('focusout', event.target, true);
- }
- }
- // only respond to synthetic events of the types we are faking
- if (focusBlurMode === SIMULATE_FOCUS_BLUR) {
- if (event.type === 'focus' || event.type === 'blur') {
- if (! event.synthetic)
- return;
- }
- } else if (focusBlurMode === SIMULATE_FOCUSIN_FOCUSOUT) {
- if (event.type === 'focusin' || event.type === 'focusout') {
- if (! event.synthetic)
- return;
- }
- }
- if (simulateMouseEnterLeave) {
- if (event.type === 'mouseenter' || event.type === 'mouseleave') {
- if (! event.synthetic)
- return;
- }
- }
-
- Meteor.ui._event._handleEventFunc(
- Meteor.ui._event._fixEvent(event));
-
- // event ordering: fire mouseleave after mouseout
- if (simulateMouseEnterLeave &&
- // We respond to mouseover/mouseout here even on
- // bubble, i.e. when event.currentTarget !== event.target,
- // to ensure we see every enter and leave.
- // We ignore the case where the mouse enters from
- // a child or leaves to a child (by checking if
- // relatedTarget is present and a descendent).
- (! event.relatedTarget ||
- (event.currentTarget !== event.relatedTarget &&
- ! DomUtils.elementContains(
- event.currentTarget, event.relatedTarget)))) {
- if (event.type === 'mouseover'){
- sendUIEvent('mouseenter', event.currentTarget, false);
- }
- else if (event.type === 'mouseout') {
- sendUIEvent('mouseleave', event.currentTarget, false);
- }
- }
- };
-
- var installCapturer = function(eventType) {
- // install handlers for the events used to fake events of this type,
- // in addition to handlers for the real type
- if (focusBlurMode === SIMULATE_FOCUS_BLUR) {
- if (eventType === 'focus')
- installCapturer('focusin');
- else if (eventType === 'blur')
- installCapturer('focusout');
- } else if (focusBlurMode === SIMULATE_FOCUSIN_FOCUSOUT) {
- if (eventType === 'focusin')
- installCapturer('focus');
- else if (eventType === 'focusout')
- installCapturer('blur');
- }
- if (simulateMouseEnterLeave) {
- if (eventType === 'mouseenter')
- installCapturer('mouseover');
- else if (eventType === 'mouseleave')
- installCapturer('mouseout');
- }
-
- if (! eventsCaptured[eventType]) {
- // only bind one event capturer per type
- eventsCaptured[eventType] = true;
- document.addEventListener(eventType, universalCapturer, true);
- }
- };
-
- var eventsCaptured = {};
-
- Meteor.ui._event.registerEventTypeImpl = function(eventType, subtreeRoot) {
- // We capture on the entire document, so don't actually care
- // about subtreeRoot!
- installCapturer(eventType);
- };
-
-};
\ No newline at end of file
diff --git a/packages/liveui/liveevents_tests.js b/packages/universal-events/event_tests.js
similarity index 96%
rename from packages/liveui/liveevents_tests.js
rename to packages/universal-events/event_tests.js
index 73f5dbf6f3..f7ed97a2c0 100644
--- a/packages/liveui/liveevents_tests.js
+++ b/packages/universal-events/event_tests.js
@@ -24,3 +24,6 @@ Meteor.ui = Meteor.ui || {};
// of this file). Of course, the tests are assumed to still pass even if
// it is `false`, in which case the extra checks aren't done.
Meteor.ui._TEST_requirePreciseEventHandlers = true;
+
+
+// XXX figure out what we're doing with this
\ No newline at end of file
diff --git a/packages/liveui/liveevents_now3c.js b/packages/universal-events/events-ie.js
similarity index 73%
rename from packages/liveui/liveevents_now3c.js
rename to packages/universal-events/events-ie.js
index 4b043091e1..de72bbe0a9 100644
--- a/packages/liveui/liveevents_now3c.js
+++ b/packages/universal-events/events-ie.js
@@ -1,77 +1,90 @@
-Meteor.ui = Meteor.ui || {};
-Meteor.ui._event = Meteor.ui._event || {};
+// XXX process comments
-// LiveEvents implementation for "old IE" versions 6-8, which lack
-// addEventListener and event capturing.
-//
-// The strategy is very different. We walk the subtree in question
-// and just attach the handler to all elements. If the handler is
-// foo and the eventType is 'click', we assign node.onclick = foo
-// everywhere. Since there is only one function object and we are
-// just assigning a property, hopefully this is somewhat lightweight.
-//
-// We use the node.onfoo method of binding events, also called "DOM0"
-// or the "traditional event registration", rather than the IE-native
-// node.attachEvent(...), mainly because we have the benefit of
-// referring to `this` from the handler in order to populate
-// event.currentTarget. It seems that otherwise we'd have to create
-// a closure per node to remember what node we are handling.
-//
-// We polyfill the usual event properties from their various locations.
-// We also make 'change' and 'submit' bubble, and we fire 'change'
-// events on checkboxes and radio buttons immediately rather than
-// only when the user blurs them, another old IE quirk.
+// Singleton
+UniversalEventListener._impl.ie = function (deliver) {
+ var self = this;
+ this.deliver = deliver;
+ this.curriedHandler = function (event) {
+ self.handler.call(this, self, event);
+ });
-Meteor.ui._event._loadNoW3CImpl = function() {
+ // submit forms that aren't preventDefaulted
+ // XXX explain
+ document.attachEvent('ondatasetcomplete', function () {
+ var evt = window.event;
+ var target = evt && evt.srcElement;
+ if (evt.synthetic && target &&
+ target.nodeName === 'FORM' &&
+ evt.returnValue !== false)
+ target.submit();
+ });
+};
- var installHandler = function(node, prop) {
- // install handlers for faking focus/blur if necessary
- if (prop === 'onfocus')
- installHandler(node, 'onfocusin');
- else if (prop === 'onblur')
- installHandler(node, 'onfocusout');
- // install handlers for faking bubbling change/submit
- else if (prop === 'onchange') {
- installHandler(node, 'oncellchange');
- // if we're looking at a checkbox or radio button,
- // sign up for propertychange and NOT change
- if (node.nodeName === 'INPUT' &&
- (node.type === 'checkbox' || node.type === 'radio')) {
- installHandler(node, 'onpropertychange');
- return;
- }
- } else if (prop === 'onsubmit')
- installHandler(node, 'ondatasetcomplete');
+_.extend(UniversalEventListener._impl.ie.prototype, {
+ addType: function (type) {
+ // not necessary for IE
+ },
- node[prop] = universalHandler;
- };
+ removeType: function (type) {
+ // not necessary for IE
+ },
- Meteor.ui._event.registerEventTypeImpl = function(eventType, subtreeRoot) {
+ installHandler: function (node, type) {
// use old-school event binding, so that we can
// access the currentTarget as `this` in the handler.
- var prop = 'on'+eventType;
+ var prop = 'on' + eventType;
if (subtreeRoot.nodeType === 1) { // ELEMENT
- installHandler(subtreeRoot, prop);
+ this._install(subtreeRoot, prop);
// hopefully fast traversal, since the browser is doing it
var descendents = subtreeRoot.getElementsByTagName('*');
for(var i=0, N = descendents.length; i
Date: Mon, 30 Jul 2012 18:10:48 -0700
Subject: [PATCH 059/212] implement _checkIECompliance (not tested)
---
packages/universal-events/listener.js | 117 +++++++++++++-------------
1 file changed, 57 insertions(+), 60 deletions(-)
diff --git a/packages/universal-events/listener.js b/packages/universal-events/listener.js
index df5f91c5ba..75f8b063f2 100644
--- a/packages/universal-events/listener.js
+++ b/packages/universal-events/listener.js
@@ -1,4 +1,38 @@
-// XXX process comments
+// Meteor Universal Events -- Normalized cross-browser event handling library
+//
+// This module lets you set up a function f that will be called
+// whenever an event fires on any element in the DOM. Specifically,
+// when an event fires on node N, f will be called with N. Then, if
+// the event is a bubbling event, f will be called again with N's
+// parent, then called again with N's grandparent, etc, until the root
+// of the document is reached. This provides a good base on top of
+// which custom event handling systems can be implemented.
+//
+// f also receives the event object for the event that fired. The
+// event object is normalized and extended to smooth over
+// cross-browser differences in event handling. See the details in
+// setHandler.
+//
+// Usage:
+// var listener = new UniversalEventListener(function (event) { ... });
+// listener.addType("click");
+//
+// If you want to support IE <= 8, you must also call installHandler
+// on each subtree of DOM nodes on which you wish to receive events,
+// eg, before inserting them into the document.
+//
+// Universal Events works reliably for events that fire on any DOM
+// element. It may not work consistently across browsers for events
+// that fire on non-element nodes (eg, text nodes.) We're not sure if
+// it's possible to handle those events consistently across browsers,
+// but in any event, it's not a common use case.
+//
+// Implementation notes:
+//
+// Internally, there are two separate implementations, one for modern
+// browsers (in liveevents_w3c.js), and one for old browsers with no
+// event capturing support (in liveevents_now3c.js.) The correct
+// implementation will be chosen for you automatically at runtime.
(function () {
@@ -68,20 +102,13 @@
var deliver = function (event) {
event = normalizeEvent(event);
_.each(listeners, function (listener) {
- if (listener.types[event.type])
- // XXX if in debug mode, have extra checks
- /*
- // When in unit test mode, wrap the given handleEventFunc to
- // block events we didn't register for explicitly.
- // See description of this flag in liveevents_tests.js.
- if (Meteor.ui._TEST_requirePreciseEventHandlers) {
- if (! event.currentTarget['_liveui_test_eventtype_'+event.type])
- return;
- }
-
-*/
- listener.handler.call(null, event);
- });
+ if (listener.types[event.type]) {
+ // if in debug mode, filter out events where the user forgot
+ // to call installHandler, even if we're not on IE
+ if (!(listener._checkIECompliance &&
+ ! event.currentTarget['_liveui_test_eventtype_' + event.type]))
+ listener.handler.call(null, event);
+ });
};
// When IE8 is dead, we can remove this springboard logic.
@@ -97,13 +124,13 @@
var typeCounts = {};
- // XXX document: no guarantees about events on text nodes
// For tests, you can set _checkIECompliance, which will throw an
// error if installHandler was not called when it should have been
// in order to support IE <= 8.
UniversalEventListener = new function (handler, _checkIECompliance) {
this.handler = handler;
this.types = {}; // map from event type name to 'true'
+ this.checkIECompliance = _checkIECompliance;
this.impl = getImpl();
listeners.push(this);
};
@@ -134,27 +161,22 @@
return;
this.impl.installHandler(node, type);
- /*
+ if (this._checkIECompliance) {
+ // When in unit test mode, mark all the nodes in the current
+ // subtree. We will later block events on nodes that weren't
+ // marked. This tests that LiveUI is generating calls to
+ // registerEventType with proper subtree information, even in
+ // browsers that don't need it.
- // When in unit test mode, mark all the nodes in the current subtree.
- // We will later block events on nodes that weren't marked. This
- // tests that LiveUI is generating calls to registerEventType
- // with proper subtree information, even in browsers that don't need
- // it.
- // See description of this flag in liveevents_tests.js.
- if (Meteor.ui._TEST_requirePreciseEventHandlers) {
- var n = subtreeRoot, t = eventType;
- // set property to any non-primitive value (to prevent showing
- // up as an HTML attribute in IE)
- n['_liveui_test_eventtype_'+t] = n;
- if (n.firstChild) {
- _.each(n.getElementsByTagName('*'), function(x) {
- x['_liveui_test_eventtype_'+t] = x;
- });
+ // set property to any non-primitive value (to prevent showing
+ // up as an HTML attribute in IE)
+ node['_liveui_test_eventtype_' + type] = node;
+ if (node.firstChild) {
+ _.each(node.getElementsByTagName('*'), function (x) {
+ x['_liveui_test_eventtype_' + type] = x;
+ });
+ }
}
- }
-
-*/
},
destroy: function () {
@@ -172,31 +194,6 @@
///////////////////////////////////////////////////////////////////////////////
-// LiveEvents -- Normalized cross-browser event handling library
-//
-// This module lets you set up a function f that will be called
-// whenever an event fires on any node in the DOM. Specifically, when
-// an event fires on node N, f will be called with N. Then, if the
-// event is a bubbling event, f will be called again with N's parent,
-// then called again with N's grandparent, etc, until the root of the
-// document is reached. This provides a good base on top of which
-// custom event handling semantics can be implemented.
-//
-// f also receives the event object for the event that fired. The
-// event object is normalized and extended to smooth over
-// cross-browser differences in event handling. See the details in
-// setHandler.
-//
-// To use, first call setHandler to set the handler function. (There
-// can be only one.) After that, it's necessary to call
-// registerEventType to indicate what events you'll be handling and
-// where in the document they could occur. setHandler and
-// registerEventType are the only public functions.
-//
-// Internally, there are two separate implementations, one for modern
-// browsers (in liveevents_w3c.js), and one for old browsers with no
-// event capturing support (in liveevents_now3c.js.) The correct
-// implementation will be chosen for you automatically at runtime.
// Install the global event handler. After this function has been
From 57e851787b12d644f7695dd788fddc220dc55e66 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Mon, 30 Jul 2012 18:34:02 -0700
Subject: [PATCH 060/212] working universal-events
---
packages/liveui/liveui.js | 5 +-
packages/liveui/package.js | 6 +--
packages/universal-events/event_tests.js | 28 ++++++++++-
packages/universal-events/events-ie.js | 17 ++++---
packages/universal-events/events-w3c.js | 38 ++++++++-------
packages/universal-events/listener.js | 60 ++++++++++++++----------
packages/universal-events/package.js | 2 +
7 files changed, 98 insertions(+), 58 deletions(-)
diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js
index c87c2f6c41..a838241536 100644
--- a/packages/liveui/liveui.js
+++ b/packages/liveui/liveui.js
@@ -603,7 +603,8 @@ Meteor.ui = Meteor.ui || {};
after = innerRange.lastNode().nextSibling;
n && n !== after;
n = n.nextSibling)
- Meteor.ui._event.registerEventType(t, n);
+ universalListener.addType(t);
+ universalListener.installHandler(n, t);
});
}
@@ -711,7 +712,7 @@ Meteor.ui = Meteor.ui || {};
return null;
};
- Meteor.ui._event.setHandler(handleEvent);
+ var universalListener = new UniversalEventListener(handleEvent);
//////////////////// DIFF / PATCH
diff --git a/packages/liveui/package.js b/packages/liveui/package.js
index 2ba701351d..7376b73b87 100644
--- a/packages/liveui/package.js
+++ b/packages/liveui/package.js
@@ -5,6 +5,7 @@ Package.describe({
Package.on_use(function (api) {
api.use('livedata');
+ api.use('universal-events');
api.use(['underscore', 'session', 'liverange'], 'client');
// XXX Depends on jquery because we need a selector engine to resolve
@@ -13,8 +14,6 @@ Package.on_use(function (api) {
// you still want the event object normalization that jquery provides?)
api.use('jquery');
- api.add_files(['liveevents_w3c.js', 'liveevents_now3c.js'], 'client');
- api.add_files(['liveevents.js'], 'client');
api.add_files(['livedocument.js'], 'client');
api.add_files(['liveui.js', 'patcher.js'],
'client');
@@ -30,7 +29,6 @@ Package.on_test(function (api) {
'livedocument_tests.js',
'liveui_tests.js',
'liveui_tests.html',
- 'patcher_tests.js',
- 'liveevents_tests.js'
+ 'patcher_tests.js'
], 'client');
});
diff --git a/packages/universal-events/event_tests.js b/packages/universal-events/event_tests.js
index f7ed97a2c0..5c0360919a 100644
--- a/packages/universal-events/event_tests.js
+++ b/packages/universal-events/event_tests.js
@@ -1,5 +1,29 @@
-Meteor.ui = Meteor.ui || {};
+Tinytest.add("universal-events - basic", function(test) {
+
+ var msgs = [];
+
+ var listener = new UniversalEventListener(function(event) {
+ var node = event.currentTarget;
+ if (DomUtils.elementContains(document.body, node)) {
+ msgs.push(event.currentTarget.nodeName.toLowerCase());
+ }
+ });
+
+ var d = OnscreenDiv(Meteor.render("
Hello
"));
+ listener.addType('click');
+ listener.installHandler(d.node(), 'click');
+ clickElement(DomUtils.find(d.node(), "b"));
+ test.equal(msgs, ['b', 'span', 'div', 'div']);
+
+ listener.destroy();
+
+});
+
+
+
+
+// XXX process comments
// LiveEvents is unit-tested by the LiveUI tests, because it was
// originally extracted from liveui.js.
@@ -23,7 +47,7 @@ Meteor.ui = Meteor.ui || {};
// This flag is set to `true` when running unit tests (via the inclusion
// of this file). Of course, the tests are assumed to still pass even if
// it is `false`, in which case the extra checks aren't done.
-Meteor.ui._TEST_requirePreciseEventHandlers = true;
+//Meteor.ui._TEST_requirePreciseEventHandlers = true;
// XXX figure out what we're doing with this
\ No newline at end of file
diff --git a/packages/universal-events/events-ie.js b/packages/universal-events/events-ie.js
index de72bbe0a9..7596a165f7 100644
--- a/packages/universal-events/events-ie.js
+++ b/packages/universal-events/events-ie.js
@@ -1,12 +1,14 @@
// XXX process comments
+UniversalEventListener._impl = UniversalEventListener._impl || {};
+
// Singleton
UniversalEventListener._impl.ie = function (deliver) {
var self = this;
this.deliver = deliver;
this.curriedHandler = function (event) {
self.handler.call(this, self, event);
- });
+ };
// submit forms that aren't preventDefaulted
// XXX explain
@@ -32,20 +34,21 @@ _.extend(UniversalEventListener._impl.ie.prototype, {
installHandler: function (node, type) {
// use old-school event binding, so that we can
// access the currentTarget as `this` in the handler.
- var prop = 'on' + eventType;
+ // note: handler is never removed from node
+ var prop = 'on' + type;
- if (subtreeRoot.nodeType === 1) { // ELEMENT
- this._install(subtreeRoot, prop);
+ if (node.nodeType === 1) { // ELEMENT
+ this._install(node, prop);
// hopefully fast traversal, since the browser is doing it
- var descendents = subtreeRoot.getElementsByTagName('*');
+ var descendents = node.getElementsByTagName('*');
for(var i=0, N = descendents.length; i
Date: Wed, 1 Aug 2012 11:45:07 -0700
Subject: [PATCH 061/212] tests, docs work
---
packages/spark/spark_tests.js | 1 +
packages/universal-events/event_tests.js | 120 ++++++++++++++---------
packages/universal-events/listener.js | 40 +++++++-
3 files changed, 112 insertions(+), 49 deletions(-)
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index 1e49316497..9615b608a9 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -1,3 +1,4 @@
+// XXX when testing spark, set the checkIECompliance flag on universal-events somehow
Tinytest.add("spark - assembly", function (test) {
diff --git a/packages/universal-events/event_tests.js b/packages/universal-events/event_tests.js
index 5c0360919a..5ae1288e18 100644
--- a/packages/universal-events/event_tests.js
+++ b/packages/universal-events/event_tests.js
@@ -1,53 +1,83 @@
Tinytest.add("universal-events - basic", function(test) {
- var msgs = [];
+ var runTest = function (testMissingHandlers) {
+ var msgs = [];
+ var listeners = [];
- var listener = new UniversalEventListener(function(event) {
- var node = event.currentTarget;
- if (DomUtils.elementContains(document.body, node)) {
- msgs.push(event.currentTarget.nodeName.toLowerCase());
- }
- });
+ var createListener = function () {
+ var out = [];
+ msgs.push(out);
+ var ret = new UniversalEventListener(function(event) {
+ var node = event.currentTarget;
+ if (DomUtils.elementContains(document.body, node)) {
+ out.push(event.currentTarget.nodeName.toLowerCase());
+ }
+ }, testMissingHandlers);
+ listeners.push(ret);
+ return ret;
+ };
- var d = OnscreenDiv(Meteor.render("
Hello
"));
- listener.addType('click');
- listener.installHandler(d.node(), 'click');
- clickElement(DomUtils.find(d.node(), "b"));
- test.equal(msgs, ['b', 'span', 'div', 'div']);
+ var L1 = createListener();
- listener.destroy();
+ var check = function (event, expected) {
+ _.each(msgs, function (m) {
+ m.length = 0;
+ });
+ simulateEvent(DomUtils.find(d.node(), "b"), event);
+ for (var i = 0; i < listeners.length; i++)
+ test.equal(msgs[i], testMissingHandlers ? [] : expected[i]);
+ };
+ var d = OnscreenDiv(Meteor.render("
Hello
"));
+ L1.addType('mousedown');
+ if (!testMissingHandlers)
+ L1.installHandler(d.node(), 'mousedown');
+ var x = ['b', 'span', 'div', 'div'];
+ check('mousedown', [x]);
+
+ check('mouseup', [[]]);
+
+ L1.removeType('mousedown');
+ check('mousedown', [[]]);
+ L1.removeType('mousedown');
+ check('mousedown', [[]]);
+
+ L1.addType('mousedown');
+ check('mousedown', [x]);
+ L1.addType('mousedown');
+ check('mousedown', [x]);
+ L1.removeType('mousedown');
+ check('mousedown', [[]]);
+
+ var L2 = createListener();
+ if (!testMissingHandlers)
+ L2.installHandler(d.node(), 'mousedown');
+
+ L1.addType('mousedown');
+ check('mousedown', [x, []]);
+ L2.addType('mousedown');
+ check('mousedown', [x, x]);
+ L2.addType('mousedown');
+ check('mousedown', [x, x]);
+ L1.removeType('mousedown');
+ check('mousedown', [[], x]);
+ L1.removeType('mousedown');
+ check('mousedown', [[], x]);
+ L2.removeType('mousedown');
+ check('mousedown', [[], []]);
+ L1.addType('mousedown');
+ check('mousedown', [x, []]);
+ L1.removeType('mousedown');
+ check('mousedown', [[], []]);
+ L2.addType('mousedown');
+ check('mousedown', [[], x]);
+ L2.removeType('mousedown');
+ check('mousedown', [[], []]);
+
+ d.kill();
+ };
+
+ runTest(false);
+ runTest(true);
});
-
-
-
-
-// XXX process comments
-
-// LiveEvents is unit-tested by the LiveUI tests, because it was
-// originally extracted from liveui.js.
-
-
-// TEST FLAG: requirePreciseEventHandlers
-//
-// This flag enables extra checking that LiveUI is correctly registering new
-// DOM nodes with LiveEvents, even in browsers that don't require it.
-// If the checks fail, it means the tests would
-// fail anyway in Old IE, but this way we get to find out sooner.
-//
-// The reason for this set-up is that the main (W3C) implementation of
-// LiveEvents doesn't need to know when nodes are added to the DOM
-// via the "subtreeRoot" information in registerEventType.
-// However, the Old IE implementation does, so it's important that LiveUI
-// tell us specifically what nodes need event handlers. When this
-// flag is true, we hold LiveUI to the same standard of specificity whether
-// or not we are running Old IE.
-//
-// This flag is set to `true` when running unit tests (via the inclusion
-// of this file). Of course, the tests are assumed to still pass even if
-// it is `false`, in which case the extra checks aren't done.
-//Meteor.ui._TEST_requirePreciseEventHandlers = true;
-
-
-// XXX figure out what we're doing with this
\ No newline at end of file
diff --git a/packages/universal-events/listener.js b/packages/universal-events/listener.js
index cdcf778e48..e3836d3c2f 100644
--- a/packages/universal-events/listener.js
+++ b/packages/universal-events/listener.js
@@ -127,19 +127,46 @@
////////// PUBLIC API
- // For tests, you can set _checkIECompliance, which will throw an
- // error if installHandler was not called when it should have been
- // in order to support IE <= 8.
+ // Create a new universal event listener. Until some event types are
+ // turned on with `addType`, it will not receive any
+ // events.
+ //
+ //
+ // Whenever an event of the appropriate type fires anywhere in the
+ // document, `handler` will be called with one argument, the
+ // event. If the event is a bubbling event (most events are
+ // bubbling, eg, 'click'), then `handler` will be called not only
+ // for the element that was the origin of the event (eg, the button
+ // that was clicked), but for each parent element as the event
+ // bubbles up to the top of the tree.
+ //
+ // The event object that's passed to `handler` will be normalized
+ // across browsers so that it contains the following fields:
+ //
+ // [XXX list]
+ //
+ // NOTE: If you want compatibility with IE <= 8, you will need to
+ // call `installHandler` to prepare each subtree of the DOM to receive
+ // the events you are interested in.
+ //
+ // Debugging only:
+ //
+ // The _checkIECompliance flag enables extra checking that the user
+ // is correctly registering new DOM nodes with installHandler, even
+ // in browsers that don't require it. In other words, when the flag
+ // is set, modern browsers will require the same API calls as IE <=
+ // 8. This is only used for tests and is private for now.
UniversalEventListener = function (handler, _checkIECompliance) {
this.handler = handler;
this.types = {}; // map from event type name to 'true'
- this.checkIECompliance = _checkIECompliance;
this.impl = getImpl();
this._checkIECompliance = _checkIECompliance;
listeners.push(this);
};
_.extend(UniversalEventListener.prototype, {
+ // XXX document
+ // idempotent
addType: function (type) {
if (!this.types[type]) {
this.types[type] = true;
@@ -149,6 +176,8 @@
}
},
+ // XXX document
+ // idempotent
removeType: function (type) {
if (this.types[type]) {
delete this.types[type];
@@ -158,10 +187,12 @@
}
},
+ // XXX document
// only necessary on IE <= 8
// noop except on element nodes
// idempotent
// assures events will be delivered on node and descendents
+ // can't rely on NOT getting events if you haven't called it, even in IE <= 8
installHandler: function (node, type) {
// Only work on element nodes, not e.g. text nodes or fragments
if (node.nodeType !== 1)
@@ -187,6 +218,7 @@
}
},
+ // XXX document
destroy: function () {
var self = this;
From d1a91876c716de4ca88e8ac7461282105738e05d Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Wed, 1 Aug 2012 13:09:00 -0700
Subject: [PATCH 062/212] more docs
---
packages/universal-events/listener.js | 96 ++++++++++-----------------
1 file changed, 36 insertions(+), 60 deletions(-)
diff --git a/packages/universal-events/listener.js b/packages/universal-events/listener.js
index e3836d3c2f..f28bfec5cc 100644
--- a/packages/universal-events/listener.js
+++ b/packages/universal-events/listener.js
@@ -127,10 +127,9 @@
////////// PUBLIC API
- // Create a new universal event listener. Until some event types are
- // turned on with `addType`, it will not receive any
- // events.
- //
+ // Create a new universal event listener with a given handler.
+ // Until some event types are turned on with `addType`, the handler
+ // will not receive any events.
//
// Whenever an event of the appropriate type fires anywhere in the
// document, `handler` will be called with one argument, the
@@ -141,9 +140,16 @@
// bubbles up to the top of the tree.
//
// The event object that's passed to `handler` will be normalized
- // across browsers so that it contains the following fields:
+ // across browsers so that it contains the following fields and
+ // methods:
//
- // [XXX list]
+ // - type
+ // - target
+ // - currentTarget
+ // - stopPropagation()
+ // - preventDefault()
+ // - isPropagationStopped()
+ // - isDefaultPrevented()
//
// NOTE: If you want compatibility with IE <= 8, you will need to
// call `installHandler` to prepare each subtree of the DOM to receive
@@ -165,8 +171,10 @@
};
_.extend(UniversalEventListener.prototype, {
- // XXX document
- // idempotent
+ // Adds `type` to the set of event types that this listener will
+ // listen to and deliver to the handler. A listener is guaranteed
+ // to only deliver events of types in the current listening set.
+ // If `type` is already in the set, this method has no effect.
addType: function (type) {
if (!this.types[type]) {
this.types[type] = true;
@@ -176,8 +184,9 @@
}
},
- // XXX document
- // idempotent
+ // Removes `type` from the set of event types that this listener
+ // will listen to and deliver to the handler. If `type` is not
+ // in the set, this method has no effect.
removeType: function (type) {
if (this.types[type]) {
delete this.types[type];
@@ -187,12 +196,23 @@
}
},
- // XXX document
- // only necessary on IE <= 8
- // noop except on element nodes
- // idempotent
- // assures events will be delivered on node and descendents
- // can't rely on NOT getting events if you haven't called it, even in IE <= 8
+ // For IE <= 8, installs the necessary handler to receive events
+ // of type `type` on the DOM subtree rooted at `node` (that is,
+ // `node` and its descendents).
+ //
+ // For proper cross-browser event handling, call this method on
+ // any nodes you want to receive events on. To support only
+ // modern browsers, it is not necessary to call `installHandler`
+ // at all. In IE <= 8, you must call it to ensure proper behavior,
+ // but the implementation is allowed to deliver events anyway if
+ // it can.
+ //
+ // Only current descendents of `node` are affected; if new nodes are
+ // added to the subtree later, installHandler must be called again
+ // to ensure events are received on those nodes.
+ //
+ // It is safe to call installHandler any number of times on the same
+ // arguments (it is idempotent).
installHandler: function (node, type) {
// Only work on element nodes, not e.g. text nodes or fragments
if (node.nodeType !== 1)
@@ -229,47 +249,3 @@
}
});
})();
-
-
-///////////////////////////////////////////////////////////////////////////////
-
-
-
-
- // Install the global event handler. After this function has been
- // done, handleEventFunc(event) will be called whenever a DOM event
- // fires or bubbles to a new node.
- //
- // 'event' will be a normalized version of the DOM event
- // object. Some of the properties that are normalized include:
- // - type
- // - target
- // - currentTarget
- // - stopPropagation()
- // - preventDefault()
- // - isPropagationStopped()
- // - isDefaultPrevented()
- //
- // This function should only be called once, ever, and must be
- // called before registerEventType.
-// Meteor.ui._event.setHandler = function(handleEventFunc) {
-
-
- // After calling setHandler, this function must be called some
- // number of times to enable handling of different events at
- // different points in the document.
- //
- // Specifically, calling this function will ensure that events of
- // type eventType will be successfully caught when they occur within
- // the DOM subtree rooted at subtreeRoot (i.e. subtreeRoot and its
- // descendents). Only the current descendents are registered.
- // If new nodes are added to the subtree later, they must be
- // registered.
- //
- // If this function isn't called for a given event type T and
- // subtree S, and T fires within S, then it's unspecified whether
- // handleEventFunc will be called. (In browsers where we are able to
- // catch events for the entire document using a capturing handler,
- // it will be called. In browsers that don't support this, the event
- // will be lost.)
-// Meteor.ui._event.registerEventType = function(eventType, subtreeRoot) {
From 95aa00f0f2e31f77acfa3158f02d8e0bd31cd3f5 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Wed, 1 Aug 2012 15:08:49 -0700
Subject: [PATCH 063/212] finish universal events clean-up
---
packages/universal-events/events-ie.js | 55 ++++++++++++-------------
packages/universal-events/events-w3c.js | 6 +--
packages/universal-events/listener.js | 47 +++++++++++----------
3 files changed, 54 insertions(+), 54 deletions(-)
diff --git a/packages/universal-events/events-ie.js b/packages/universal-events/events-ie.js
index 7596a165f7..15442ad037 100644
--- a/packages/universal-events/events-ie.js
+++ b/packages/universal-events/events-ie.js
@@ -1,4 +1,23 @@
-// XXX process comments
+// Universal Events implementation for IE versions 6-8, which lack
+// addEventListener and event capturing.
+//
+// The strategy is very different. We walk the subtree in question
+// and just attach the handler to all elements. If the handler is
+// foo and the eventType is 'click', we assign node.onclick = foo
+// everywhere. Since there is only one function object and we are
+// just assigning a property, hopefully this is somewhat lightweight.
+//
+// We use the node.onfoo method of binding events, also called "DOM0"
+// or the "traditional event registration", rather than the IE-native
+// node.attachEvent(...), mainly because we have the benefit of
+// referring to `this` from the handler in order to populate
+// event.currentTarget. It seems that otherwise we'd have to create
+// a closure per node to remember what node we are handling.
+//
+// We polyfill the usual event properties from their various locations.
+// We also make 'change' and 'submit' bubble, and we fire 'change'
+// events on checkboxes and radio buttons immediately rather than
+// only when the user blurs them, another old IE quirk.
UniversalEventListener._impl = UniversalEventListener._impl || {};
@@ -10,14 +29,19 @@ UniversalEventListener._impl.ie = function (deliver) {
self.handler.call(this, self, event);
};
- // submit forms that aren't preventDefaulted
- // XXX explain
+ // The 'submit' event on IE doesn't bubble. We want to simulate
+ // bubbling submit to match other browsers, and to do that we use
+ // IE's own event machinery. We can't dispatch events with arbitrary
+ // names in IE, so we appropriate the obscure "datasetcomplete" event
+ // for this purpose.
document.attachEvent('ondatasetcomplete', function () {
var evt = window.event;
var target = evt && evt.srcElement;
if (evt.synthetic && target &&
target.nodeName === 'FORM' &&
evt.returnValue !== false)
+ // No event handler called preventDefault on the simulated
+ // submit event. That means the form should be submitted.
target.submit();
});
};
@@ -137,28 +161,3 @@ _.extend(UniversalEventListener._impl.ie.prototype, {
}
});
-
-
-
-
-
-// LiveEvents implementation for "old IE" versions 6-8, which lack
-// addEventListener and event capturing.
-//
-// The strategy is very different. We walk the subtree in question
-// and just attach the handler to all elements. If the handler is
-// foo and the eventType is 'click', we assign node.onclick = foo
-// everywhere. Since there is only one function object and we are
-// just assigning a property, hopefully this is somewhat lightweight.
-//
-// We use the node.onfoo method of binding events, also called "DOM0"
-// or the "traditional event registration", rather than the IE-native
-// node.attachEvent(...), mainly because we have the benefit of
-// referring to `this` from the handler in order to populate
-// event.currentTarget. It seems that otherwise we'd have to create
-// a closure per node to remember what node we are handling.
-//
-// We polyfill the usual event properties from their various locations.
-// We also make 'change' and 'submit' bubble, and we fire 'change'
-// events on checkboxes and radio buttons immediately rather than
-// only when the user blurs them, another old IE quirk.
diff --git a/packages/universal-events/events-w3c.js b/packages/universal-events/events-w3c.js
index 0ac8bdfd58..f3461aefee 100644
--- a/packages/universal-events/events-w3c.js
+++ b/packages/universal-events/events-w3c.js
@@ -1,7 +1,5 @@
-// XXX process comments
-
-// LiveEvents implementation that depends on the W3C event model,
-// i.e. addEventListener and capturing. It's intended for all
+// Universal Events implementation that depends on the W3C event
+// model, i.e. addEventListener and capturing. It's intended for all
// browsers except IE <= 8.
//
// We take advantage of the fact that event handlers installed during
diff --git a/packages/universal-events/listener.js b/packages/universal-events/listener.js
index f28bfec5cc..354e7c30ce 100644
--- a/packages/universal-events/listener.js
+++ b/packages/universal-events/listener.js
@@ -30,8 +30,8 @@
// Implementation notes:
//
// Internally, there are two separate implementations, one for modern
-// browsers (in liveevents_w3c.js), and one for old browsers with no
-// event capturing support (in liveevents_now3c.js.) The correct
+// browsers (in events-w3c.js), and one for old browsers with no
+// event capturing support (in events-ie.js.) The correct
// implementation will be chosen for you automatically at runtime.
(function () {
@@ -143,7 +143,7 @@
// across browsers so that it contains the following fields and
// methods:
//
- // - type
+ // - type (e.g. "click")
// - target
// - currentTarget
// - stopPropagation()
@@ -172,9 +172,8 @@
_.extend(UniversalEventListener.prototype, {
// Adds `type` to the set of event types that this listener will
- // listen to and deliver to the handler. A listener is guaranteed
- // to only deliver events of types in the current listening set.
- // If `type` is already in the set, this method has no effect.
+ // listen to and deliver to the handler. Has no effect if `type`
+ // is already in the set.
addType: function (type) {
if (!this.types[type]) {
this.types[type] = true;
@@ -185,8 +184,8 @@
},
// Removes `type` from the set of event types that this listener
- // will listen to and deliver to the handler. If `type` is not
- // in the set, this method has no effect.
+ // will listen to and deliver to the handler. Has no effect if `type`
+ // is not in the set.
removeType: function (type) {
if (this.types[type]) {
delete this.types[type];
@@ -196,23 +195,26 @@
}
},
- // For IE <= 8, installs the necessary handler to receive events
- // of type `type` on the DOM subtree rooted at `node` (that is,
- // `node` and its descendents).
+ // It is only necessary to call this method if you want to support
+ // IE <= 8. On those browsers, you must call this method on each
+ // set of nodes before adding them to the DOM (or at least, before
+ // expecting to receive events on them), and you must specify the
+ // types of events you'll be receiving.
//
- // For proper cross-browser event handling, call this method on
- // any nodes you want to receive events on. To support only
- // modern browsers, it is not necessary to call `installHandler`
- // at all. In IE <= 8, you must call it to ensure proper behavior,
- // but the implementation is allowed to deliver events anyway if
- // it can.
- //
- // Only current descendents of `node` are affected; if new nodes are
- // added to the subtree later, installHandler must be called again
- // to ensure events are received on those nodes.
+ // `node` and all of its descendents will be set up to handle
+ // events of type `type` (eg, 'click'). Only current descendents
+ // of `node` are affected; if new nodes are added to the subtree
+ // later, installHandler must be called again to ensure events are
+ // received on those nodes. To set up to handle multiple event
+ // types, make multiple calls.
//
// It is safe to call installHandler any number of times on the same
// arguments (it is idempotent).
+ //
+ // If you forget to call this function for a given node, it's
+ // unspecified whether you'll receive events on IE <= 8 (you may,
+ // you may not.) If you don't care about supporting IE <= 8 you
+ // can ignore this function.
installHandler: function (node, type) {
// Only work on element nodes, not e.g. text nodes or fragments
if (node.nodeType !== 1)
@@ -238,7 +240,8 @@
}
},
- // XXX document
+ // Tear down this UniversalEventListener so that no more events
+ // are delivered.
destroy: function () {
var self = this;
From 9bcebc0fd2a8e160feef01b4552e12fbb0fbe44e Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Wed, 1 Aug 2012 17:17:25 -0700
Subject: [PATCH 064/212] attachEvents implemented (untested)
---
packages/spark/spark.js | 161 ++++++++++++++++++++++++++++++++++++++--
1 file changed, 153 insertions(+), 8 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index e1c706bcf9..fd610d916d 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -1,10 +1,16 @@
+(function() {
+
Spark = {};
Spark._currentRenderer = new Meteor.EnvironmentVariable;
+// XXX document contract for each type of annotation?
Spark._ANNOTATION_DATA = "_spark_data";
Spark._ANNOTATION_ISOLATE = "_spark_isolate";
-Spark._ANNOTATIONS = [Spark._ANNOTATION_DATA, Spark._ANNOTATION_ISOLATE];
+Spark._ANNOTATION_EVENTS = "_spark_events";
+Spark._ANNOTATION_WATCH = "_spark_watch";
+Spark._ANNOTATIONS = [Spark._ANNOTATION_DATA, Spark._ANNOTATION_ISOLATE,
+ Spark._ANNOTATION_EVENTS, Spark._ANNOTATION_WATCH];
Spark._Renderer = function () {
// Map from annotation ID to an annotation function, which is called
@@ -122,21 +128,144 @@ Spark.render = function (htmlFunc) {
}
};
-Spark.setDataContext = function (dataContext, html) {
- var renderer = Spark._currentRenderer.get();
- if (!renderer)
- return html;
-
- return renderer.annotate(
- html, Spark._ANNOTATION_DATA, { data: dataContext });
+var withRenderer = function (f) {
+ return function (/* arguments */) {
+ var renderer = Spark._currentRenderer.get();
+ var args = _.toArray(arguments);
+ if (!renderer)
+ return args.pop();
+ args.push(renderer);
+ return f.apply(null, args);
+ };
};
+Spark.setDataContext = withRenderer(function (dataContext, html, _renderer) {
+ return _renderer.annotate(
+ html, Spark._ANNOTATION_DATA, { data: dataContext });
+});
+
Spark.getDataContext = function (node) {
var range = LiveRange.findRange(
Spark._ANNOTATION_DATA, node);
return range && range.data;
};
+var universalListener = null;
+var getListener = function () {
+ if (!universalListener)
+ universalListener = new UniversalEventListener(function (event) {
+ // Handle a currently-propagating event on a particular node.
+ // We walk all enclosing liveranges of the node, from the inside
+ // out, looking for matching handlers. If the app calls
+ // stopPropagation(), we still call all handlers in all event
+ // maps for the current node. If the app calls
+ // "stopImmediatePropagation()", we don't call any more
+ // handlers.
+
+ var range = LiveRange.findRange(Spark._ANNOTATION_EVENTS,
+ event.currentTarget);
+ while (range && !event.isImmediatePropagationStopped()) {
+ range.handler(event);
+ range = range.findParent();
+ }
+ });
+
+ return universalListener;
+};
+
+Spark.attachEvents = withRenderer(function (eventMap, html, _renderer) {
+ var listener = getListener();
+
+ var handlerMap = {}; // type -> [{selector, callback}, ...]
+ // iterate over eventMap, which has form {"type selector, ...": callback},
+ // and populate handlerMap
+ _.each(eventMap, function(callback, spec) {
+ var clauses = spec.split(/,\s+/);
+ // iterate over clauses of spec, e.g. ['click .foo', 'click .bar']
+ _.each(clauses, function (clause) {
+ var parts = clause.split(/\s+/);
+ if (parts.length === 0)
+ return;
+
+ var type = parts.shift();
+ var selector = parts.join(' ');
+
+ handlerMap[type] = handlerMap[type] || [];
+ handlerMap[type].push({selector: selector, callback: callback});
+ });
+ });
+
+ var eventTypes = _.keys(handlerMap);
+
+ var installHandlers = function (range) {
+ _.each(eventTypes, function (t) {
+ for(var n = range.firstNode(),
+ after = range.lastNode().nextSibling;
+ n && n !== after;
+ n = n.nextSibling)
+ listener.installHandler(n, t);
+ });
+ };
+
+ html = _renderer.annotate(
+ html, Spark._ANNOTATION_WATCH, {
+ notify: function () {
+ installHandlers(this);
+ }
+ });
+
+ html = _renderer.annotate(
+ html, Spark._ANNOTATION_EVENTS, function (range) {
+ _.each(eventTypes, function (t) {
+ listener.addType(t);
+ });
+ installHandlers(range);
+
+ range.handler = function (event) {
+ var handlers = handlerMap[event.type] || [];
+
+ for (var i = 0; i < handlers.length; i++) {
+ var handler = handlers[i];
+ var callback = handler.callback;
+ var selector = handler.selector;
+
+ if (selector) {
+ // This ends up doing O(n) findAllInRange calls when an
+ // event bubbles up N level in the DOM. If this ends up
+ // being too slow, we could memoize findAllInRange across
+ // the processing of each event.
+ var results = DomUtils.findAllInRange(range.firstNode(),
+ range.lastNode(), selector);
+ // This is a linear search through what could be a large
+ // result set.
+ if (! _.contains(results, event.currentTarget))
+ continue;
+ } else {
+ // if no selector, only match the event target
+ if (event.currentTarget !== event.target)
+ continue;
+ }
+
+ // Found a matching handler.
+ var eventData = Spark.getDataContext(event.currentTarget);
+ var returnValue = callback.call(eventData, event);
+
+ // allow app to `return false` from event handler, just like
+ // you can in a jquery event handler
+ if (returnValue === false) {
+ event.stopImmediatePropagation();
+ event.preventDefault();
+ }
+ if (event.isImmediatePropagationStopped())
+ break; // don't let any other handlers in this event map fire
+ }
+ };
+ });
+
+ return html;
+});
+
+
Spark.isolate = function (htmlFunc) {
var renderer = Spark._currentRenderer.get();
if (!renderer)
@@ -181,6 +310,7 @@ Spark.isolate = function (htmlFunc) {
});
var oldContents = range.replace_contents(frag); // XXX should patch
Spark.finalize(oldContents);
+ notifyWatchers(range);
range.destroy();
});
});
@@ -188,6 +318,19 @@ Spark.isolate = function (htmlFunc) {
return html;
};
+var notifyWatchers = function (range) {
+ // find the innermost WATCH annotation containing the nodes in `range`
+ var tempRange = new LiveRange(Spark._ANNOTATION_WATCH, range.firstNode(),
+ range.lastNode(), true /* innermost */);
+ var walk = tempRange.findParent();
+ tempRange.destroy();
+
+ // tell all enclosing WATCH annotations that their contents changed
+ while (walk) {
+ walk.notify();
+ walk = walk.findParent();
+ }
+};
// Delete all of the liveranges in the range of nodes between `start`
// and `end`, and call their 'finalize' function if any. Or instead of
@@ -209,3 +352,5 @@ Spark.finalize = function (start, end) {
wrapper.destroy(true /* recursive */);
});
};
+
+})();
From 46238fd47371e2bcc05447e8b6ea658ff84ea79f Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Wed, 1 Aug 2012 18:34:32 -0700
Subject: [PATCH 065/212] table fixes and tests
---
packages/liveui/liveui_tests.js | 88 ++-------------------------------
packages/spark/spark.js | 14 ++++++
packages/spark/spark_tests.js | 86 ++++++++++++++++++++++++++++++++
3 files changed, 103 insertions(+), 85 deletions(-)
diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js
index f4fef0f5f7..bb52d37d8d 100644
--- a/packages/liveui/liveui_tests.js
+++ b/packages/liveui/liveui_tests.js
@@ -31,95 +31,13 @@ var legacyLabels = {
Tinytest.add("liveui - tables", function(test) {
- var R = ReactiveVar(0);
- var table = OnscreenDiv(Meteor.ui.render(function() {
- var buf = [];
- buf.push("
");
- div.kill();
- Meteor.flush();
- test.equal(R.numListeners(), 0);
+ var R = ReactiveVar("");
// Test tables with patching
- R.set("");
- div = OnscreenDiv(Meteor.ui.render(function() {
+ var div = OnscreenDiv(Meteor.ui.render(function() {
return '
");
Meteor.flush();
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index fd610d916d..fa86ff3f1f 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -308,6 +308,20 @@ Spark.isolate = function (htmlFunc) {
var frag = Spark.render(function () {
return Spark.isolate(htmlFunc);
});
+
+ var tempRange = new LiveRange(Spark._ANNOTATION_ISOLATE, frag, null,
+ true /* inner */);
+ tempRange.operate(function (start, end) {
+ // Wrap contents of frag, *inside* the ISOLATE annotation,
+ // as appropriate for insertion into `range`. We want the
+ // wrapping inside the range so that if you have a
+ // containing an isolate, and the isolate returns a
+ // sometimes and a other times, the wrapping will
+ // change as appropriate.
+ DomUtils.wrapFragmentForContainer(frag, range.containerNode());
+ });
+ tempRange.destroy();
+
var oldContents = range.replace_contents(frag); // XXX should patch
Spark.finalize(oldContents);
notifyWatchers(range);
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index 9615b608a9..daaa158487 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -478,3 +478,89 @@ Tinytest.add("spark - data context", function (test) {
"");
});
});
+
+Tinytest.add("spark - tables", function (test) {
+ var R = ReactiveVar(0);
+
+ var table = OnscreenDiv(Meteor.render(function() {
+ var buf = [];
+ buf.push("
';
- }, { events: eventmap('click *'), event_data:event_buf }));
- R.set('bar');
- Meteor.flush();
- // click on input
- clickElement(div.node().getElementsByTagName('input')[0]);
- test.equal(
- event_buf,
- ['click input', 'click *', 'click *', 'click *', 'click *', 'click *']);
- event_buf.length = 0;
- div.kill();
- Meteor.flush();
-
- // clicking on a div in a nested chunk (without patching)
- event_buf.length = 0;
- R = ReactiveVar('foo');
- div = OnscreenDiv(Meteor.ui.render(function() {
- return R.get() + Meteor.ui.chunk(function() {
- return 'ism';
- }, {events: eventmap("click"), event_data:event_buf});
- }));
- test.equal(div.text(), 'fooism');
- clickElement(div.node().getElementsByTagName('SPAN')[0]);
- test.equal(event_buf, ['click']);
- event_buf.length = 0;
- R.set('bar');
- Meteor.flush();
- test.equal(div.text(), 'barism');
- clickElement(div.node().getElementsByTagName('SPAN')[0]);
- test.equal(event_buf, ['click']);
- event_buf.length = 0;
- div.kill();
- Meteor.flush();
-
- // Test that reactive fragments manually inserted inside
- // a reactive fragment eventually get wired.
- event_buf.length = 0;
- div = OnscreenDiv(Meteor.ui.render(function() {
- return "";
- }, { events: eventmap("click span", event_buf) }));
- Meteor.flush();
- div.node().firstChild.appendChild(Meteor.ui.render(function() {
- return 'hello';
- }));
- clickElement(getid("foozy"));
- // implementation has no way to know we've inserted the fragment
- test.equal(event_buf, []);
- event_buf.length = 0;
- Meteor.flush();
- clickElement(getid("foozy"));
- // now should be wired up
- test.equal(event_buf, ['click span']);
- event_buf.length = 0;
- div.kill();
- Meteor.flush();
-
- // Event data comes from event.currentTarget, not event.target
- var data_buf = [];
- div = OnscreenDiv(Meteor.ui.render(function() {
- return "
"+Meteor.ui.chunk(function() {
- return '
Hello
';
- }, { event_data: {x:'listuff'} })+"
";
- }, { event_data: {x:'ulstuff'},
- events: { 'click ul': function() { data_buf.push(this); }}}));
- clickElement(getid("funyard"));
- test.equal(data_buf, [{x:'ulstuff'}]);
- div.kill();
- Meteor.flush();
-});
Tinytest.add("liveui - cleanup", function(test) {
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index fa86ff3f1f..6f054174d7 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -4,13 +4,39 @@ Spark = {};
Spark._currentRenderer = new Meteor.EnvironmentVariable;
+Spark._TAG = "_spark_"+Meteor.uuid();
// XXX document contract for each type of annotation?
-Spark._ANNOTATION_DATA = "_spark_data";
-Spark._ANNOTATION_ISOLATE = "_spark_isolate";
-Spark._ANNOTATION_EVENTS = "_spark_events";
-Spark._ANNOTATION_WATCH = "_spark_watch";
-Spark._ANNOTATIONS = [Spark._ANNOTATION_DATA, Spark._ANNOTATION_ISOLATE,
- Spark._ANNOTATION_EVENTS, Spark._ANNOTATION_WATCH];
+Spark._ANNOTATION_NOTIFY = "notify";
+Spark._ANNOTATION_DATA = "data";
+Spark._ANNOTATION_ISOLATE = "isolate";
+Spark._ANNOTATION_EVENTS = "events";
+Spark._ANNOTATION_WATCH = "watch";
+
+// Set in tests to turn on extra UniversalEventListener sanity checks
+Spark._checkIECompliance = false;
+
+var makeRange = function(type, start, end, inner) {
+ var range = new LiveRange(Spark._TAG, start, end, inner);
+ range.type = type;
+ return range;
+};
+
+var findRangeOfType = function(type, node) {
+ var range = LiveRange.findRange(Spark._TAG, node);
+ while (range && range.type !== type)
+ range = range.findParent();
+
+ return range;
+};
+
+var findParentOfType = function (type, range) {
+ do {
+ range = range.findParent();
+ } while (range && range.type !== type);
+
+ return range;
+};
+
Spark._Renderer = function () {
// Map from annotation ID to an annotation function, which is called
@@ -39,12 +65,12 @@ _.extend(Spark._Renderer.prototype, {
// `what` can be a function that takes a LiveRange, or just a set of
// attributes to add to the liverange. tag and what are optional.
// if no tag is passed, no liverange will be created.
- annotate: function (html, tag, what) {
- var id = tag + "-" + this.createId();
+ annotate: function (html, type, what) {
+ var id = type + "-" + this.createId();
this.annotations[id] = function (start, end) {
- if (! tag)
+ if (! type)
return;
- var range = new LiveRange(tag, start, end);
+ var range = makeRange(type, start, end);
if (what instanceof Function)
what(range);
else
@@ -60,7 +86,34 @@ _.extend(Spark._Renderer.prototype, {
Spark.render = function (htmlFunc) {
var renderer = new Spark._Renderer;
var html = Spark._currentRenderer.withValue(renderer, function () {
- return renderer.annotate(htmlFunc());
+ return renderer.annotate(
+ htmlFunc(), Spark._ANNOTATION_NOTIFY, function (range) {
+ // This is a heuristic to benefit users that manually insert
+ // nodes into regions of the DOM that were rendered by Spark
+ // and have event maps. The heuristic is: at every flush, look
+ // for any nodes that were rendered by Spark since the last
+ // flush, and call notifyWatchers on them so that any
+ // *enclosing* event maps can wire up event handlers on the
+ // newly inserted nodes.
+ //
+ // This heuristic is only of relevance on IE6-8 and only if
+ // the user manually inserts nodes in the DOM (eg, through
+ // jQuery or appendChild). We're not sure it's the right thing
+ // and it could be removed at any time.
+ var finalized = false;
+ range.finalize = function () {
+ finalized = true;
+ };
+
+ var ctx = new Meteor.deps.Context;
+ ctx.on_invalidate(function () {
+ if (!finalized) {
+ notifyWatchers(range.firstNode(), range.lastNode());
+ range.destroy();
+ }
+ });
+ ctx.invalidate();
+ });
});
var fragById = {};
@@ -145,8 +198,7 @@ Spark.setDataContext = withRenderer(function (dataContext, html, _renderer) {
});
Spark.getDataContext = function (node) {
- var range = LiveRange.findRange(
- Spark._ANNOTATION_DATA, node);
+ var range = findRangeOfType(Spark._ANNOTATION_DATA, node);
return range && range.data;
};
@@ -155,20 +207,24 @@ var getListener = function () {
if (!universalListener)
universalListener = new UniversalEventListener(function (event) {
// Handle a currently-propagating event on a particular node.
- // We walk all enclosing liveranges of the node, from the inside
- // out, looking for matching handlers. If the app calls
- // stopPropagation(), we still call all handlers in all event
- // maps for the current node. If the app calls
- // "stopImmediatePropagation()", we don't call any more
- // handlers.
+ // We walk each enclosing liverange of the node and offer it the
+ // chance to handle the event. It's range.handler's
+ // responsibility to check isImmediatePropagationStopped()
+ // before delivering events to the user. We precompute the list
+ // of enclosing liveranges to defend against the case where user
+ // event handlers change the DOM.
- var range = LiveRange.findRange(Spark._ANNOTATION_EVENTS,
- event.currentTarget);
- while (range && !event.isImmediatePropagationStopped()) {
- range.handler(event);
- range = range.findParent();
+ var ranges = [];
+ var walk = findRangeOfType(Spark._ANNOTATION_EVENTS,
+ event.currentTarget);
+ while (walk) {
+ ranges.push(walk);
+ walk = findParentOfType(Spark._ANNOTATION_EVENTS, walk);
}
- });
+ _.each(ranges, function (r) {
+ r.handler(event);
+ });
+ }, Spark._checkIECompliance);
return universalListener;
};
@@ -214,6 +270,8 @@ Spark.attachEvents = withRenderer(function (eventMap, html, _renderer) {
}
});
+ var finalized = false;
+
html = _renderer.annotate(
html, Spark._ANNOTATION_EVENTS, function (range) {
_.each(eventTypes, function (t) {
@@ -221,10 +279,17 @@ Spark.attachEvents = withRenderer(function (eventMap, html, _renderer) {
});
installHandlers(range);
+ range.finalize = function () {
+ finalized = true;
+ };
+
range.handler = function (event) {
var handlers = handlerMap[event.type] || [];
for (var i = 0; i < handlers.length; i++) {
+ if (finalized || event.isImmediatePropagationStopped())
+ return;
+
var handler = handlers[i];
var callback = handler.callback;
var selector = handler.selector;
@@ -246,8 +311,12 @@ Spark.attachEvents = withRenderer(function (eventMap, html, _renderer) {
continue;
}
- // Found a matching handler.
+ // Found a matching handler. Call it.
var eventData = Spark.getDataContext(event.currentTarget);
+ // Note that the handler can do arbitrary things, like call
+ // Meteor.flush() or otherwise remove and finalize parts of
+ // the DOM. We can't assume `range` is valid past this point,
+ // and we'll check the `finalized` flag at the top of the loop.
var returnValue = callback.call(eventData, event);
// allow app to `return false` from event handler, just like
@@ -256,8 +325,6 @@ Spark.attachEvents = withRenderer(function (eventMap, html, _renderer) {
event.stopImmediatePropagation();
event.preventDefault();
}
- if (event.isImmediatePropagationStopped())
- break; // don't let any other handlers in this event map fire
}
};
});
@@ -309,8 +376,8 @@ Spark.isolate = function (htmlFunc) {
return Spark.isolate(htmlFunc);
});
- var tempRange = new LiveRange(Spark._ANNOTATION_ISOLATE, frag, null,
- true /* inner */);
+ var tempRange = makeRange(Spark._ANNOTATION_ISOLATE, frag, null,
+ true /* inner */);
tempRange.operate(function (start, end) {
// Wrap contents of frag, *inside* the ISOLATE annotation,
// as appropriate for insertion into `range`. We want the
@@ -324,7 +391,7 @@ Spark.isolate = function (htmlFunc) {
var oldContents = range.replace_contents(frag); // XXX should patch
Spark.finalize(oldContents);
- notifyWatchers(range);
+ notifyWatchers(range.firstNode(), range.lastNode());
range.destroy();
});
});
@@ -332,18 +399,12 @@ Spark.isolate = function (htmlFunc) {
return html;
};
-var notifyWatchers = function (range) {
- // find the innermost WATCH annotation containing the nodes in `range`
- var tempRange = new LiveRange(Spark._ANNOTATION_WATCH, range.firstNode(),
- range.lastNode(), true /* innermost */);
- var walk = tempRange.findParent();
+var notifyWatchers = function (start, end) {
+ var tempRange = new LiveRange(Spark._TAG, start, end, true /* innermost */);
+ for (var walk = tempRange; walk; walk = walk.findParent())
+ if (walk.type === Spark._ANNOTATION_WATCH)
+ walk.notify();
tempRange.destroy();
-
- // tell all enclosing WATCH annotations that their contents changed
- while (walk) {
- walk.notify();
- walk = walk.findParent();
- }
};
// Delete all of the liveranges in the range of nodes between `start`
@@ -358,13 +419,11 @@ Spark.finalize = function (start, end) {
start = frag;
end = null;
}
- _.each(Spark._ANNOTATIONS, function (tag) {
- var wrapper = new LiveRange(tag, start, end);
- wrapper.visit(function (isStart, range) {
- isStart && range.finalize && range.finalize();
- });
- wrapper.destroy(true /* recursive */);
+ var wrapper = new LiveRange(Spark._TAG, start, end);
+ wrapper.visit(function (isStart, range) {
+ isStart && range.finalize && range.finalize();
});
+ wrapper.destroy(true /* recursive */);
};
})();
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index daaa158487..f911510d6b 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -1,15 +1,19 @@
-// XXX when testing spark, set the checkIECompliance flag on universal-events somehow
+// XXX make sure that when tests use id="..." to trigger patching, "preserve" happens
+
+Spark._checkIECompliance = true;
+
+(function () {
Tinytest.add("spark - assembly", function (test) {
- var doTest = function(calc) {
- var frag = Spark.render(function() {
- return calc(function(str, expected) {
+ var doTest = function (calc) {
+ var frag = Spark.render(function () {
+ return calc(function (str, expected) {
return Spark.setDataContext(null, str);
});
});
var groups = [];
- var html = calc(function(str, expected, noRange) {
+ var html = calc(function (str, expected, noRange) {
if (arguments.length > 1)
str = expected;
if (! noRange)
@@ -20,41 +24,41 @@ Tinytest.add("spark - assembly", function (test) {
test.equal(f.html(), html);
var actualGroups = [];
- var tempRange = new LiveRange(Spark._ANNOTATION_DATA, frag);
- tempRange.visit(function(isStart, rng) {
- if (! isStart)
+ var tempRange = new LiveRange(Spark._TAG, frag);
+ tempRange.visit(function (isStart, rng) {
+ if (! isStart && rng.type === Spark._ANNOTATION_DATA)
actualGroups.push(rangeToHtml(rng));
});
test.equal(actualGroups.join(','), groups.join(','));
};
- doTest(function(A) { return "
" + Spark.labelBranch("a", function () {
+ var inner = Spark.labelBranch("b", function () {
+ return Spark.isolate(function () {
+ R.get();
+ return Spark.createLandmark({
+ create: function () {
+ test.instanceOf(this, Spark.Landmark);
+ if (l2)
+ test.equal(l2, this);
+ l2 = this;
+ }
+ }, "b4b6");
+ });
+ });
+ var html =
+ Spark.createLandmark({
+ create: function () {
+ test.instanceOf(this, Spark.Landmark);
+ if (l1)
+ test.equal(l1, this);
+ l1 = this;
+ }
+ }, "a" + inner + "");
+ return html;
+ }) + "c
";
+ }));
+
+ var ids = function (nodes) {
+ if (nodes instanceof Array)
+ nodes = [nodes];
+ return _.pluck(nodes, 'id').join('');
+ };
+
+ test.equal(ids(l1.find('.kitten')), '');
+ test.equal(ids(l2.find('.kitten')), '');
+
+// test.equal(ids(l1.find('.a')), '3');
+// test.equal(ids(l2.find('.a')), '');
+ // XXX work in progress
+});
+
From a9dac1f0aa56fbd1a83aa7e9e0bde1a61b748726 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Tue, 7 Aug 2012 16:05:13 -0700
Subject: [PATCH 112/212] refactor to make LabelStack
---
packages/spark/spark.js | 143 ++++++++++++++++++++++------------------
1 file changed, 78 insertions(+), 65 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 57308d05ac..263e5e8fb3 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -73,31 +73,28 @@ var notifyWatchers = function (start, end) {
tempRange.destroy();
};
-Spark._Renderer = function (getLandmarkState) {
+Spark._Renderer = function () {
// Map from annotation ID to an annotation function, which is called
// at render time and receives (startNode, endNode).
this.annotations = {};
- // A map of landmarks, organized by branch path in a tree.
- // XXX document better (see visitLandmarkTree)
- this.landmarkTree = {};
+ // Map from branch path to "notes" object, organized as a tree.
+ // Each node in the tree has child pointers named ('_'+label).
+ // Properties that don't start with '_' are arbitrary notes.
+ // For example, the "happiness" of the branch path consisting
+ // of labels "foo" and then "bar" would be
+ // `this._branchNotes._foo._bar.happiness`.
+ // Access to these notes is provided by LabelStack objects, of
+ // which `this.currentBranch` is one.
+ this._branchNotes = {};
- // Array of pointers into this.landmarkTree for
- // visitLandmarkTree-style traversal of landmarks as rendering is
- // happening (XXX document better)
- this.labelStack = [this.landmarkTree];
+ // The label stack representing the current branch path we
+ // are in (based on calls to `Spark.labelBranch(label, htmlFunc)`).
+ this.currentBranch = this.newLabelStack();
// All landmark ranges created during this rendering.
this.landmarks = [];
- // Function to call when a landmark is encountered during rendering
- // (that is, when createLandmark is called) to get the state object
- // for that landmark (from a previous version of the landmark.) Or
- // return null if the landmark is new and a new state object should
- // be created.
- // XXX document better
- this.getLandmarkState = getLandmarkState || function () { return null; };
-
// Assembles the preservation information for patching.
this.pc = new PreservationController;
};
@@ -136,6 +133,30 @@ _.extend(Spark._Renderer.prototype, {
};
return "<$" + id + ">" + html + "$" + id + ">";
+ },
+
+ // A LabelStack is a mutable branch path that you can modify
+ // by pushing or popping labels. At any time, you can ask for
+ // this Renderer's notes for the current branch path.
+ // Renderer's `currentBranch` field is a LabelStack, but you
+ // can create your own for the purpose of walking the branches
+ // and accessing notes.
+ newLabelStack: function () {
+ var stack = [this._branchNotes];
+ return {
+ pushLabel: function (label) {
+ var top = stack[stack.length - 1];
+ var key = '_' + label;
+ stack.push(top[key] = (top[key] || {}));
+ },
+ popLabel: function () {
+ stack.pop();
+ },
+ getNotes: function () {
+ var top = stack[stack.length - 1];
+ return top;
+ }
+ };
}
});
@@ -407,20 +428,31 @@ _.extend(PreservationController.prototype, {
// `range` is a region of `document`. Modify it in-place so that it
// matches the result of Spark.render(htmlFunc), preserving landmarks.
Spark.renderToRange = function (range, htmlFunc) {
- var getLandmarkState = function () {
- var node = renderer.labelStack[this.labelStack.length - 1];
+ var renderer = new Spark._Renderer();
- if (node.original && ! node.original.superceded) {
- node.original.superceded = true; // prevent destroy(), second match
- return node.original.state; // the old state
- } else
- return null;
+ // Call 'func' for each landmark in 'range'. Pass two arguments to
+ // 'func', the range, and an extra "notes" object such that two
+ // landmarks receive the same (===) notes object iff they have the
+ // same branch path. 'func' can write to the notes object so long as
+ // it limits itself to attributes that do not start with '_'.
+ var visitLandmarksInRange = function (range, func) {
+ var stack = renderer.newLabelStack();
+
+ range.visit(function (isStart, r) {
+ if (r.type === Spark._ANNOTATION_LABEL) {
+ if (isStart)
+ stack.pushLabel(r.label);
+ else
+ stack.popLabel();
+ } else if (r.type === Spark._ANNOTATION_LANDMARK && isStart) {
+ func(r, stack.getNotes());
+ }
+ });
};
- var renderer = new Spark._Renderer(getLandmarkState);
// Find all of the landmarks in the old contents of the range
- visitLandmarkTree(renderer.landmarkTree, range, function (landmark, node) {
- node.original = landmark;
+ visitLandmarksInRange(range, function (landmark, notes) {
+ notes.original = landmark;
});
var html = Spark._currentRenderer.withValue(renderer, htmlFunc);
@@ -434,14 +466,14 @@ Spark.renderToRange = function (range, htmlFunc) {
// find preservation roots from matched landmarks inside the
// rerendered region
var pc = renderer.pc;
- visitLandmarkTree(
- renderer.landmarkTree, tempRange, function (landmark, node) {
- if (node.original) {
+ visitLandmarksInRange(
+ tempRange, function (landmark, notes) {
+ if (notes.original) {
if (landmark.constant)
- pc.addConstantRegion(node.original, landmark);
+ pc.addConstantRegion(notes.original, landmark);
- pc.addRoot(node.original.containerNode(), landmark.preserve,
- node.original, landmark);
+ pc.addRoot(notes.original.containerNode(), landmark.preserve,
+ notes.original, landmark);
}
});
@@ -834,12 +866,9 @@ Spark.labelBranch = function (label, htmlFunc) {
if (! renderer || label === null)
return htmlFunc();
- var stack = renderer.labelStack;
- var top = stack[stack.length - 1];
- var key = '_' + label;
- stack.push(top[key] = (top[key] || {}));
+ renderer.currentBranch.pushLabel(label);
var html = htmlFunc();
- stack.pop();
+ renderer.currentBranch.popLabel();
return renderer.annotate(
html, Spark._ANNOTATION_LABEL, { label: label });
@@ -871,14 +900,21 @@ Spark.createLandmark = withRenderer(function (options, html, _renderer) {
if (typeof preserve[selector] !== 'function')
preserve[selector] = function () { return true; };
+ var notes = _renderer.currentBranch.getNotes();
// XXX 'state' has gotten to be a bad name for this variable
- var state = _renderer.getLandmarkState();
+ var state;
+ if (notes.original && ! notes.original.superceded) {
+ notes.original.superceded = true; // prevent destroy(), second match
+ state = notes.original.state; // the old state
+ } else {
+ state = null;
+ }
+
if (state === null) {
state = new Spark.Landmark;
options.create && options.create.call(state);
}
- var top = _renderer.labelStack[_renderer.labelStack.length - 1];
- top.current = state;
+ notes.current = state;
return _renderer.annotate(
html, Spark._ANNOTATION_LANDMARK, function (range) {
@@ -910,8 +946,8 @@ Spark.getCurrentLandmark = function () {
var renderer = Spark._currentRenderer.get();
if (! renderer)
throw new Error("Only available during rendering");
- var top = renderer.labelStack[renderer.labelStack.length - 1];
- return top.current || null;
+ var notes = renderer.currentBranch.getNotes();
+ return notes.current || null;
};
@@ -920,27 +956,4 @@ Spark.getEnclosingLandmark = function (node) {
return range ? range.state : null;
};
-// XXX could use docs, better name
-var visitLandmarkTree = function (tree, range, func) {
- // Call 'func' for each landmark in 'range'. Pass two arguments to
- // 'func', the range, and an extra "notes" object such that two
- // landmarks receive the same (===) notes object iff they have the
- // same branch path. 'func' can write to the notes object so long as
- // it limits itself to attributes that do not start with '_'.
- var stack = [tree];
-
- range.visit(function (isStart, r) {
- var top = stack[stack.length - 1];
-
- if (r.type === Spark._ANNOTATION_LABEL) {
- if (isStart) {
- var key = '_' + r.label;
- stack.push(top[key] = (top[key] || {}));
- } else
- stack.pop();
- } else if (r.type === Spark._ANNOTATION_LANDMARK && isStart)
- func(r, top);
- });
-};
-
})();
\ No newline at end of file
From 14704f846843396d3b5e6778c54d27bfff9a8eb7 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Tue, 7 Aug 2012 16:59:23 -0700
Subject: [PATCH 113/212] naming: state->landmark, landmark->landmarkRange
---
packages/spark/spark.js | 70 +++++++++++++++++++----------------------
1 file changed, 33 insertions(+), 37 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 263e5e8fb3..f84246862d 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -93,7 +93,7 @@ Spark._Renderer = function () {
this.currentBranch = this.newLabelStack();
// All landmark ranges created during this rendering.
- this.landmarks = [];
+ this.landmarkRanges = [];
// Assembles the preservation information for patching.
this.pc = new PreservationController;
@@ -253,13 +253,13 @@ var materialize = function (html, renderer) {
// newly rendered fragment must be on the screen (if it doesn't want
// to get garbage-collected.)
//
-// 'landmarks' is a list of the landmark ranges in 'frag'. It may be
+// 'landmarkRanges' is a list of the landmark ranges in 'frag'. It may be
// omitted if frag doesn't contain any landmarks.
//
// XXX expose in the public API, eg as Spark.introduce(), so the user
// can call it when manually inserting nodes? (via, eg, jQuery?) -- of
-// course in that case 'landmarks' would be empty.
-var scheduleOnscreenSetup = function (frag, landmarks) {
+// course in that case 'landmarkRanges' would be empty.
+var scheduleOnscreenSetup = function (frag, landmarkRanges) {
var renderedRange = new LiveRange(Spark._TAG, frag);
var finalized = false;
renderedRange.finalize = function () {
@@ -299,8 +299,8 @@ var scheduleOnscreenSetup = function (frag, landmarks) {
//
// XXX should bubble up and notify parent landmarks too? for all
// the same reasons we need to do it for node preservation?
- _.each(landmarks, function (landmark) {
- landmark.renderCallback.call(landmark.state);
+ _.each(landmarkRanges, function (landmarkRange) {
+ landmarkRange.renderCallback.call(landmarkRange.landmark);
});
// This code can run several times on the same nodes (if the
@@ -319,7 +319,7 @@ Spark.render = function (htmlFunc) {
var html = Spark._currentRenderer.withValue(renderer, htmlFunc);
var frag = materialize(html, renderer);
- scheduleOnscreenSetup(frag, renderer.landmarks);
+ scheduleOnscreenSetup(frag, renderer.landmarkRanges);
return frag;
};
@@ -341,7 +341,7 @@ Spark.render = function (htmlFunc) {
// the document DOM tree. The implementation will temporarily reparent
// the nodes in `newRange` into the document to check for selector matches.
var PreservationController = function () {
- this.roots = []; // keys 'landmark', 'fromRange', 'toRange'
+ this.roots = []; // keys 'landmarkRange', 'fromRange', 'toRange'
this.regionPreservations = [];
};
@@ -451,13 +451,13 @@ Spark.renderToRange = function (range, htmlFunc) {
};
// Find all of the landmarks in the old contents of the range
- visitLandmarksInRange(range, function (landmark, notes) {
- notes.original = landmark;
+ visitLandmarksInRange(range, function (landmarkRange, notes) {
+ notes.originalRange = landmarkRange;
});
var html = Spark._currentRenderer.withValue(renderer, htmlFunc);
var frag = materialize(html, renderer);
- scheduleOnscreenSetup(frag, renderer.landmarks);
+ scheduleOnscreenSetup(frag, renderer.landmarkRanges);
DomUtils.wrapFragmentForContainer(frag, range.containerNode());
@@ -467,13 +467,14 @@ Spark.renderToRange = function (range, htmlFunc) {
// rerendered region
var pc = renderer.pc;
visitLandmarksInRange(
- tempRange, function (landmark, notes) {
- if (notes.original) {
- if (landmark.constant)
- pc.addConstantRegion(notes.original, landmark);
+ tempRange, function (landmarkRange, notes) {
+ if (notes.originalRange) {
+ if (landmarkRange.constant)
+ pc.addConstantRegion(notes.originalRange, landmarkRange);
- pc.addRoot(notes.original.containerNode(), landmark.preserve,
- notes.original, landmark);
+ pc.addRoot(notes.originalRange.containerNode(),
+ landmarkRange.preserve,
+ notes.originalRange, landmarkRange);
}
});
@@ -901,20 +902,15 @@ Spark.createLandmark = withRenderer(function (options, html, _renderer) {
preserve[selector] = function () { return true; };
var notes = _renderer.currentBranch.getNotes();
- // XXX 'state' has gotten to be a bad name for this variable
- var state;
- if (notes.original && ! notes.original.superceded) {
- notes.original.superceded = true; // prevent destroy(), second match
- state = notes.original.state; // the old state
+ var landmark;
+ if (notes.originalRange && ! notes.originalRange.superceded) {
+ notes.originalRange.superceded = true; // prevent destroy(), second match
+ landmark = notes.originalRange.landmark; // the old Landmark
} else {
- state = null;
+ landmark = new Spark.Landmark;
+ options.create && options.create.call(landmark);
}
-
- if (state === null) {
- state = new Spark.Landmark;
- options.create && options.create.call(state);
- }
- notes.current = state;
+ notes.landmark = landmark;
return _renderer.annotate(
html, Spark._ANNOTATION_LANDMARK, function (range) {
@@ -923,22 +919,22 @@ Spark.createLandmark = withRenderer(function (options, html, _renderer) {
constant: !! options.constant,
renderCallback: options.render || function () {},
destroyCallback: options.destroy || function () {},
- state: state,
+ landmark: landmark,
finalize: function () {
if (! this.superceded)
- this.destroyCallback.call(this.state);
+ this.destroyCallback.call(this.landmark);
}
});
- state._range = range;
- _renderer.landmarks.push(range);
+ landmark._range = range;
+ _renderer.landmarkRanges.push(range);
});
// XXX need to arrange for destroyCallback to be called if the
// returned html is never materialized..
});
-// Call during rendering to get the (state object for) the landmark
+// Call during rendering to get the landmark
// with the current branch path (as determined by the
// Spark.labelBranch calls on the stack), or null if createLandmark()
// has not yet been called with this branch path during this render.
@@ -947,13 +943,13 @@ Spark.getCurrentLandmark = function () {
if (! renderer)
throw new Error("Only available during rendering");
var notes = renderer.currentBranch.getNotes();
- return notes.current || null;
+ return notes.landmark || null;
};
Spark.getEnclosingLandmark = function (node) {
var range = findRangeOfType(Spark._ANNOTATION_LANDMARK, node);
- return range ? range.state : null;
+ return range ? range.landmark : null;
};
-})();
\ No newline at end of file
+})();
From de53ca9205f01ec54d38afe4f74c313584540903 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Tue, 7 Aug 2012 19:40:48 -0700
Subject: [PATCH 114/212] more XXX TODOs
---
packages/spark/spark.js | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index f84246862d..6cd6dfe8bd 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -18,9 +18,17 @@
// path, or if you have multiple preserve nodes in a landmark with the
// same selector and label
-// getCurrentLandmark always searches up
+// XXX event handling passes args (event, landmark) to handler,
+// where `landmark` is the immediately enclosing landmark of the
+// attachEvents range.
-// getCurrentLandmark creates a dummy renderer if there isn't one
+// XXX createLandmark takes an htmlFunc, which takes the landmark
+// as its argument. There is no getCurrentLandmark. If there's
+// no renderer, we createLandmark doesn't create an annotation,
+// but it does instantiate a new Landmark for the sake of the
+// htmlFunc (and create/destroy it inline).
+
+// XXX delete getEnclosingLandmark (unless used privately by tests)
(function() {
From 14b8f526fb8570f6919d3b21e6c8f8c11b94f205 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Tue, 7 Aug 2012 22:30:58 -0700
Subject: [PATCH 115/212] createLandmark calls htmlFunc (fixing tests wip)
---
packages/spark/spark.js | 52 +++++++++++++-----------------
packages/spark/spark_tests.js | 31 ++++++++++--------
packages/templating/deftemplate.js | 10 +++---
3 files changed, 45 insertions(+), 48 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 6cd6dfe8bd..09283d3c83 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -18,17 +18,7 @@
// path, or if you have multiple preserve nodes in a landmark with the
// same selector and label
-// XXX event handling passes args (event, landmark) to handler,
-// where `landmark` is the immediately enclosing landmark of the
-// attachEvents range.
-
-// XXX createLandmark takes an htmlFunc, which takes the landmark
-// as its argument. There is no getCurrentLandmark. If there's
-// no renderer, we createLandmark doesn't create an annotation,
-// but it does instantiate a new Landmark for the sake of the
-// htmlFunc (and create/destroy it inline).
-
-// XXX delete getEnclosingLandmark (unless used privately by tests)
+// XXX should functions with an htmlFunc use try/finally inside?
(function() {
@@ -657,11 +647,15 @@ Spark.attachEvents = withRenderer(function (eventMap, html, _renderer) {
// Found a matching handler. Call it.
var eventData = Spark.getDataContext(event.currentTarget);
+ var landmarkRange =
+ findParentOfType(Spark._ANNOTATION_LANDMARK, range);
+ var landmark = (landmarkRange && landmarkRange.landmark);
+
// Note that the handler can do arbitrary things, like call
// Meteor.flush() or otherwise remove and finalize parts of
// the DOM. We can't assume `range` is valid past this point,
// and we'll check the `finalized` flag at the top of the loop.
- var returnValue = callback.call(eventData, event);
+ var returnValue = callback.call(eventData, event, landmark);
// allow app to `return false` from event handler, just like
// you can in a jquery event handler
@@ -896,7 +890,17 @@ Spark.labelBranch = function (label, htmlFunc) {
// nodes?)
};
-Spark.createLandmark = withRenderer(function (options, html, _renderer) {
+Spark.createLandmark = function (options, htmlFunc) {
+ var renderer = Spark._currentRenderer.get();
+ if (! renderer) {
+ // no renderer -- create and destroy Landmark inline
+ var landmark = new Spark.Landmark;
+ options.create && options.create.call(landmark);
+ var html = htmlFunc(landmark);
+ options.destroy && options.destroy.call(landmark);
+ return html;
+ }
+
// Normalize preserve map
var preserve = {};
if (options.preserve instanceof Array)
@@ -909,7 +913,7 @@ Spark.createLandmark = withRenderer(function (options, html, _renderer) {
if (typeof preserve[selector] !== 'function')
preserve[selector] = function () { return true; };
- var notes = _renderer.currentBranch.getNotes();
+ var notes = renderer.currentBranch.getNotes();
var landmark;
if (notes.originalRange && ! notes.originalRange.superceded) {
notes.originalRange.superceded = true; // prevent destroy(), second match
@@ -920,7 +924,8 @@ Spark.createLandmark = withRenderer(function (options, html, _renderer) {
}
notes.landmark = landmark;
- return _renderer.annotate(
+ var html = htmlFunc(landmark);
+ return renderer.annotate(
html, Spark._ANNOTATION_LANDMARK, function (range) {
_.extend(range, {
preserve: preserve,
@@ -935,27 +940,14 @@ Spark.createLandmark = withRenderer(function (options, html, _renderer) {
});
landmark._range = range;
- _renderer.landmarkRanges.push(range);
+ renderer.landmarkRanges.push(range);
});
// XXX need to arrange for destroyCallback to be called if the
// returned html is never materialized..
-});
-
-// Call during rendering to get the landmark
-// with the current branch path (as determined by the
-// Spark.labelBranch calls on the stack), or null if createLandmark()
-// has not yet been called with this branch path during this render.
-Spark.getCurrentLandmark = function () {
- var renderer = Spark._currentRenderer.get();
- if (! renderer)
- throw new Error("Only available during rendering");
- var notes = renderer.currentBranch.getNotes();
- return notes.landmark || null;
};
-
-Spark.getEnclosingLandmark = function (node) {
+Spark._getEnclosingLandmark = function (node) {
var range = findRangeOfType(Spark._ANNOTATION_LANDMARK, node);
return range ? range.landmark : null;
};
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index 5c2b834d11..223c52bbc1 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -36,8 +36,8 @@ var legacyLabels = {
var renderWithLegacyLabels = function (htmlFunc) {
return Meteor.render(function () {
- var html = htmlFunc();
- return Spark.createLandmark({ preserve: legacyLabels }, html);
+ return Spark.createLandmark({ preserve: legacyLabels },
+ htmlFunc);
});
};
@@ -1115,7 +1115,7 @@ Tinytest.add("spark - basic landmarks", function (test) {
destroy: function () {
x.push("d", this.a);
}
- }, "hi");
+ }, function() { return "hi"; });
});
}));
@@ -1165,7 +1165,6 @@ Tinytest.add("spark - labeled landmarks", function (test) {
var f = function () {
var thisSerial = serial++;
- var html = htmlFunc();
return Spark.createLandmark({
create: function () {
@@ -1183,7 +1182,7 @@ Tinytest.add("spark - labeled landmarks", function (test) {
s.push(thisSerial);
test.equal(this.id, id);
}
- }, html);
+ }, htmlFunc);
};
if (isolateLandmarks.get())
@@ -1213,7 +1212,7 @@ Tinytest.add("spark - labeled landmarks", function (test) {
});
}));
- expect(["c", 1, "c", 2, "c", 5, "c", 4, "c", 3], [1, 2, 5, 4, 3]);
+ expect(["c", 1, "c", 2, "c", 3, "c", 4, "c", 5], [1, 2, 3, 4, 5]);
Meteor.flush();
expect(["r", 1, "r", 2, "r", 5, "r", 4, "r", 3], [1, 2, 5, 4, 3]);
for (var i = 0; i < 10; i++) {
@@ -1241,8 +1240,8 @@ Tinytest.add("spark - labeled landmarks", function (test) {
excludeLandmarks[3].set(false);
expect([], []);
Meteor.flush();
- expect(["c", 5, "c", 4, "c", 3, "d", 2, "r", 1, "r", 5, "r", 4, "r", 3],
- [65, 64, 63, 61, 62, 65, 64, 63]);
+ expect(["c", 3, "c", 4, "c", 5, "d", 2, "r", 1, "r", 5, "r", 4, "r", 3],
+ [63, 64, 65, 61, 62, 65, 64, 63]);
excludeLandmarks[2].set(false);
expect([], []);
@@ -1611,7 +1610,7 @@ Tinytest.add("spark - landmark constant", function(test) {
render: function() {
states.push(this);
}
- }, '');
+ }, function() { return ''; });
}));
var nodes = _.toArray(div.node().childNodes);
@@ -1649,8 +1648,10 @@ Tinytest.add("spark - landmark constant", function(test) {
return (nodeBefore ? R.get() : '') +
Spark.labelBranch(
brnch, function () {
- return Spark.createLandmark({ constant: isConstant },
- hasSpan ? 'stuff' : 'blah');}) +
+ return Spark.createLandmark(
+ { constant: isConstant },
+ function() { return hasSpan ?
+ 'stuff' : 'blah'; });}) +
(nodeAfter ? R.get() : '');
}));
@@ -1740,7 +1741,9 @@ Tinytest.add("spark - leaderboard", function(test) {
'
' + player.name + '
' +
'
' + player.score + '
';
html = Spark.setDataContext(player, html);
- html = Spark.createLandmark({preserve: legacyLabels}, html);
+ html = Spark.createLandmark(
+ {preserve: legacyLabels},
+ function() { return html; });
return html;
});
});
@@ -1884,7 +1887,9 @@ Tinytest.add("spark - list table", function(test) {
return Spark.isolate(function () {
var html = "
"+doc.value + (doc.reactive ? R.get() : '')+
"
";
- html = Spark.createLandmark({preserve: legacyLabels}, html);
+ html = Spark.createLandmark(
+ {preserve: legacyLabels},
+ function() { return html; });
return html;
});
});
diff --git a/packages/templating/deftemplate.js b/packages/templating/deftemplate.js
index 1b1c0fdff1..3f998f8d6f 100644
--- a/packages/templating/deftemplate.js
+++ b/packages/templating/deftemplate.js
@@ -55,7 +55,11 @@
var t = name && Template[name];
if (t) {
html = Spark.attachEvents(t.events || {}, html);
- html = Spark.createLandmark({ preserve: t.preserve || {} }, html);
+ html = Spark.createLandmark(
+ { preserve: t.preserve || {} },
+ // XXX actually, we need to make this landmark available
+ // to Forms and execute the template here.
+ function(landmark) { return html; });
}
html = Spark.setDataContext(data, html);
@@ -85,7 +89,3 @@
};
})();
-
-
-
-
From 4ab40534fe4f42037251ee5e31072e74565d96cd Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Wed, 8 Aug 2012 10:51:58 -0700
Subject: [PATCH 116/212] fix tests (whew)
---
packages/spark/spark.js | 1 +
packages/spark/spark_tests.js | 250 ++++++++++++++++++----------------
2 files changed, 133 insertions(+), 118 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 09283d3c83..72b186f114 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -947,6 +947,7 @@ Spark.createLandmark = function (options, htmlFunc) {
// returned html is never materialized..
};
+// used by unit tests
Spark._getEnclosingLandmark = function (node) {
var range = findRangeOfType(Spark._ANNOTATION_LANDMARK, node);
return range ? range.landmark : null;
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index 223c52bbc1..2cf5af7079 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -1212,6 +1212,7 @@ Tinytest.add("spark - labeled landmarks", function (test) {
});
}));
+ // callback order is not specced
expect(["c", 1, "c", 2, "c", 3, "c", 4, "c", 5], [1, 2, 3, 4, 5]);
Meteor.flush();
expect(["r", 1, "r", 2, "r", 5, "r", 4, "r", 3], [1, 2, 5, 4, 3]);
@@ -2635,8 +2636,9 @@ Tinytest.add("spark - oldschool landmark matching", function(test) {
counts = {};
var R = ReactiveVar("A");
var div = OnscreenDiv(Meteor.render(function() {
- var html = String(R.get());
- html = Spark.createLandmark(testCallbacks(0), html);
+ var html = Spark.createLandmark(testCallbacks(0), function () {
+ return String(R.get());
+ });
return html;
}, testCallbacks(0)));
@@ -2664,19 +2666,20 @@ Tinytest.add("spark - oldschool landmark matching", function(test) {
R = ReactiveVar("A");
div = OnscreenDiv(Meteor.render(function() {
R.get();
- var html = Spark.labelBranch("foo", function () {
- return Spark.createLandmark(testCallbacks(1), "HI");
+ return Spark.createLandmark(testCallbacks(0), function () {
+ var html = Spark.labelBranch("foo", function () {
+ return Spark.createLandmark(testCallbacks(1),
+ function () { return "HI"; });
+ });
+ return "
" + html + "
";
});
- html = "
" + html + "
";
- html = Spark.createLandmark(testCallbacks(0), html);
- return html;
}));
- test.equal(buf, ["c1", "c0"]);
+ test.equal(buf, ["c0", "c1"]);
Meteor.flush();
// what order of chunks {0,1} is preferable??
// should be consistent but I'm not sure what makes most sense.
- test.equal(buf, "c1,c0,r1,r0".split(','));
+ test.equal(buf, "c0,c1,r1,r0".split(','));
buf.length = 0;
R.set("B");
@@ -2700,11 +2703,9 @@ Tinytest.add("spark - oldschool branch keys", function(test) {
var objs = [];
R = ReactiveVar("foo");
div = OnscreenDiv(Meteor.render(function() {
- var html = R.get();
- html = Spark.createLandmark({
+ return Spark.createLandmark({
render: function () { objs.push(true); }
- }, html);
- return html;
+ }, function () { return R.get(); });
}));
Meteor.flush();
@@ -2750,20 +2751,20 @@ Tinytest.add("spark - oldschool branch keys", function(test) {
branch = "unique_branch_" + (counter++);
return Spark.labelBranch(branch, function () {
- var html;
- if (typeof contents === "string")
- html = contents;
- else if (_.isArray(contents))
- html = _.map(contents, function(x) {
- if (typeof x === 'string')
- return x;
- return chunk(x[0], x[1], x[2]);
- }).join('');
- else
- html = contents();
-
- html = Spark.createLandmark(testCallbacks(num), html);
- return html;
+ return Spark.createLandmark(
+ testCallbacks(num),
+ function () {
+ if (typeof contents === "string")
+ return contents;
+ else if (_.isArray(contents))
+ return _.map(contents, function(x) {
+ if (typeof x === 'string')
+ return x;
+ return chunk(x[0], x[1], x[2]);
+ }).join('');
+ else
+ return contents();
+ });
});
};
@@ -2852,9 +2853,11 @@ Tinytest.add("spark - isolate inside landmark", function (test) {
var d = OnscreenDiv(Spark.render(function () {
return Spark.createLandmark(
{ preserve: ['.foo'] },
- Spark.isolate(function () {
- return '' + R.get();
- }));
+ function () {
+ return Spark.isolate(function () {
+ return '' + R.get();
+ });
+ });
}));
var foo1 = d.node().firstChild;
@@ -2874,9 +2877,11 @@ Tinytest.add("spark - isolate inside landmark", function (test) {
d = OnscreenDiv(Spark.render(function () {
return Spark.createLandmark(
{ preserve: ['div .foo'] },
- "
";
}));
var ids = function (nodes) {
From f3a66b59f2541bf4e8cb8fa791411d89e22b0ff6 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Wed, 8 Aug 2012 14:37:25 -0700
Subject: [PATCH 117/212] fix region patching
---
packages/spark/patch.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/spark/patch.js b/packages/spark/patch.js
index 01e2f7a211..e263cc1dc4 100644
--- a/packages/spark/patch.js
+++ b/packages/spark/patch.js
@@ -39,7 +39,8 @@ Spark._patch = function(tgtParent, srcParent, tgtBefore, tgtAfter, preservations
DomUtils.elementOrder(lastTgtMatch, tgt) > 0) {
if (pres.type === 'region') {
// preserved region for constant landmark
- if (patcher.match(pres.fromStart, pres.newRange.firstNode(), null, true)) {
+ if (patcher.match(pres.fromStart, pres.newRange.firstNode(),
+ copyFunc, true)) {
patcher.skipToSiblings(pres.fromEnd, pres.newRange.lastNode());
// without knowing or caring what DOM nodes are in pres.newRange,
// transplant the range data to pres.fromStart and pres.fromEnd
From 88d13f1862da779bcb08e3fbf9e265ae9d29f8cb Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Wed, 8 Aug 2012 14:39:14 -0700
Subject: [PATCH 118/212] constant landmark only gets render() if not preserved
---
packages/spark/patch.js | 12 ++++++-
packages/spark/spark.js | 22 +++++++++---
packages/spark/spark_tests.js | 63 ++++++++++++++++++++++++++++++++---
3 files changed, 88 insertions(+), 9 deletions(-)
diff --git a/packages/spark/patch.js b/packages/spark/patch.js
index e263cc1dc4..a4343bb395 100644
--- a/packages/spark/patch.js
+++ b/packages/spark/patch.js
@@ -1,5 +1,6 @@
-Spark._patch = function(tgtParent, srcParent, tgtBefore, tgtAfter, preservations) {
+Spark._patch = function(tgtParent, srcParent, tgtBefore, tgtAfter, preservations,
+ results) {
var copyFunc = function(t, s) {
LiveRange.transplantTag(Spark._TAG, t, s);
@@ -18,6 +19,13 @@ Spark._patch = function(tgtParent, srcParent, tgtBefore, tgtAfter, preservations
}
};
+ // results arg is optional; it is mutated if provided; returned either way
+ results = (results || {});
+ // array of LiveRanges that were successfully preserved from
+ // the region preservations
+ var regionPreservations = (results.regionPreservations =
+ results.regionPreservations || []);
+
var lastTgtMatch = null;
visitNodes(srcParent, null, null, function(src) {
@@ -47,6 +55,7 @@ Spark._patch = function(tgtParent, srcParent, tgtBefore, tgtAfter, preservations
// (including references to enclosing ranges).
LiveRange.transplantRange(
pres.fromStart, pres.fromEnd, pres.newRange);
+ regionPreservations.push(pres.newRange);
}
} else if (pres.type === 'node') {
if (patcher.match(tgt, src, copyFunc)) {
@@ -71,6 +80,7 @@ Spark._patch = function(tgtParent, srcParent, tgtBefore, tgtAfter, preservations
patcher.finish();
+ return results;
};
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index 72b186f114..e38b608f73 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -20,6 +20,8 @@
// XXX should functions with an htmlFunc use try/finally inside?
+// XXX do render callbacks "bubble up" to enclosing landmarks?
+
(function() {
Spark = {};
@@ -298,7 +300,8 @@ var scheduleOnscreenSetup = function (frag, landmarkRanges) {
// XXX should bubble up and notify parent landmarks too? for all
// the same reasons we need to do it for node preservation?
_.each(landmarkRanges, function (landmarkRange) {
- landmarkRange.renderCallback.call(landmarkRange.landmark);
+ if (! landmarkRange.isPreservedConstant)
+ landmarkRange.renderCallback.call(landmarkRange.landmark);
});
// This code can run several times on the same nodes (if the
@@ -488,6 +491,8 @@ Spark.renderToRange = function (range, htmlFunc) {
tempRange.destroy();
+ var results = {};
+
// patch (using preservations)
range.operate(function (start, end) {
// XXX this will destroy all liveranges, including ones
@@ -495,7 +500,16 @@ Spark.renderToRange = function (range, htmlFunc) {
// to preserve untouched
Spark.finalize(start, end);
Spark._patch(start.parentNode, frag, start.previousSibling,
- end.nextSibling, preservations);
+ end.nextSibling, preservations, results);
+ });
+
+ _.each(results.regionPreservations, function (landmarkRange) {
+ // Rely on the fact that computePreservations only emits
+ // region preservations whose ranges are landmarks.
+ // This flag means that landmarkRange is a new constant landmark
+ // range that matched an old one *and* was DOM-preservable by
+ // the patcher.
+ landmarkRange.isPreservedConstant = true;
});
};
@@ -697,7 +711,7 @@ Spark.isolate = function (htmlFunc) {
return; // killed by finalize. range has already been destroyed.
ctx = new Meteor.deps.Context;
- var frag = Spark.renderToRange(range, function () {
+ Spark.renderToRange(range, function () {
return ctx.run(htmlFunc);
});
ctx.on_invalidate(refresh);
@@ -726,7 +740,7 @@ var atFlushTime = function (f) {
atFlushContext = new Meteor.deps.Context;
atFlushContext.on_invalidate(function () {
var f;
- while (f = atFlushQueue.shift()) {
+ while ((f = atFlushQueue.shift())) {
// Since atFlushContext is truthy, if f() calls atFlushTime
// reentrantly, it's guaranteed to append to atFlushQueue and
// not contruct a new atFlushContext.
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index 2cf5af7079..6491bb436b 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -1620,8 +1620,7 @@ Tinytest.add("spark - landmark constant", function(test) {
test.equal(states.length, 1);
R.set(1);
Meteor.flush();
- test.equal(states.length, 2);
- test.isTrue(states[0] === states[1]);
+ test.equal(states.length, 1); // no render callback on constant
var nodes2 = _.toArray(div.node().childNodes);
test.equal(nodes2.length, 3);
test.isTrue(nodes[0] === nodes2[0]);
@@ -1642,6 +1641,8 @@ Tinytest.add("spark - landmark constant", function(test) {
var hasSpan = true;
var isConstant = true;
+ var crd = null; // [createCount, renderCount, destroyCount]
+
R = ReactiveVar('foo');
div = OnscreenDiv(Meteor.render(function() {
R.get(); // create unconditional dependency
@@ -1650,7 +1651,17 @@ Tinytest.add("spark - landmark constant", function(test) {
Spark.labelBranch(
brnch, function () {
return Spark.createLandmark(
- { constant: isConstant },
+ {
+ constant: isConstant,
+ create: function () {
+ this.crd = [0,0,0];
+ if (! crd)
+ crd = this.crd; // capture first landmark's crd
+ this.crd[0]++;
+ },
+ render: function () { this.crd[1]++; },
+ destroy: function () { this.crd[2]++; }
+ },
function() { return hasSpan ?
'stuff' : 'blah'; });}) +
(nodeAfter ? R.get() : '');
@@ -1667,12 +1678,16 @@ Tinytest.add("spark - landmark constant", function(test) {
R.set('bar');
Meteor.flush();
- // only absence of branch should cause the constant
+ // only non-matching landmark should cause the constant
// chunk to be re-rendered
test.equal(div.text(),
(nodeBefore ? 'bar' : '')+
(matchLandmark ? 'stuff' : 'blah')+
(nodeAfter ? 'bar' : ''));
+ // in non-matching case, first landmark is destroyed.
+ // otherwise, it is kept (and not re-rendered because
+ // it is constant)
+ test.equal(crd, matchLandmark ? [1,1,0] : [1,1,1]);
R.set('baz');
Meteor.flush();
@@ -1717,6 +1732,46 @@ Tinytest.add("spark - landmark constant", function(test) {
});
});
+ // test that constant landmark gets render callback if it
+ // wasn't preserved.
+
+ var renderCount;
+
+ renderCount = 0;
+ R = ReactiveVar('div');
+ div = OnscreenDiv(Meteor.render(function () {
+ return '<' + R.get() + '>' + Spark.createLandmark(
+ {constant: true, render: function () { renderCount++; },
+ destroy: function () {
+ console.log(3, rangeToHtml(this._range.findParent()));
+ }},
+ function () {
+ if (R.get() === 'div class="hamburger"')
+ console.log(2);
+ return "hi";
+ }) +
+ '' + R.get().split(' ')[0] + '>';
+ }));
+ Meteor.flush();
+ test.equal(renderCount, 1);
+
+ R.set('div class="hamburger"');
+ console.log(1);
+ Meteor.flush();
+ console.log(4);
+ // constant patched around, not re-rendered!
+ test.equal(renderCount, 1);
+
+ R.set('span class="hamburger"');
+ Meteor.flush();
+ // can't patch parent to a different tag
+ test.equal(renderCount, 2);
+
+ R.set('span');
+ Meteor.flush();
+ // can patch here, renderCount stays the same
+ test.equal(renderCount, 2);
+
});
From 212b4b940ed0be3e84ba4eb8259bc6913375e0a6 Mon Sep 17 00:00:00 2001
From: Geoff Schmidt
Date: Wed, 8 Aug 2012 15:02:44 -0700
Subject: [PATCH 119/212] finish test for find/findAll on landmarks
---
packages/spark/spark_tests.js | 33 ++++++++++++++++++++++++++-------
1 file changed, 26 insertions(+), 7 deletions(-)
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index 6491bb436b..8fe9c3eb5a 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -3191,17 +3191,36 @@ Tinytest.add("spark - find/findAll on landmark", function (test) {
}));
var ids = function (nodes) {
- if (nodes instanceof Array)
- nodes = [nodes];
+ if (!(nodes instanceof Array))
+ nodes = nodes ? [nodes] : [];
return _.pluck(nodes, 'id').join('');
};
- test.equal(ids(l1.find('.kitten')), '');
- test.equal(ids(l2.find('.kitten')), '');
+ var check = function (all) {
+ var f = all ? 'findAll' : 'find';
-// test.equal(ids(l1.find('.a')), '3');
-// test.equal(ids(l2.find('.a')), '');
- // XXX work in progress
+ test.equal(ids(l1[f]('.kitten')), '');
+ test.equal(ids(l2[f]('.kitten')), '');
+
+ test.equal(ids(l1[f]('.a')), '3');
+ test.equal(ids(l2[f]('.a')), '');
+
+ test.equal(ids(l1[f]('.b')), all ? '46' : '4');
+ test.equal(ids(l2[f]('.b')), all ? '46' : '4');
+
+ test.equal(ids(l1[f]('.c')), '');
+ test.equal(ids(l2[f]('.c')), '');
+
+ test.equal(ids(l1[f]('.a .b')), all ? '46' : '4');
+ test.equal(ids(l2[f]('.a .b')), '');
+ };
+
+ check(false);
+ check(true);
+ R.set(2);
+ Meteor.flush();
+ check(false);
+ check(true);
});
From 7411ae058f05aef2bd020bcc85e9e3bb580bd846 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Wed, 8 Aug 2012 15:19:50 -0700
Subject: [PATCH 120/212] remove leftover console.logs
---
packages/spark/spark_tests.js | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index 8fe9c3eb5a..a99a73fd69 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -1741,13 +1741,8 @@ Tinytest.add("spark - landmark constant", function(test) {
R = ReactiveVar('div');
div = OnscreenDiv(Meteor.render(function () {
return '<' + R.get() + '>' + Spark.createLandmark(
- {constant: true, render: function () { renderCount++; },
- destroy: function () {
- console.log(3, rangeToHtml(this._range.findParent()));
- }},
+ {constant: true, render: function () { renderCount++; }},
function () {
- if (R.get() === 'div class="hamburger"')
- console.log(2);
return "hi";
}) +
'' + R.get().split(' ')[0] + '>';
@@ -1756,9 +1751,7 @@ Tinytest.add("spark - landmark constant", function(test) {
test.equal(renderCount, 1);
R.set('div class="hamburger"');
- console.log(1);
Meteor.flush();
- console.log(4);
// constant patched around, not re-rendered!
test.equal(renderCount, 1);
From 7ba39d96cb52ffa4d11cad7a662aaa6d2adbf918 Mon Sep 17 00:00:00 2001
From: David Greenspan
Date: Wed, 8 Aug 2012 16:08:12 -0700
Subject: [PATCH 121/212] destroy unmaterialized landmarks
move materialize() into Renderer and have it include clean-up.
creating a duplicate landmark in the same branch is considered an
immediate error, without waiting to see if the annotation is
materialized.
---
packages/spark/spark.js | 204 +++++++++++++++++++---------------
packages/spark/spark_tests.js | 53 +++++++++
2 files changed, 167 insertions(+), 90 deletions(-)
diff --git a/packages/spark/spark.js b/packages/spark/spark.js
index e38b608f73..2269aa4a69 100644
--- a/packages/spark/spark.js
+++ b/packages/spark/spark.js
@@ -1,5 +1,3 @@
-// XXX rename liverange methods to camelCase?
-
// XXX adjust Spark API so that the modules (eg, list, events) could
// have been written by third parties on top of the public API?
@@ -120,10 +118,17 @@ _.extend(Spark._Renderer.prototype, {
// `what` can be a function that takes a LiveRange, or just a set of
// attributes to add to the liverange. tag and what are optional.
// if no tag is passed, no liverange will be created.
- annotate: function (html, type, what) {
- var id = type + ":" + this.createId();
+ annotate: function (html, type, what, unusedFunc) {
+ var id = (type || '') + ":" + this.createId();
this.annotations[id] = function (start, end) {
+ if (! start) {
+ // materialize called us with no args because this annotation
+ // wasn't used
+ unusedFunc && unusedFunc();
+ return;
+ }
if (! type)
+ // no type given; don't generate a LiveRange
return;
var range = makeRange(type, start, end);
if (what instanceof Function)
@@ -157,9 +162,106 @@ _.extend(Spark._Renderer.prototype, {
return top;
}
};
+ },
+
+ // Turn the `html` string into a fragment, applying the annotations
+ // from 'renderer' in the process.
+ materialize: function (htmlFunc) {
+ var self = this;
+
+ var html = Spark._currentRenderer.withValue(self, htmlFunc);
+ html = self.annotate(html); // wrap with an anonymous annotation
+
+ var fragById = {};
+ var replaceInclusions = function (container) {
+ var n = container.firstChild;
+ while (n) {
+ var next = n.nextSibling;
+ if (n.nodeType === 8) { // COMMENT
+ var frag = fragById[n.nodeValue];
+ if (frag === false) {
+ // id already used!
+ throw new Error("Spark HTML fragments may only be used once. " +
+ "Second use in " +
+ DomUtils.fragmentToHtml(container));
+ } else if (frag) {
+ fragById[n.nodeValue] = false; // mark as used
+ DomUtils.wrapFragmentForContainer(frag, n.parentNode);
+ n.parentNode.replaceChild(frag, n);
+ }
+ } else if (n.nodeType === 1) { // ELEMENT
+ replaceInclusions(n);
+ }
+ n = next;
+ }
+ };
+
+ var bufferStack = [[]];
+ var idStack = [];
+ var ret;
+
+ var regex = /<(\/?)\$([^<>]+)>|<|[^<]+/g;
+ regex.lastIndex = 0;
+ var parts;
+ while ((parts = regex.exec(html))) {
+ var isOpen = ! parts[1];
+ var id = parts[2];
+ var annotationFunc = self.annotations[id];
+ if (annotationFunc === false) {
+ throw new Error("Spark HTML fragments may be used only once. " +
+ "Second use of: " +
+ DomUtils.fragmentToHtml(fragById[id]));
+ } else if (! annotationFunc) {
+ bufferStack[bufferStack.length - 1].push(parts[0]);
+ } else if (isOpen) {
+ idStack.push(id);
+ bufferStack.push([]);
+ } else {
+ var idOnStack = idStack.pop();
+ if (idOnStack !== id)
+ throw new Error("Range mismatch: " + idOnStack + " / " + id);
+ var frag = DomUtils.htmlToFragment(bufferStack.pop().join(''));
+ replaceInclusions(frag);
+ // empty frag becomes HTML comment so we have start/end
+ // nodes to pass to the annotation function
+ if (! frag.firstChild)
+ frag.appendChild(document.createComment("empty"));
+ annotationFunc(frag.firstChild, frag.lastChild);
+ self.annotations[id] = false; // mark as used
+ if (! idStack.length) {
+ // we're done; we just rendered the contents of the top-level
+ // annotation that we wrapped around htmlFunc ourselves.
+ // there may be unused fragments in fragById that include
+ // LiveRanges, but only if the user broke the rules by including
+ // an annotation somewhere besides element level, like inside
+ // an attribute (which is not allowed).
+ ret = frag;
+ break;
+ }
+ fragById[id] = frag;
+ bufferStack[bufferStack.length - 1].push('');
+ }
+ }
+
+ scheduleOnscreenSetup(ret, self.landmarkRanges);
+ self.landmarkRanges = [];
+
+ _.each(self.annotations, function(annotationFunc) {
+ if (annotationFunc)
+ // call annotation func with no arguments to mean "you weren't used"
+ annotationFunc();
+ });
+ self.annotations = {};
+
+ return ret;
}
+
});
+// Decorator for Spark annotations that take `html` and are
+// pass-through without a renderer. With this decorator,
+// the annotation routine gets the current renderer, and
+// if there isn't one returns `html` (the last argument).
var withRenderer = function (f) {
return function (/* arguments */) {
var renderer = Spark._currentRenderer.get();
@@ -175,80 +277,6 @@ var withRenderer = function (f) {
/* Render and finalize */
/******************************************************************************/
-// Turn the `html` string into a fragment, applying the annotations
-// from 'renderer' in the process.
-var materialize = function (html, renderer) {
- var fragById = {};
-
- // XXX refactor the parsing loop so we don't have to do this, and so
- // we can just take 'annotations' instead of the whole renderer
- // object
- html = renderer.annotate(html);
-
- var replaceInclusions = function (container) {
- var n = container.firstChild;
- while (n) {
- var next = n.nextSibling;
- if (n.nodeType === 8) { // COMMENT
- var frag = fragById[n.nodeValue];
- if (frag === false) {
- // id already used!
- throw new Error("Spark HTML fragments may only be used once. " +
- "Second use in " +
- DomUtils.fragmentToHtml(container));
- } else if (frag) {
- fragById[n.nodeValue] = false; // mark as used
- DomUtils.wrapFragmentForContainer(frag, n.parentNode);
- n.parentNode.replaceChild(frag, n);
- }
- } else if (n.nodeType === 1) { // ELEMENT
- replaceInclusions(n);
- }
- n = next;
- }
- };
-
- var bufferStack = [[]];
- var idStack = [];
- var ret;
-
- var regex = /<(\/?)\$([^<>]+)>|<|[^<]+/g;
- regex.lastIndex = 0;
- var parts;
- while ((parts = regex.exec(html))) {
- var isOpen = ! parts[1];
- var id = parts[2];
- var annotationFunc = renderer.annotations[id];
- if (! annotationFunc) {
- bufferStack[bufferStack.length - 1].push(parts[0]);
- } else if (isOpen) {
- idStack.push(id);
- bufferStack.push([]);
- } else {
- var idOnStack = idStack.pop();
- if (idOnStack !== id)
- throw new Error("Range mismatch: " + idOnStack + " / " + id);
- var frag = DomUtils.htmlToFragment(bufferStack.pop().join(''));
- replaceInclusions(frag);
- // empty frag becomes HTML comment so we have start/end
- // nodes to pass to the annotation function
- if (! frag.firstChild)
- frag.appendChild(document.createComment("empty"));
- annotationFunc(frag.firstChild, frag.lastChild);
- if (! idStack.length)
- // we're done; we just rendered the contents of the top-level
- // annotation that we wrapped around htmlFunc ourselves.
- // there may be unused fragments in fragById that include
- // LiveRanges, but only if the user broke the rules by including
- // an annotation somewhere besides element level, like inside
- // an attribute (which is not allowed).
- return frag;
- fragById[id] = frag;
- bufferStack[bufferStack.length - 1].push('');
- }
- }
-};
-
// Schedule setup tasks to run at the next flush, which is when the
// newly rendered fragment must be on the screen (if it doesn't want
// to get garbage-collected.)
@@ -317,11 +345,7 @@ var scheduleOnscreenSetup = function (frag, landmarkRanges) {
Spark.render = function (htmlFunc) {
var renderer = new Spark._Renderer;
- var html = Spark._currentRenderer.withValue(renderer, htmlFunc);
- var frag = materialize(html, renderer);
-
- scheduleOnscreenSetup(frag, renderer.landmarkRanges);
-
+ var frag = renderer.materialize(htmlFunc);
return frag;
};
@@ -456,9 +480,7 @@ Spark.renderToRange = function (range, htmlFunc) {
notes.originalRange = landmarkRange;
});
- var html = Spark._currentRenderer.withValue(renderer, htmlFunc);
- var frag = materialize(html, renderer);
- scheduleOnscreenSetup(frag, renderer.landmarkRanges);
+ var frag = renderer.materialize(htmlFunc);
DomUtils.wrapFragmentForContainer(frag, range.containerNode());
@@ -929,7 +951,9 @@ Spark.createLandmark = function (options, htmlFunc) {
var notes = renderer.currentBranch.getNotes();
var landmark;
- if (notes.originalRange && ! notes.originalRange.superceded) {
+ if (notes.originalRange) {
+ if (notes.originalRange.superceded)
+ throw new Error("Can't create second landmark in same branch");
notes.originalRange.superceded = true; // prevent destroy(), second match
landmark = notes.originalRange.landmark; // the old Landmark
} else {
@@ -955,10 +979,10 @@ Spark.createLandmark = function (options, htmlFunc) {
landmark._range = range;
renderer.landmarkRanges.push(range);
+ }, function () {
+ // "annotation not used" callback
+ options.destroy && options.destroy.call(landmark);
});
-
- // XXX need to arrange for destroyCallback to be called if the
- // returned html is never materialized..
};
// used by unit tests
diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js
index a99a73fd69..787ef06f64 100644
--- a/packages/spark/spark_tests.js
+++ b/packages/spark/spark_tests.js
@@ -3216,7 +3216,60 @@ Tinytest.add("spark - find/findAll on landmark", function (test) {
check(true);
});
+Tinytest.add("spark - landmark clean-up", function (test) {
+ var crd;
+ var makeCrd = function () {
+ var crd = [0,0,0];
+ crd.callbacks = {
+ create: function () { crd[0]++; },
+ render: function () { crd[1]++; },
+ destroy: function () { crd[2]++; }
+ };
+ return crd;
+ };
+
+ // not inside render
+ crd = makeCrd();
+ Spark.createLandmark(crd.callbacks, function () { return 'hi'; });
+ test.equal(crd, [1,0,1]);
+
+ // landmark never materialized
+ crd = makeCrd();
+ Spark.render(function() {
+ var html =
+ Spark.createLandmark(crd.callbacks, function () { return 'hi'; });
+ return '';
+ });
+ test.equal(crd, [1,0,1]);
+ Meteor.flush();
+ test.equal(crd, [1,0,1]);
+
+ // two landmarks, only one materialized at a time.
+ // one replaces the other
+ var crd1 = makeCrd();
+ var crd2 = makeCrd();
+ var R = ReactiveVar(1);
+ var div = OnscreenDiv(Meteor.render(function() {
+ return (R.get() === 1 ?
+ Spark.createLandmark(crd1.callbacks, function() { return 'hi'; }) :
+ Spark.createLandmark(crd2.callbacks, function() { return 'hi'; }));
+ }));
+ test.equal(crd1, [1,0,0]); // created
+ test.equal(crd2, [0,0,0]);
+ Meteor.flush();
+ test.equal(crd1, [1,1,0]); // rendered
+ test.equal(crd2, [0,0,0]);
+ R.set(2);
+ Meteor.flush();
+ test.equal(crd1, [1,1,0]); // not destroyed (callback replaced)
+ test.equal(crd2, [0,1,0]); // matched
+
+ div.kill();
+ Meteor.flush();
+ test.equal(crd1, [1,1,0]);
+ test.equal(crd2, [0,1,1]); // destroyed
+});
From f766cf29fc140ffaeff735f1e155ff31d13c7ae3 Mon Sep 17 00:00:00 2001
From: Geoff Schmidt
Date: Wed, 8 Aug 2012 17:05:36 -0700
Subject: [PATCH 122/212] First pass of template-level API extensions Untested,
but a demo is in the works
---
examples/landmark-demo/.meteor/.gitignore | 1 +
examples/landmark-demo/.meteor/packages | 6 ++
.../landmark-demo/client/landmark-demo.js | 53 ++++++++++++++
examples/landmark-demo/landmark-demo.css | 1 +
examples/landmark-demo/landmark-demo.html | 60 ++++++++++++++++
packages/handlebars/evaluate.js | 9 +--
packages/templating/deftemplate.js | 69 ++++++++++++++-----
7 files changed, 176 insertions(+), 23 deletions(-)
create mode 100644 examples/landmark-demo/.meteor/.gitignore
create mode 100644 examples/landmark-demo/.meteor/packages
create mode 100644 examples/landmark-demo/client/landmark-demo.js
create mode 100644 examples/landmark-demo/landmark-demo.css
create mode 100644 examples/landmark-demo/landmark-demo.html
diff --git a/examples/landmark-demo/.meteor/.gitignore b/examples/landmark-demo/.meteor/.gitignore
new file mode 100644
index 0000000000..4083037423
--- /dev/null
+++ b/examples/landmark-demo/.meteor/.gitignore
@@ -0,0 +1 @@
+local
diff --git a/examples/landmark-demo/.meteor/packages b/examples/landmark-demo/.meteor/packages
new file mode 100644
index 0000000000..12c5f051c0
--- /dev/null
+++ b/examples/landmark-demo/.meteor/packages
@@ -0,0 +1,6 @@
+# Meteor packages used by this project, one per line.
+#
+# 'meteor add' and 'meteor remove' will edit this file for you,
+# but you can also edit it by hand.
+
+autopublish
diff --git a/examples/landmark-demo/client/landmark-demo.js b/examples/landmark-demo/client/landmark-demo.js
new file mode 100644
index 0000000000..8af45035c5
--- /dev/null
+++ b/examples/landmark-demo/client/landmark-demo.js
@@ -0,0 +1,53 @@
+Timers = new Meteor.Collection(null);
+
+if (! Session.get("x")) {
+ Session.set("x", 1);
+}
+
+if (! Session.get("y")) {
+ Session.set("y", 1);
+}
+
+if (! Session.get("z")) {
+ Session.set("z", 1);
+}
+
+Template.redrawButtons.events = {
+ 'click input.x': function () {
+ Session.set("x", Session.get("x") + 1);
+ },
+
+ 'click input.y': function () {
+ Session.set("y", Session.get("y") + 1);
+ },
+
+ 'click input.z': function () {
+ Session.set("z", Session.get("z") + 1);
+ }
+};
+
+Template.preserveDemo.preserve = [ '.input' ];
+
+Template.preserveDemo.x =
+Template.constantDemo.x =
+Template.stateDemo.x =
+function () {
+ return Session.get("x");
+};
+
+
+Template.stateDemo.events = {
+ 'click .create': function () {
+ Timers.insert({});
+ }
+};
+
+Template.stateDemo.timers = function () {
+ return Timers.find();
+};
+
+Template.timer.events = {
+ 'click .delete': function () {
+ Timers.remove(this._id);
+ }
+};
diff --git a/examples/landmark-demo/landmark-demo.css b/examples/landmark-demo/landmark-demo.css
new file mode 100644
index 0000000000..b6b4052b43
--- /dev/null
+++ b/examples/landmark-demo/landmark-demo.css
@@ -0,0 +1 @@
+/* CSS declarations go here */
diff --git a/examples/landmark-demo/landmark-demo.html b/examples/landmark-demo/landmark-demo.html
new file mode 100644
index 0000000000..2818193c41
--- /dev/null
+++ b/examples/landmark-demo/landmark-demo.html
@@ -0,0 +1,60 @@
+
+ landmark-demo
+
+
+
+