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()+'

'); } return result.join(''); - })).hold(); + }, { preserve: legacyLabels })).hold(); test.equal(frag.html(), '

1

'); R.set(3); @@ -490,7 +490,7 @@ Tinytest.add("liveui - copied attributes", function(test) { R = ReactiveVar(false); frag = WrappedFrag(Meteor.ui.render(function() { return ''; - })).hold(); + }, { preserve: legacyLabels })).hold(); var get_checked = function() { return !! frag.node().firstChild.checked; }; test.equal(get_checked(), false); Meteor.flush(); @@ -510,7 +510,7 @@ Tinytest.add("liveui - copied attributes", function(test) { R = ReactiveVar(true); frag = WrappedFrag(Meteor.ui.render(function() { return ''; - })).hold(); + }, { preserve: legacyLabels })).hold(); test.equal(get_checked(), true); Meteor.flush(); test.equal(get_checked(), true); @@ -525,7 +525,7 @@ Tinytest.add("liveui - copied attributes", function(test) { R = ReactiveVar("apple"); var div = OnscreenDiv(Meteor.ui.render(function() { return ''; - })); + }, { preserve: legacyLabels })); var maybe_focus = function(div) { if (with_focus) { div.show(); @@ -557,7 +557,7 @@ Tinytest.add("liveui - copied attributes", function(test) { R = ReactiveVar(""); div = OnscreenDiv(Meteor.ui.render(function() { return ''; - })); + }, { preserve: legacyLabels })); maybe_focus(div); test.equal(get_value(), ""); Meteor.flush(); @@ -580,7 +580,7 @@ Tinytest.add("liveui - bad labels", function(test) { var R = ReactiveVar(true); var frag = WrappedFrag(Meteor.ui.render(function() { return R.get() ? html1 : html2; - })).hold(); + }, { preserve: legacyLabels })).hold(); R.set(false); Meteor.flush(); @@ -853,7 +853,8 @@ Tinytest.add("liveui - leaderboard", function(test) { "click": function () { selected_player.set(this._id); } - } + }, + preserve: legacyLabels }); })); @@ -987,7 +988,8 @@ Tinytest.add("liveui - listChunk table", function(test) { }, function() { return "(nothing)"; - })); + }, + { preserve: legacyLabels })); buf.push(''); return buf.join(''); })).hold(); @@ -1720,7 +1722,7 @@ var make_input_tester = function(render_func, events) { Meteor.ui.render(function() { R.get(); // create dependency return render_func(); - }, { events: events, event_data: buf })); + }, { events: events, event_data: buf, preserve: legacyLabels })); div.show(true); var getbuf = function() { @@ -2002,22 +2004,24 @@ Tinytest.add("liveui - controls", function(test) { }); buf.push(R.get()); return buf.join(''); - }, {events: { - 'change input': function(event) { - // IE 7 is known to fire change events on all - // the radio buttons with checked=false, as if - // each button were deselected before selecting - // the new one. - // However, browsers are consistent if we are - // getting a checked=true notification. - var btn = event.target; - if (btn.checked) { - var band = btn.value; - change_buf.push(band); - R.set(band); + }, { + events: { + 'change input': function(event) { + // IE 7 is known to fire change events on all + // the radio buttons with checked=false, as if + // each button were deselected before selecting + // the new one. + // However, browsers are consistent if we are + // getting a checked=true notification. + var btn = event.target; + if (btn.checked) { + var band = btn.value; + change_buf.push(band); + R.set(band); + } } - } - }})); + }, + preserve: legacyLabels })); Meteor.flush(); @@ -2064,7 +2068,7 @@ Tinytest.add("liveui - controls", function(test) { div = OnscreenDiv(Meteor.ui.render(function() { return ''; - })); + }, { preserve: legacyLabels })); div.show(true); var textarea = div.node().firstChild; From bd97b5e739ad86cb2d04b052572a6ca2c7acf870 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Mon, 9 Jul 2012 16:40:41 -0700 Subject: [PATCH 019/212] options.constant (maybe) --- packages/liveui/liveui.js | 64 ++++++++++++++++++++++---------------- packages/liveui/patcher.js | 47 ++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 36 deletions(-) diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js index e342f3c62b..d111debdbd 100644 --- a/packages/liveui/liveui.js +++ b/packages/liveui/liveui.js @@ -1,9 +1,5 @@ Meteor.ui = Meteor.ui || {}; -// TODO: -// -// - {constant:true} chunk options - (function() { //////////////////// PUBLIC API @@ -275,6 +271,7 @@ Meteor.ui = Meteor.ui || {}; } range.preserve = normalizePreserveOption(options.preserve); + range.constant = options.constant; if (mode === "patch") { // Rendering top level of the current update, with patching @@ -735,27 +732,36 @@ Meteor.ui = Meteor.ui || {}; eachKeyedChunk(tempRange, function(r, path) { var oldRange = oldChunks[path]; if (oldRange) { - var preserveMap; + // matched chunk + var rangeForOpts = r; if (r === tempRange) { // top level; don't copy chunkState to tempRange! - // use oldRange.preserve for preservation - preserveMap = oldRange.preserve; + // use oldRange for `preserve` and other options + rangeForOpts = oldRange; } 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, preserveMap); - var newLabeledNodes = collectLabeledNodes(r, preserveMap); - _.each(newLabeledNodes, function(newNode, label) { - var oldNode = oldLabeledNodes[label]; - if (oldNode) - nodeMatches.push([oldNode, newNode]); - }); + var preserveMap = rangeForOpts.preserve; + var isConstant = rangeForOpts.constant; + + if (isConstant) { + // add a quadruple + nodeMatches.push([oldRange.firstNode(), r.firstNode(), + oldRange.lastNode(), r.lastNode()]); + } else { + var oldLabeledNodes = collectLabeledNodes(oldRange, preserveMap); + var newLabeledNodes = collectLabeledNodes(r, preserveMap); + _.each(newLabeledNodes, function(newNode, label) { + var oldNode = oldLabeledNodes[label]; + if (oldNode) + nodeMatches.push([oldNode, newNode]); + }); + } } }); tempRange.destroy(); @@ -793,19 +799,25 @@ Meteor.ui = Meteor.ui || {}; 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! - patch(tgt, src, null, null, nodeMatches); + if (pair.length === 4) { + // range match! for constant chunk + if (patcher.match(tgt, src, null, true)) + patcher.skipToSiblings(pair[2], pair[3]); + } else { + 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! + patch(tgt, src, null, null, nodeMatches); + } } + return false; // tell visitNodes not to recurse } - return false; // tell visitNodes not to recurse } } } diff --git a/packages/liveui/patcher.js b/packages/liveui/patcher.js index dff3b4f95b..5ef3bcc534 100644 --- a/packages/liveui/patcher.js +++ b/packages/liveui/patcher.js @@ -77,7 +77,8 @@ Meteor.ui._Patcher = function(tgtParent, srcParent, tgtBefore, tgtAfter) { // copyCallback is called on every new matched (tgt, src) pair // right after copying attributes. It's a good time to transplant // liveranges and patch children. -Meteor.ui._Patcher.prototype.match = function(tgtNode, srcNode, copyCallback) { +Meteor.ui._Patcher.prototype.match = function( + tgtNode, srcNode, copyCallback, onlyAdvance) { // last nodes "kept" (matched/identified with each other) var lastKeptTgt = this.lastKeptTgtNode; @@ -137,28 +138,36 @@ Meteor.ui._Patcher.prototype.match = function(tgtNode, srcNode, copyCallback) { this._replaceNodes(lastKeptTgt, null, lastKeptSrc, null, this.tgtParent, this.srcParent); } else { - // Compare tag names and depths to make sure we can match nodes. + // Compare tag names and depths to make sure we can match nodes... + if (! onlyAdvance) { + if (tgt.nodeName !== src.nodeName) + return false; + } + // Look at tags of parents until we hit parent of last-kept, // which we know is ok. - for(var a=tgt, b=src; + for(var a=tgt.parentNode, b=src.parentNode; a !== (starting ? this.tgtParent : lastKeptTgt.parentNode); a = a.parentNode, b = b.parentNode) { - if (b === (starting ? this.srcParent : lastKeptSrc.parentNode)) { + if (b === (starting ? this.srcParent : lastKeptSrc.parentNode)) return false; // src is shallower, b hit top first - } - if (a.nodeName !== b.nodeName) { + if (a.nodeName !== b.nodeName) return false; // tag names don't match - } } if (b !== (starting ? this.srcParent : lastKeptSrc.parentNode)) { return false; // src is deeper, b didn't hit top when a did } + var firstIter = true; // move tgt and src backwards and out, replacing as we go while (true) { - Meteor.ui._Patcher._copyAttributes(tgt, src); - if (copyCallback) - copyCallback(tgt, src); + if (! (firstIter && onlyAdvance)) { + Meteor.ui._Patcher._copyAttributes(tgt, src); + if (copyCallback) + copyCallback(tgt, src); + } + + firstIter = false; if ((starting ? this.tgtParent : lastKeptTgt.parentNode) === tgt.parentNode) { @@ -180,6 +189,24 @@ Meteor.ui._Patcher.prototype.match = function(tgtNode, srcNode, copyCallback) { return true; }; +// After a match, skip ahead to later siblings of the last kept nodes, +// without performing any replacements. +Meteor.ui._Patcher.prototype.skipToSiblings = function(tgt, src) { + var lastTgt = this.lastKeptTgtNode; + var lastSrc = this.lastKeptSrcNode; + + if (! (lastTgt && lastTgt.parentNode === tgt.parentNode)) + return false; + + if (! (lastSrc && lastSrc.parentNode === src.parentNode)) + return false; + + this.lastKeptTgtNode = tgt; + this.lastKeptSrcNode = src; + + return true; +}; + // Completes patching assuming no more matches. // // Patchers are single-use, so no more methods can be called From 9d07be2a79724baf9f98ec7348ddb3c341d1e2a3 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Mon, 9 Jul 2012 17:36:05 -0700 Subject: [PATCH 020/212] preserve liveranges around embeds --- packages/liveui/liverange.js | 27 ++++++++++++++++++++++++++- packages/liveui/liveui.js | 25 ++++++++++++++++--------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/liveui/liverange.js b/packages/liveui/liverange.js index f326758a4e..c10c5c5737 100644 --- a/packages/liveui/liverange.js +++ b/packages/liveui/liverange.js @@ -474,12 +474,15 @@ Meteor.ui = Meteor.ui || {}; }; // Move all liverange data represented in the DOM from sourceNode to - // targetNode. + // targetNode. targetNode must be capable of receiving liverange tags + // (for example, a node that has been the first or last node of a liverange + // before; not a text node in IE). // // This is a low-level operation suitable for moving liveranges en masse // from one DOM tree to another, where transplant_tag is called on every // pair of nodes such that targetNode takes the place of sourceNode. Meteor.ui._LiveRange.transplant_tag = function(tag, targetNode, sourceNode) { + if (! sourceNode[tag]) return; @@ -497,6 +500,28 @@ Meteor.ui = Meteor.ui || {}; ends[i]._end = targetNode; }; + // Takes two sibling nodes tgtStart and tgtEnd with no LiveRange data on them + // and a LiveRange srcRange in a separate DOM tree. Transplants srcRange + // to span from tgtStart to tgtEnd, and also copies info about enclosing ranges + // starting on srcRange._start or ending on srcRange._end. tgtStart and tgtEnd + // must be capable of receiving liverange tags (for example, nodes that have + // held liverange data in the past; not text nodes in IE). + // + // This is a low-level operation suitable for moving liveranges en masse + // from one DOM tree to another. + Meteor.ui._LiveRange.transplant_range = function(tgtStart, tgtEnd, srcRange) { + srcRange._ensure_tag(tgtStart); + if (tgtEnd !== tgtStart) + srcRange._ensure_tag(tgtEnd); + + srcRange._insert_entries( + tgtStart, 0, 0, + srcRange._start[srcRange.tag][0].slice(0, srcRange._start_idx + 1)); + srcRange._insert_entries( + tgtEnd, 1, 0, + srcRange._end[srcRange.tag][1].slice(srcRange._end_idx)); + }; + // Inserts a DocumentFragment immediately before this range. // The new nodes are outside this range but inside all // enclosing ranges. diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js index d111debdbd..b5806d29aa 100644 --- a/packages/liveui/liveui.js +++ b/packages/liveui/liveui.js @@ -750,9 +750,11 @@ Meteor.ui = Meteor.ui || {}; var isConstant = rangeForOpts.constant; if (isConstant) { - // add a quadruple - nodeMatches.push([oldRange.firstNode(), r.firstNode(), - oldRange.lastNode(), r.lastNode()]); + // add a range match + var a = [oldRange.firstNode(), r.firstNode(), + oldRange.lastNode(), r.lastNode(), rangeForOpts]; + a.rangeMatch = true; + nodeMatches.push(a); } else { var oldLabeledNodes = collectLabeledNodes(oldRange, preserveMap); var newLabeledNodes = collectLabeledNodes(r, preserveMap); @@ -779,18 +781,20 @@ Meteor.ui = Meteor.ui || {}; tgtParent, srcParent, tgtBefore, tgtAfter); - var visitNodes = function(parent, before, after, func) { + var visitElements = 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); + if (n.nodeType === 1) { + if (func(n) !== false && n.firstChild) + visitElements(n, null, null, func); + } } }; var lastTgtMatch = null; - visitNodes(srcParent, null, null, function(src) { + visitElements(srcParent, null, null, function(src) { // XXX inefficient to scan for match for every node! var pair = _.find(nodeMatches, function(p) { return p[1] === src; @@ -799,10 +803,13 @@ Meteor.ui = Meteor.ui || {}; var tgt = pair[0]; if (! lastTgtMatch || Meteor.ui._elementOrder(lastTgtMatch, tgt) > 0) { - if (pair.length === 4) { + if (pair.rangeMatch) { // range match! for constant chunk - if (patcher.match(tgt, src, null, true)) + if (patcher.match(pair[0], pair[1], null, true)) { patcher.skipToSiblings(pair[2], pair[3]); + Meteor.ui._LiveRange.transplant_range( + pair[0], pair[2], pair[4]); + } } else { if (patcher.match(tgt, src, copyFunc)) { // match succeeded From 0471557c6701025a8ea7a5152d4eb28e52fd72fb Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Mon, 9 Jul 2012 17:48:41 -0700 Subject: [PATCH 021/212] notes --- packages/liveui/liveui_tests.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js index dfe158affc..3c7a212d1d 100644 --- a/packages/liveui/liveui_tests.js +++ b/packages/liveui/liveui_tests.js @@ -2185,5 +2185,32 @@ Tinytest.add("liveui - chunk matching", function(test) { test.equal(buf, "off0,off1".split(',')); }); +// TO TEST: +// - options.constant +// - as top-level +// - below top-level +// - when it differs between old/new +// - preservation of surrounding liveranges +// - chunk matching +// - Handlebars branch keys +// - top-level replacement always matches +// - options.branch +// - preserve nodes +// - API (one-match selectors, lambdas) +// - in lists +// - onscreen/offscreen/created callbacks +// - timing of calls +// - custom vs. original object +// - arguments to offscreen +// - when differ between old/new +// - on listChunk +// - old and new data + +// API Notes: +// - { constant: true } requires branch key; doesn't preserve liveranges +// - { preserve: ... } takes array of selectors or map of selector to value or lambda; +// also requires branch key +// - event this is calculated from currentTarget +// - options.data; deprecate event_data? })(); From 5370cb6100356405a680f5c9e1d9ce97382f883d Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Mon, 9 Jul 2012 19:22:28 -0700 Subject: [PATCH 022/212] constant=true (embeds) tested --- packages/liveui/liveui.js | 12 +-- packages/liveui/liveui_tests.js | 128 ++++++++++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 11 deletions(-) diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js index b5806d29aa..785c8c1d03 100644 --- a/packages/liveui/liveui.js +++ b/packages/liveui/liveui.js @@ -781,21 +781,21 @@ Meteor.ui = Meteor.ui || {}; tgtParent, srcParent, tgtBefore, tgtAfter); - var visitElements = function(parent, before, after, func) { + var visitNodes = function(parent, before, after, func) { for(var n = before ? before.nextSibling : parent.firstChild; n && n !== after; n = n.nextSibling) { - if (n.nodeType === 1) { - if (func(n) !== false && n.firstChild) - visitElements(n, null, null, func); - } + if (func(n) !== false && n.firstChild) + visitNodes(n, null, null, func); } }; var lastTgtMatch = null; - visitElements(srcParent, null, null, function(src) { + visitNodes(srcParent, null, null, function(src) { // XXX inefficient to scan for match for every node! + // We could skip non-element nodes, except for "range matches" + // used for constant chunks, which may begin on a non-element. var pair = _.find(nodeMatches, function(p) { return p[1] === src; }); diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js index 3c7a212d1d..72e4448cd1 100644 --- a/packages/liveui/liveui_tests.js +++ b/packages/liveui/liveui_tests.js @@ -2185,12 +2185,130 @@ Tinytest.add("liveui - chunk matching", function(test) { test.equal(buf, "off0,off1".split(',')); }); + +Tinytest.add("liveui - constant chunk", function(test) { + + var R, div; + + // top-level { constant: true } + + R = ReactiveVar(0); + var ranges = []; + div = OnscreenDiv(Meteor.ui.render(function() { + R.get(); // create dependency + return ''; + }, { + constant: true, + onscreen: function(start, end, range) { + ranges.push(range); + } + })); + + var nodes = _.toArray(div.node().childNodes); + test.equal(nodes.length, 3); + Meteor.flush(); + test.equal(ranges.length, 1); + R.set(1); + Meteor.flush(); + test.equal(ranges.length, 2); + test.isTrue(ranges[0] === ranges[1]); + var nodes2 = _.toArray(div.node().childNodes); + test.equal(nodes2.length, 3); + test.isTrue(nodes[0] === nodes2[0]); + test.isTrue(nodes[1] === nodes2[1]); + test.isTrue(nodes[2] === nodes2[2]); + div.kill(); + Meteor.flush(); + test.equal(R.numListeners(), 0); + + // non-top-level + + // run test with and without branch + _.each([null, 'foo'], function(brnch) { + // run test with node before or after, or neither or both + _.each([false, true], function(nodeBefore) { + _.each([false, true], function(nodeAfter) { + var hasSpan = true; + var isConstant = true; + + R = ReactiveVar('foo'); + div = OnscreenDiv(Meteor.ui.render(function() { + R.get(); // create unconditional dependency + return (nodeBefore ? R.get() : '') + + Meteor.ui.chunk(function() { + return hasSpan ? 'stuff' : + 'blah'; + }, { branch: brnch, constant: isConstant }) + + (nodeAfter ? R.get() : ''); + })); + + var span = div.node().getElementsByTagName('span')[0]; + hasSpan = false; + + test.equal(div.text(), + (nodeBefore ? 'foo' : '')+ + 'stuff'+ + (nodeAfter ? 'foo' : '')); + + R.set('bar'); + Meteor.flush(); + + // only absence of branch should cause the constant + // chunk to be re-rendered + test.equal(div.text(), + (nodeBefore ? 'bar' : '')+ + (brnch ? 'stuff' : 'blah')+ + (nodeAfter ? 'bar' : '')); + + R.set('baz'); + Meteor.flush(); + + // should be repeatable (liveranges not damaged) + test.equal(div.text(), + (nodeBefore ? 'baz' : '')+ + (brnch ? 'stuff' : 'blah')+ + (nodeAfter ? 'baz' : '')); + + isConstant = false; // no longer constant:true! + R.set('qux'); + Meteor.flush(); + test.equal(div.text(), + (nodeBefore ? 'qux' : '')+ + 'blah'+ + (nodeAfter ? 'qux' : '')); + + // turn constant back on + isConstant = true; + hasSpan = true; + R.set('popsicle'); + debugger; + Meteor.flush(); + // we don't get the span, instead old "blah" is preserved + test.equal(div.text(), + (nodeBefore ? 'popsicle' : '')+ + (brnch ? 'blah' : 'stuff')+ + (nodeAfter ? 'popsicle' : '')); + + isConstant = false; + R.set('hi'); + Meteor.flush(); + // now we get the span! + test.equal(div.text(), + (nodeBefore ? 'hi' : '')+ + 'stuff'+ + (nodeAfter ? 'hi' : '')); + + div.kill(); + Meteor.flush(); + }); + }); + }); + + +}); + + // TO TEST: -// - options.constant -// - as top-level -// - below top-level -// - when it differs between old/new -// - preservation of surrounding liveranges // - chunk matching // - Handlebars branch keys // - top-level replacement always matches From 0719a88b80d6649bfa2b099839fece535f28675a Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Thu, 12 Jul 2012 17:55:49 -0700 Subject: [PATCH 023/212] use new elementContains --- packages/liveui/liveevents_w3c.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/liveui/liveevents_w3c.js b/packages/liveui/liveevents_w3c.js index be0ac6d1e0..d251536bb6 100644 --- a/packages/liveui/liveevents_w3c.js +++ b/packages/liveui/liveevents_w3c.js @@ -128,9 +128,7 @@ Meteor.ui._event._loadW3CImpl = function() { // relatedTarget is present and a descendent). (! event.relatedTarget || (event.currentTarget !== event.relatedTarget && - // XXX change this to call domutils.js when - // davidchunks branch lands - ! Meteor.ui._Patcher._elementContains( + ! Meteor.ui._elementContains( event.currentTarget, event.relatedTarget)))) { if (event.type === 'mouseover'){ sendUIEvent('mouseenter', event.currentTarget, false); From 9ea0b8d95068f021fc6875c008f7837f687341f0 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Thu, 12 Jul 2012 17:57:22 -0700 Subject: [PATCH 024/212] comments --- packages/liveui/liveui_tests.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js index 72e4448cd1..e69fff4269 100644 --- a/packages/liveui/liveui_tests.js +++ b/packages/liveui/liveui_tests.js @@ -2322,13 +2322,12 @@ Tinytest.add("liveui - constant chunk", function(test) { // - arguments to offscreen // - when differ between old/new // - on listChunk -// - old and new data +// - different old and new data // API Notes: // - { constant: true } requires branch key; doesn't preserve liveranges // - { preserve: ... } takes array of selectors or map of selector to value or lambda; // also requires branch key -// - event this is calculated from currentTarget // - options.data; deprecate event_data? })(); From bc8a76c8f36f3fb994fa8417f33175f6e265b349 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 13 Jul 2012 14:56:41 -0700 Subject: [PATCH 025/212] remove "debugger" --- packages/liveui/liveui_tests.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js index e69fff4269..cb577a59b2 100644 --- a/packages/liveui/liveui_tests.js +++ b/packages/liveui/liveui_tests.js @@ -2105,7 +2105,7 @@ Tinytest.add("liveui - controls", function(test) { div.kill(); }); -Tinytest.add("liveui - chunk matching", function(test) { +Tinytest.add("liveui - basic chunk matching", function(test) { // basic created / onscreen / offscreen callback flow @@ -2281,7 +2281,6 @@ Tinytest.add("liveui - constant chunk", function(test) { isConstant = true; hasSpan = true; R.set('popsicle'); - debugger; Meteor.flush(); // we don't get the span, instead old "blah" is preserved test.equal(div.text(), From 5855c9fcf41b57b15af4e19cdf5c5bdca2f68242 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 13 Jul 2012 16:32:20 -0700 Subject: [PATCH 026/212] beginning of branch key tests --- packages/liveui/liveui_tests.js | 109 +++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js index cb577a59b2..817ea5cfd6 100644 --- a/packages/liveui/liveui_tests.js +++ b/packages/liveui/liveui_tests.js @@ -2307,10 +2307,115 @@ Tinytest.add("liveui - constant chunk", function(test) { }); +Tinytest.add("liveui - branch keys", function(test) { + + var R, div; + + // Re-rendered Meteor.ui.render keeps same chunkState + + var objs = []; + R = ReactiveVar("foo"); + div = OnscreenDiv(Meteor.ui.render(function() { + return R.get(); + }, { + onscreen: function() { + objs.push(this); + } + })); + + Meteor.flush(); + R.set("bar"); + Meteor.flush(); + R.set("baz"); + Meteor.flush(); + + test.equal(objs.length, 3); + test.isTrue(objs[0] === objs[1]); + test.isTrue(objs[1] === objs[2]); + + div.kill(); + Meteor.flush(); + + // track chunk matching / re-rendering in detail + + 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;iapple', 2, 'x'], + ['banana', 3, 'y'], + ['kiwi', 4, 'z'] + ], 1, 'fruit'); + })); + + Meteor.flush(); + buf.sort(); + test.equal(buf, ['c1', 'c2', 'c3', 'c4', 'on1', 'on2', 'on3', 'on4']); + buf.length = 0; + + R.set("bar"); + Meteor.flush(); + buf.sort(); + test.equal(buf, ['on1', 'on2', 'on3', 'on4']); + buf.length = 0; + + R.set("nothing"); + Meteor.flush(); + buf.sort(); + test.equal(buf, ['off1', 'off2', 'off3', 'off4']); + buf.length = 0; + + div.kill(); + Meteor.flush(); + + // XXX test some chunks not preserved; intermediate unkeyed chunks; + // duplicate branch keys; different order +}); + // TO TEST: // - chunk matching // - Handlebars branch keys -// - top-level replacement always matches // - options.branch // - preserve nodes // - API (one-match selectors, lambdas) @@ -2318,7 +2423,7 @@ Tinytest.add("liveui - constant chunk", function(test) { // - onscreen/offscreen/created callbacks // - timing of calls // - custom vs. original object -// - arguments to offscreen +// - arguments to onscreen // - when differ between old/new // - on listChunk // - different old and new data From 70ca9b98c8b2d3f391364762e074caa2da362a7d Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Mon, 16 Jul 2012 10:59:44 -0700 Subject: [PATCH 027/212] more tests --- packages/liveui/liveui_tests.js | 40 ++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js index 817ea5cfd6..c773020aee 100644 --- a/packages/liveui/liveui_tests.js +++ b/packages/liveui/liveui_tests.js @@ -2375,6 +2375,8 @@ Tinytest.add("liveui - branch keys", function(test) { }, testCallbacks(num, {branch: branch})); }; + ///// Chunk 1 contains 2,3,4, all should be matched + buf = []; counts = {}; @@ -2409,7 +2411,42 @@ Tinytest.add("liveui - branch keys", function(test) { div.kill(); Meteor.flush(); - // XXX test some chunks not preserved; intermediate unkeyed chunks; + ///// Chunk 3 has no branch key, should be recreated + + buf = []; + counts = {}; + + R = ReactiveVar("foo"); + div = OnscreenDiv(Meteor.ui.render(function() { + if (R.get() === 'nothing') + return "no chunk!"; + else + return chunk([['apple', 2, 'x'], + ['banana', 3, ''], + ['kiwi', 4, 'z'] + ], 1, 'fruit'); + })); + + Meteor.flush(); + buf.sort(); + test.equal(buf, ['c1', 'c2', 'c3', 'c4', 'on1', 'on2', 'on3', 'on4']); + buf.length = 0; + + R.set("bar"); + Meteor.flush(); + buf.sort(); + test.equal(buf, ['c3*', 'off3', 'on1', 'on2', 'on3*', 'on4']); + buf.length = 0; + + div.kill(); + Meteor.flush(); + buf.sort(); + // killing the div should have given us offscreen calls for 1,2,3*,4 + test.equal(buf, ['off1', 'off2', 'off3*', 'off4']); + buf.length = 0; + + + // XXX test intermediate unkeyed chunks; // duplicate branch keys; different order }); @@ -2427,6 +2464,7 @@ Tinytest.add("liveui - branch keys", function(test) { // - when differ between old/new // - on listChunk // - different old and new data +// - options.data in general // API Notes: // - { constant: true } requires branch key; doesn't preserve liveranges From 41bfedafe244eb0ab5c14373421f19722efa62e2 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Tue, 17 Jul 2012 15:14:54 -0700 Subject: [PATCH 028/212] comments --- packages/liveui/liveui.js | 3 ++- packages/templating/deftemplate.js | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js index 785c8c1d03..7c8d06c9c6 100644 --- a/packages/liveui/liveui.js +++ b/packages/liveui/liveui.js @@ -794,8 +794,9 @@ Meteor.ui = Meteor.ui || {}; visitNodes(srcParent, null, null, function(src) { // XXX inefficient to scan for match for every node! - // We could skip non-element nodes, except for "range matches" + // We could at least skip non-element nodes, except for "range matches" // used for constant chunks, which may begin on a non-element. + // But really this shouldn't be a linear search. var pair = _.find(nodeMatches, function(p) { return p[1] === src; }); diff --git a/packages/templating/deftemplate.js b/packages/templating/deftemplate.js index ec8b703000..30f187d130 100644 --- a/packages/templating/deftemplate.js +++ b/packages/templating/deftemplate.js @@ -36,6 +36,8 @@ var react_data = { events: (name ? Template[name].events : {}), data: data, + // legacy 'id' preservation + //preserve: { '*[id]': function(n) { return n.id; } }, branch: branch }; return Meteor.ui.chunk(getHtml, react_data); From 7ad1f5eff3c2bca109d37bdded1309fda7242263 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Wed, 18 Jul 2012 10:16:09 -0700 Subject: [PATCH 029/212] tests exploring branch keys; ReactiveVar helper; start of 'constant' helper --- packages/handlebars/evaluate.js | 7 +- packages/liveui/liveui_tests.js | 40 -------- packages/templating/deftemplate.js | 13 ++- packages/templating/templating_tests.html | 12 +++ packages/templating/templating_tests.js | 114 ++++++++++++++++++++++ packages/test-helpers/package.js | 1 + packages/test-helpers/reactivevar.js | 53 ++++++++++ 7 files changed, 194 insertions(+), 46 deletions(-) create mode 100644 packages/test-helpers/reactivevar.js diff --git a/packages/handlebars/evaluate.js b/packages/handlebars/evaluate.js index 587a2ce8c9..2688ea0d90 100644 --- a/packages/handlebars/evaluate.js +++ b/packages/handlebars/evaluate.js @@ -262,9 +262,10 @@ Handlebars.evaluate = function (ast, data, options) { var decorateBlockFn = function(fn, old_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`) - if ((data || Handlebars._defaultThis) === + // don't create spurious ranges when no branch given and data is same + // as before (or when transitioning between e.g. `window` and + // `undefined`) + if (! branch && (data || Handlebars._defaultThis) === (old_data || Handlebars._defaultThis)) { return result; } else { diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js index c773020aee..028a512b6f 100644 --- a/packages/liveui/liveui_tests.js +++ b/packages/liveui/liveui_tests.js @@ -1,46 +1,6 @@ (function() { -///// ReactiveVar ///// - -var ReactiveVar = function(initialValue) { - if (! (this instanceof ReactiveVar)) - return new ReactiveVar(initialValue); - - this._value = (typeof initialValue === "undefined" ? null : - initialValue); - this._deps = {}; -}; -ReactiveVar.prototype.get = 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]; - }); - } - - return this._value; -}; - -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) - this._deps[id].invalidate(); - -}; - -ReactiveVar.prototype.numListeners = function() { - return _.keys(this._deps).length; -}; - ///// WrappedFrag ///// var WrappedFrag = function(frag) { diff --git a/packages/templating/deftemplate.js b/packages/templating/deftemplate.js index 30f187d130..de81b14e4b 100644 --- a/packages/templating/deftemplate.js +++ b/packages/templating/deftemplate.js @@ -2,8 +2,10 @@ Meteor._partials = {}; - Meteor._hook_handlebars_each = function () { - Meteor._hook_handlebars_each = function(){}; // install the hook only once + // XXX Handlebars hooking is janky and gross + + Meteor._hook_handlebars = function () { + Meteor._hook_handlebars = function(){}; // install the hook only once var orig = Handlebars._default_helpers.each; Handlebars._default_helpers.each = function (arg, options) { @@ -12,11 +14,15 @@ return Meteor.ui.listChunk(arg, options.fn, options.inverse, null); }; + + Handlebars._default_helpers.constant = function(options) { + // XXX + }; }; Meteor._def_template = function (name, raw_func) { - Meteor._hook_handlebars_each(); + Meteor._hook_handlebars(); window.Template = window.Template || {}; @@ -35,6 +41,7 @@ var react_data = { events: (name ? Template[name].events : {}), + preserve: (name ? Template[name].preserve: {}), data: data, // legacy 'id' preservation //preserve: { '*[id]': function(n) { return n.id; } }, diff --git a/packages/templating/templating_tests.html b/packages/templating/templating_tests.html index 779b2122ea..915ae28eea 100644 --- a/packages/templating/templating_tests.html +++ b/packages/templating/templating_tests.html @@ -183,3 +183,15 @@ (biggie={{#get_arg helperListFour platypus thisTest fancyhelper.currentFruit fancyhelper.currentCountry.unicorns a=platypus b=thisTest c=fancyhelper.currentFruit d=fancyhelper.currentCountry.unicorns}}{{/get_arg}}) (twoArgBlock={{#two_args "foo" "foo"}}{{/two_args}}) + + + + + + diff --git a/packages/templating/templating_tests.js b/packages/templating/templating_tests.js index 046a769213..bb28fe0bab 100644 --- a/packages/templating/templating_tests.js +++ b/packages/templating/templating_tests.js @@ -313,3 +313,117 @@ Tinytest.add("templating - helpers and dots", function(test) { test.equal(trials[5], "(twoArgBlock=true,false)"); test.equal(trials.length, 6); }); + + +Tinytest.add("templating - rendered template", function(test) { + var R = ReactiveVar('foo'); + Template.test_render_a.foo = function() { + R.get(); + return this.x + 1; + }; + + Template.test_render_a.preserve = ['br']; + + var div = OnscreenDiv( + Meteor.ui.render(Template.test_render_a, {data: { x: 123 } })); + + test.equal(div.text().match(/\S+/)[0], "124"); + + var br1 = div.node().getElementsByTagName('br')[0]; + var hr1 = div.node().getElementsByTagName('hr')[0]; + test.isTrue(br1); + test.isTrue(hr1); + + R.set('bar'); + Meteor.flush(); + var br2 = div.node().getElementsByTagName('br')[0]; + var hr2 = div.node().getElementsByTagName('hr')[0]; + test.isTrue(br2); + test.isTrue(br1 === br2); + test.isTrue(hr2); + test.isFalse(hr1 === hr2); + + ///// + + R = ReactiveVar('foo'); + + Template.test_render_b.foo = function() { + R.get(); + return (+this) + 1; + }; + Template.test_render_b.preserve = ['br']; + + div = OnscreenDiv( + Meteor.ui.render(Template.test_render_b, {data: { x: 123 } })); + + test.equal(div.text().match(/\S+/)[0], "201"); + + var br1 = div.node().getElementsByTagName('br')[0]; + var hr1 = div.node().getElementsByTagName('hr')[0]; + test.isTrue(br1); + test.isTrue(hr1); + + R.set('bar'); + Meteor.flush(); + var br2 = div.node().getElementsByTagName('br')[0]; + var hr2 = div.node().getElementsByTagName('hr')[0]; + test.isTrue(br2); + test.isTrue(br1 === br2); + test.isTrue(hr2); + test.isFalse(hr1 === hr2); + + ///// + + var stuff = new LocalCollection(); + stuff.insert({foo:'bar'}); + + Template.test_render_c.preserve = ['br']; + + div = OnscreenDiv(Meteor.ui.render(function() { + return Meteor.ui.listChunk( + stuff.find(), function(data) { + return Template.test_render_c(data, 'blah'); + }); + })); + + var br1 = div.node().getElementsByTagName('br')[0]; + var hr1 = div.node().getElementsByTagName('hr')[0]; + test.isTrue(br1); + test.isTrue(hr1); + + stuff.update({foo:'bar'}, {$set: {foo: 'baz'}}); + Meteor.flush(); + var br2 = div.node().getElementsByTagName('br')[0]; + var hr2 = div.node().getElementsByTagName('hr')[0]; + test.isTrue(br2); + test.isTrue(br1 === br2); + test.isTrue(hr2); + test.isFalse(hr1 === hr2); + + ///// + + var stuff = new LocalCollection(); + stuff.insert({foo:'bar'}); + + Template.test_render_c.preserve = ['br']; + + div = OnscreenDiv(Meteor.ui.render(function() { + return Meteor.ui.listChunk( + stuff.find(), Template.test_render_c); + })); + + var br1 = div.node().getElementsByTagName('br')[0]; + var hr1 = div.node().getElementsByTagName('hr')[0]; + test.isTrue(br1); + test.isTrue(hr1); + + stuff.update({foo:'bar'}, {$set: {foo: 'baz'}}); + Meteor.flush(); + var br2 = div.node().getElementsByTagName('br')[0]; + var hr2 = div.node().getElementsByTagName('hr')[0]; + test.isTrue(br2); + test.isTrue(br1 === br2); + test.isTrue(hr2); + test.isFalse(hr1 === hr2); + +}); \ No newline at end of file diff --git a/packages/test-helpers/package.js b/packages/test-helpers/package.js index 463d4829d9..506e12198c 100644 --- a/packages/test-helpers/package.js +++ b/packages/test-helpers/package.js @@ -14,6 +14,7 @@ Package.on_use(function (api, where) { api.add_files('stub_stream.js', where); api.add_files('onscreendiv.js', where); api.add_files('current_style.js', where); + api.add_files('reactivevar.js', where); }); Package.on_test(function (api) { diff --git a/packages/test-helpers/reactivevar.js b/packages/test-helpers/reactivevar.js new file mode 100644 index 0000000000..cf369255fd --- /dev/null +++ b/packages/test-helpers/reactivevar.js @@ -0,0 +1,53 @@ +// ReactiveVar is like a portable Session var. When you get it, +// it registers a dependency, and when it's set, it invalidates +// its dependencies. +// +// When set to a primitive value, invalidation +// is only fired if the new value is !== the old one. When set +// to an object value, invalidation always happens. Each behavior +// may be desirable in different test scenarios. +// body and keeps track of it, providing methods that query it, +// mutate, and destroy it. +// +// Constructor, with optional 'new': +// var R = [new] ReactiveVar([initialValue]) + + +var ReactiveVar = function(initialValue) { + if (! (this instanceof ReactiveVar)) + return new ReactiveVar(initialValue); + + this._value = (typeof initialValue === "undefined" ? null : + initialValue); + this._deps = {}; +}; + +ReactiveVar.prototype.get = 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]; + }); + } + + return this._value; +}; + +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) + this._deps[id].invalidate(); + +}; + +ReactiveVar.prototype.numListeners = function() { + return _.keys(this._deps).length; +}; From 14ced70720bf1f6c45d6909f8a074efb57e0666c Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Mon, 23 Jul 2012 15:57:38 -0700 Subject: [PATCH 030/212] first cut of annotate/materialize --- packages/liveui/livedocument.js | 159 ++++++++++++++++++++++++++ packages/liveui/livedocument_tests.js | 56 +++++++++ packages/liveui/liveui_tests.js | 26 ----- packages/liveui/package.js | 2 + packages/test-helpers/package.js | 1 + packages/test-helpers/wrappedfrag.js | 33 ++++++ 6 files changed, 251 insertions(+), 26 deletions(-) create mode 100644 packages/liveui/livedocument.js create mode 100644 packages/liveui/livedocument_tests.js create mode 100644 packages/test-helpers/wrappedfrag.js diff --git a/packages/liveui/livedocument.js b/packages/liveui/livedocument.js new file mode 100644 index 0000000000..d2e471da84 --- /dev/null +++ b/packages/liveui/livedocument.js @@ -0,0 +1,159 @@ +Meteor.ui = Meteor.ui || {}; +Meteor.ui._doc = Meteor.ui._doc || {}; + +(function() { + + var LIVEUI_START_PREFIX = "LIVEUI_START_"; + var LIVEUI_END_PREFIX = "LIVEUI_END_"; + var LIVEUI_MARKER_PREFIX = "LIVEUI_"; + var HTML_PARSE_REGEX = /|<|>|[^<>]+/g; + var RANGE_PARSE_REGEX = /^$/; + var MARKER_PARSE_REGEX = /^LIVEUI_(.*)$/; + + Meteor.ui._TAG = "_liveui"; + + Meteor.ui._doc._newAnnotations = {}; // {id -> options} until range created + Meteor.ui._doc._newRanges = []; // [LiveRange, ...] until flush time + Meteor.ui._doc._nextId = 1; + + // XXX to mention: + // - turning ranges into frags separately helps deal with + // mismatched tags + // - the LIVEUI_START/END comments could be any strings, in theory, + // since we pull them out -- for example, they could be fake + // tags like . Html comments are invisible + // if they go through, but not e.g. inside tag attributes. + // - there is no "ignore annotations" mode, that has to be implemented + // in liveui. + + + Meteor.ui._doc.materialize = function (html) { + var idToSubHtml = {}; + var inTag = false; + var parts = [[]]; + var ids = []; + _.each(html.match(HTML_PARSE_REGEX), function(tok) { + var part = tok; + if (tok === '<') { + inTag = true; + } else if (tok === '>') { + inTag = false; + } else if (tok.charAt(0) === '<') { + // START or END comment + if (inTag) { + // can't have a "LiveRange" between tag angle brackets; + // until we deal with this case somehow, ignore + // the annotation + part = ""; + } else { + var match = tok.match(RANGE_PARSE_REGEX); + var isStart = match[1] === 'START'; + var id = match[2]; + if (isStart) { + ids.push(id); // push the id we're in + parts.push([]); // start a new fragment + part = ""; // don't emit anything + } else { + var curId = ids.pop(); // pop the id + if (curId !== id) + throw new Error("Range mismatch: "+curId+" / "+id); + // record the HTML for this range + var subHtml = parts.pop().join(''); + idToSubHtml[id] = subHtml; + // emit a comment in the parent range + part = ""; + } + } + } + // append the current token to the current fragment + parts[parts.length - 1].push(part); + }); + + if (ids.length > 0) + throw new Error("Unclosed ranges "+ids.join(',')); + + var topHtml = parts.pop().join(''); + + // Helper that invokes `f` on every comment node under `parent`. + // If `f` returns a node, visit that node next. + var eachComment = 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 + eachComment(n, f); // recurse + n = n.nextSibling; + } + }; + + var makeFrag = function(html) { + var frag = Meteor.ui._htmlToFragment(html); + // empty frag becomes HTML comment + if (! frag.firstChild) + frag.appendChild(document.createComment("empty")); + + eachComment(frag, function(comment) { + var match = MARKER_PARSE_REGEX.exec(comment.nodeValue); + if (match) { + var id = match[1]; + var html = idToSubHtml[id]; + + // Look up annotation data for this id, to determine if it exists + // and hasn't been used before during this or a previous + // materialize (if the dev is not playing by the rules) + var options = Meteor.ui._doc._newAnnotations[id]; + if (! options) + throw new Error("Missing or duplicate annotation (on "+ + (html||'unknown html')+")"); + Meteor.ui._doc._newAnnotations[id] = null; + + var subFrag = makeFrag(html); + var range = new Meteor.ui._LiveRange(Meteor.ui._TAG, subFrag); + // assign options to the LiveRange, including `id` + _.extend(range, options); + + var next = comment.nextSibling; + + var container = comment.parentNode; + if (container && container.nodeName === "TABLE" && + _.any(subFrag.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(subFrag); + comment.parentNode.replaceChild(tbody, comment); + } else { + comment.parentNode.replaceChild(subFrag, comment); + } + + return next; + } + }); + + return frag; + }; + + return makeFrag(topHtml); + }; + + Meteor.ui._doc.annotate = function(html, options) { + options = options || {}; + + // Generate a unique id string, e.g. "a17" + var id = "a"+(Meteor.ui._doc._nextId++); + options.id = id; + + // Save `options` object to attach to LiveRange later + Meteor.ui._doc._newAnnotations[id] = options; + + // Surround the HTML with comments + return ("" + + html + ""); + }; + +})(); \ No newline at end of file diff --git a/packages/liveui/livedocument_tests.js b/packages/liveui/livedocument_tests.js new file mode 100644 index 0000000000..30eb642d3b --- /dev/null +++ b/packages/liveui/livedocument_tests.js @@ -0,0 +1,56 @@ +Tinytest.add("livedocument - assembly", function(test) { + + var doTest = function(calc) { + var frag = Meteor.ui._doc.materialize( + calc(function(str, expected) { + return Meteor.ui._doc.annotate(str); + })); + var groups = []; + var html = calc(function(str, expected, noRange) { + if (arguments.length > 1) + str = expected; + if (! noRange) + groups.push(str); + return str; + }); + test.equal(WrappedFrag(frag).html(), html); + + var actualGroups = []; + var tempRange = new Meteor.ui._LiveRange(Meteor.ui._TAG, frag); + tempRange.visit(function(isStart, rng) { + if (! isStart) + actualGroups.push(Meteor.ui._rangeToHtml(rng)); + }); + test.equal(actualGroups.join(','), groups.join(',')); + }; + + doTest(function(A) { return "

Hello

"; }); + doTest(function(A) { return "HelloWorld"; }); + doTest(function(A) { return ""+A("Hello")+""; }); + doTest(function(A) { return A(""+A("Hello")+""); }); + doTest(function(A) { return A(A(A(A(A(A("foo")))))); }); + doTest( + function(A) { return "
Yo"+A("

Hello "+A(A("World")),"

Hello World

")+ + "
"; }); + doTest(function(A) { + return A("