diff --git a/admin/spark-standalone.sh b/admin/spark-standalone.sh index 2b38d4a5a3..e84c3bdbd0 100755 --- a/admin/spark-standalone.sh +++ b/admin/spark-standalone.sh @@ -9,6 +9,7 @@ PACKAGES_DIR=`dirname $0`/../packages echo 'Meteor = {};' cat $PACKAGES_DIR/uuid/uuid.js cat $PACKAGES_DIR/deps/deps.js +cat $PACKAGES_DIR/deps/deps-utils.js cat $PACKAGES_DIR/liverange/liverange.js cat $PACKAGES_DIR/universal-events/listener.js cat $PACKAGES_DIR/universal-events/events-ie.js diff --git a/examples/other/template-demo/client/template-demo.js b/examples/other/template-demo/client/template-demo.js index 2268e41455..92a490cafa 100644 --- a/examples/other/template-demo/client/template-demo.js +++ b/examples/other/template-demo/client/template-demo.js @@ -137,32 +137,6 @@ Template.timer.destroyed = function () { /////////////////////////////////////////////////////////////////////////////// -// Run f(). Record its dependencies. Rerun it whenever the -// dependencies change. -// -// Returns an object with a stop() method. Call stop() to stop the -// rerunning. -// -// XXX this should go into Meteor core as Meteor.autorun -var autorun = function (f) { - var ctx; - var slain = false; - var rerun = function () { - if (slain) - return; - ctx = new Meteor.deps.Context; - ctx.run(f); - ctx.onInvalidate(rerun); - }; - rerun(); - return { - stop: function () { - slain = true; - ctx.invalidate(); - } - }; -}; - Template.d3Demo.left = function () { return { group: "left" }; }; @@ -233,7 +207,7 @@ Template.circles.rendered = function () { if (! self.handle) { d3.select(self.node).append("rect"); - self.handle = autorun(function () { + self.handle = Meteor._autorun(function () { var circle = d3.select(self.node).selectAll("circle") .data(Circles.find({group: data.group}).fetch(), function (d) { return d._id; }); diff --git a/packages/deps/deps-utils.js b/packages/deps/deps-utils.js new file mode 100644 index 0000000000..569d2deadd --- /dev/null +++ b/packages/deps/deps-utils.js @@ -0,0 +1,118 @@ +(function () { + // XXX Document, test, and remove the leading underscore from everything. + + ////////// Meteor.deps._ContextSet + + // Constructor for an empty _ContextSet. + // + // A _ContextSet is used to hold a set of Meteor.deps.Contexts that + // are to be invalidated at some future time. If a Context in the + // set becomes invalidated for any reason, it's immediately removed + // from the set. + var _ContextSet = function () { + this._contextsById = {}; + }; + + // Adds the Context `ctx` to this set if it is not already + // present. Returns true if the context is new to this set. + _ContextSet.prototype.add = function (ctx) { + var self = this; + if (ctx && ! (ctx.id in self._contextsById)) { + self._contextsById[ctx.id] = ctx; + ctx.onInvalidate(function () { + delete self._contextsById[ctx.id]; + }); + return true; + } + return false; + }; + + // Adds the current Context to this set if there is one. Returns + // true if there is a current Context and it's new to the set. + _ContextSet.prototype.addCurrentContext = function () { + var self = this; + var context = Meteor.deps.Context.current; + if (! context) + return false; + return self.add(context); + }; + + // Invalidate all Contexts in this set. They will be removed + // from the set as a consequence. + _ContextSet.prototype.invalidateAll = function () { + var self = this; + for (var id in self._contextsById) + self._contextsById[id].invalidate(); + }; + + // Returns true if there are no Contexts in this set. + _ContextSet.prototype.isEmpty = function () { + var self = this; + for(var id in self._contextsById) + return false; + return true; + }; + + Meteor.deps._ContextSet = _ContextSet; + + ////////// Meteor._autorun + + // Run f(). Record its dependencies. Rerun it whenever the + // dependencies change. + // + // Returns an object with a stop() method. Call stop() to stop the + // rerunning. Also passes this object as an argument to f. + Meteor._autorun = function (f) { + var ctx; + var slain = false; + var handle = { + stop: function () { + slain = true; + ctx.invalidate(); + } + }; + var rerun = function () { + if (slain) + return; + ctx = new Meteor.deps.Context; + ctx.run(function () { f.call(this, handle); }); + ctx.onInvalidate(rerun); + }; + rerun(); + return handle; + }; + + ////////// Meteor._atFlush + + // Run 'f' at Meteor.flush()-time. If atFlush is called multiple times, + // we guarantee that the 'f's will run in the same order that + // atFlush was called on them. If we are inside a Meteor.flush() already, + // f will be scheduled as part of the current flush(). + + var atFlushQueue = []; + var atFlushContext = null; + Meteor._atFlush = function (f) { + atFlushQueue.push(f); + + if (! atFlushContext) { + atFlushContext = new Meteor.deps.Context; + atFlushContext.onInvalidate(function () { + var f; + while ((f = atFlushQueue.shift())) { + // Since atFlushContext is truthy, if f() calls atFlush + // reentrantly, it's guaranteed to append to atFlushQueue and + // not contruct a new atFlushContext. + try { + f(); + } catch (e) { + Meteor._debug("Exception from Meteor._atFlush:", e); + } + } + atFlushContext = null; + }); + + atFlushContext.invalidate(); + } + }; + +})(); \ No newline at end of file diff --git a/packages/deps/deps.js b/packages/deps/deps.js index dc229ad960..f1cff70855 100644 --- a/packages/deps/deps.js +++ b/packages/deps/deps.js @@ -59,7 +59,11 @@ _.each(pending, function (ctx) { _.each(ctx._callbacks, function (f) { - f(ctx); // XXX wrap in try? + try { + f(ctx); + } catch (e) { + Meteor._debug("Exception from Meteor.flush:", e); + } }); delete ctx._callbacks; // maybe help the GC }); diff --git a/packages/deps/package.js b/packages/deps/package.js index 9949cfdd73..f21ff22073 100644 --- a/packages/deps/package.js +++ b/packages/deps/package.js @@ -9,5 +9,5 @@ Package.on_use(function (api, where) { where = where || ['client', 'server']; api.use('underscore', where); - api.add_files('deps.js', where); + api.add_files(['deps.js', 'deps-utils.js'], where); }); diff --git a/packages/session/session.js b/packages/session/session.js index df76d163eb..c55d5b39e5 100644 --- a/packages/session/session.js +++ b/packages/session/session.js @@ -1,31 +1,9 @@ // XXX could use some tests Session = _.extend({}, { - keys: {}, - key_deps: {}, // key -> context id -> context - key_value_deps: {}, // key -> value -> context id -> context - - // XXX remove debugging method (or improve it, but anyway, don't - // ship it in production) - dump_state: function () { - var self = this; - console.log("=== Session state ==="); - for (var key in self.key_deps) { - var ids = _.keys(self.key_deps[key]); - if (!ids.length) - continue; - console.log(key + ": " + _.reject(ids, function (x) {return x === "_once"}).join(' ')); - } - - for (var key in self.key_value_deps) { - for (var value in self.key_value_deps[key]) { - var ids = _.keys(self.key_value_deps[key][value]); - if (!ids.length) - continue; - console.log(key + "(" + value + "): " + _.reject(ids, function (x) {return x === "_once";}).join(' ')); - } - } - }, + keys: {}, // key -> value + keyDeps: {}, // key -> _ContextSet + keyValueDeps: {}, // key -> value -> _ContextSet set: function (key, value) { var self = this; @@ -36,35 +14,26 @@ Session = _.extend({}, { value !== null && value !== undefined) throw new Error("Session.set: value can't be an object"); - var old_value = self.keys[key]; - if (value === old_value) + var oldValue = self.keys[key]; + if (value === oldValue) return; self.keys[key] = value; - var invalidate = function (map) { - if (map) - for (var id in map) - map[id].invalidate(); + var invalidateAll = function (cset) { + cset && cset.invalidateAll(); }; - self._ensureKey(key); - invalidate(self.key_deps[key]); - invalidate(self.key_value_deps[key][old_value]); - invalidate(self.key_value_deps[key][value]); + invalidateAll(self.keyDeps[key]); + if (self.keyValueDeps[key]) { + invalidateAll(self.keyValueDeps[key][oldValue]); + invalidateAll(self.keyValueDeps[key][value]); + } }, get: function (key) { var self = this; - var context = Meteor.deps.Context.current; self._ensureKey(key); - - if (context && !(context.id in self.key_deps[key])) { - self.key_deps[key][context.id] = context; - context.onInvalidate(function () { - delete self.key_deps[key][context.id]; - }); - } - + self.keyDeps[key].addCurrentContext(); return self.keys[key]; }, @@ -75,24 +44,23 @@ Session = _.extend({}, { if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean' && - value !== null && value !== undefined) + typeof value !== 'undefined' && + value !== null) throw new Error("Session.equals: value can't be an object"); if (context) { self._ensureKey(key); - if (!(value in self.key_value_deps[key])) - self.key_value_deps[key][value] = {}; - if (!(context.id in self.key_value_deps[key][value])) { - self.key_value_deps[key][value][context.id] = context; + if (!(value in self.keyValueDeps[key])) + self.keyValueDeps[key][value] = new Meteor.deps._ContextSet; + + var isNew = self.keyValueDeps[key][value].add(context); + if (isNew) { context.onInvalidate(function () { - delete self.key_value_deps[key][value][context.id]; - // clean up [key][value] if it's now empty, so we don't use // O(n) memory for n = values seen ever - for (var x in self.key_value_deps[key][value]) - return; - delete self.key_value_deps[key][value]; + if (self.keyValueDeps[key][value].isEmpty()) + delete self.keyValueDeps[key][value]; }); } } @@ -102,9 +70,9 @@ Session = _.extend({}, { _ensureKey: function (key) { var self = this; - if (!(key in self.key_deps)) { - self.key_deps[key] = {}; - self.key_value_deps[key] = {}; + if (!(key in self.keyDeps)) { + self.keyDeps[key] = new Meteor.deps._ContextSet; + self.keyValueDeps[key] = {}; } } }); diff --git a/packages/spark/spark.js b/packages/spark/spark.js index 6706254e86..ee4d0b69be 100644 --- a/packages/spark/spark.js +++ b/packages/spark/spark.js @@ -22,7 +22,7 @@ // XXX in landmark-demo, if Template.timer.created throws an exception, // then it is never called again, even if you push the 'create a -// timer' button again. the problem is almost certainly in atFlushTime +// timer' button again. the problem is almost certainly in atFlush // (not hard to see what it is.) (function() { @@ -144,9 +144,19 @@ Spark._Renderer = function () { _.extend(Spark._Renderer.prototype, { // `what` can be a function that takes a LiveRange, or just a set of - // attributes to add to the liverange. tag and what are optional. - // if no tag is passed, no liverange will be created. - annotate: function (html, type, what, unusedFunc) { + // attributes to add to the liverange. type and what are optional. + // if no type is passed, no liverange will be created. + // If what is a function, it will be called no matter what, even + // if the annotated HTML was not used and no LiveRange was created, + // in which case it gets null as an argument. + annotate: function (html, type, what) { + if (typeof what !== 'function') { + var attribs = what; + what = function (range) { + if (range) + _.extend(range, attribs); + }; + } // The annotation tags that we insert into HTML strings must be // unguessable in order to not create potential cross-site scripting // attack vectors, so we use random strings. Even a well-written app @@ -156,20 +166,15 @@ _.extend(Spark._Renderer.prototype, { // and not arbitrary user-entered data. var id = (type || '') + ":" + Spark._createId(); this.annotations[id] = function (start, end) { - if (! start) { - // materialize called us with no args because this annotation - // wasn't used - unusedFunc && unusedFunc(); + if ((! start) || (! type)) { + // ! start: materialize called us with no args because this + // annotation wasn't used + // ! type: no type given, don't generate a LiveRange + what(null); return; } - if (! type) - // no type given; don't generate a LiveRange - return; var range = makeRange(type, start, end); - if (what instanceof Function) - what(range); - else - _.extend(range, what); + what(range); }; return "<$" + id + ">" + html + ""; @@ -340,8 +345,7 @@ var scheduleOnscreenSetup = function (frag, landmarkRanges) { finalized = true; }; - var ctx = new Meteor.deps.Context; - ctx.onInvalidate(function () { + Meteor._atFlush(function () { if (finalized) return; @@ -395,8 +399,6 @@ var scheduleOnscreenSetup = function (frag, landmarkRanges) { notifyWatchers(renderedRange.firstNode(), renderedRange.lastNode()); renderedRange.destroy(); }); - - ctx.invalidate(); }; Spark.render = function (htmlFunc) { @@ -721,6 +723,9 @@ Spark.attachEvents = withRenderer(function (eventMap, html, _renderer) { html = _renderer.annotate( html, Spark._ANNOTATION_EVENTS, function (range) { + if (! range) + return; + _.each(eventTypes, function (t) { listener.addType(t); }); @@ -787,66 +792,40 @@ Spark.isolate = function (htmlFunc) { if (!renderer) return htmlFunc(); - var ctx = new Meteor.deps.Context; - - return renderer.annotate( - ctx.run(htmlFunc), Spark._ANNOTATION_ISOLATE, function (range) { - range.finalize = function () { - // Spark.finalize() was called on us (presumably because we were - // removed from the document.) Tear down our structures without - // doing any more updates. note that range is about to be - // destroyed by finalize. - range = null; - ctx.invalidate(); - }; - - var refresh = function () { - if (! range) - return; // killed by finalize. range has already been destroyed. - - ctx = new Meteor.deps.Context; - Spark.renderToRange(range, function () { - return ctx.run(htmlFunc); + var range; + var firstRun = true; + var retHtml; + Meteor._autorun(function (handle) { + if (firstRun) { + retHtml = renderer.annotate( + htmlFunc(), Spark._ANNOTATION_ISOLATE, + function (r) { + if (! r) { + // annotation not used; kill our context + handle.stop(); + } else { + range = r; + range.finalize = function () { + // Spark.finalize() was called on our range (presumably + // because it was removed from the document.) Kill + // this context and stop rerunning. + handle.stop(); + }; + } }); - ctx.onInvalidate(refresh); - }; + firstRun = false; + } else { + Spark.renderToRange(range, htmlFunc); + } + }); - ctx.onInvalidate(refresh); - }); + return retHtml; }; /******************************************************************************/ /* Lists */ /******************************************************************************/ -// Run 'f' at flush()-time. If atFlushTime is called multiple times, -// we guarantee that the 'f's will run in the order of their -// respective atFlushTime calls. -// -// XXX either break this out into a separate package or fold it into -// deps -var atFlushQueue = []; -var atFlushContext = null; -var atFlushTime = function (f) { - atFlushQueue.push(f); - - if (! atFlushContext) { - atFlushContext = new Meteor.deps.Context; - atFlushContext.onInvalidate(function () { - var f; - while ((f = atFlushQueue.shift())) { - // Since atFlushContext is truthy, if f() calls atFlushTime - // reentrantly, it's guaranteed to append to atFlushQueue and - // not contruct a new atFlushContext. - f(); - } - atFlushContext = null; - }); - - atFlushContext.invalidate(); - } -}; - Spark.list = function (cursor, itemFunc, elseFunc) { elseFunc = elseFunc || function () { return ''; }; @@ -874,8 +853,8 @@ Spark.list = function (cursor, itemFunc, elseFunc) { // Get the renderer, if any var renderer = Spark._currentRenderer.get(); - var annotate = renderer ? - _.bind(renderer.annotate, renderer) : + var maybeAnnotate = renderer ? + _.bind(renderer.annotate, renderer) : function (html) { return html; }; // Render the initial contents. If we have a renderer, create a @@ -889,11 +868,11 @@ Spark.list = function (cursor, itemFunc, elseFunc) { else { for (var i = 0; i < initialContents.length; i++) { (function (i) { - html += annotate(itemFunc(initialContents[i]), - Spark._ANNOTATION_LIST_ITEM, - function (range) { - itemRanges[i] = range; - }); + html += maybeAnnotate(itemFunc(initialContents[i]), + Spark._ANNOTATION_LIST_ITEM, + function (range) { + itemRanges[i] = range; + }); })(i); // scope i to closure } } @@ -903,13 +882,15 @@ Spark.list = function (cursor, itemFunc, elseFunc) { handle.stop(); stopped = true; }; - html = annotate(html, Spark._ANNOTATION_LIST, function (range) { - outerRange = range; - outerRange.finalize = cleanup; - }, function () { - // We never ended up on the screen (caller discarded our return - // value) - cleanup(); + html = maybeAnnotate(html, Spark._ANNOTATION_LIST, function (range) { + if (! range) { + // We never ended up on the screen (caller discarded our return + // value) + cleanup(); + } else { + outerRange = range; + outerRange.finalize = cleanup; + } }); // No renderer? Then we have no way to update the returned html and @@ -930,7 +911,7 @@ Spark.list = function (cursor, itemFunc, elseFunc) { }; var later = function (f) { - atFlushTime(function () { + Meteor._atFlush(function () { if (! stopped) withEventGuard(f); }); @@ -1124,6 +1105,12 @@ Spark.createLandmark = function (options, htmlFunc) { var html = htmlFunc(landmark); return renderer.annotate( html, Spark._ANNOTATION_LANDMARK, function (range) { + if (! range) { + // annotation not used + options.destroyed && options.destroyed.call(landmark); + return; + } + _.extend(range, { preserve: preserve, constant: !! options.constant, @@ -1140,9 +1127,6 @@ Spark.createLandmark = function (options, htmlFunc) { landmark._range = range; renderer.landmarkRanges.push(range); - }, function () { - // "annotation not used" callback - options.destroyed && options.destroyed.call(landmark); }); }; diff --git a/packages/stream/stream_client.js b/packages/stream/stream_client.js index 0f70866b75..6b03f33c76 100644 --- a/packages/stream/stream_client.js +++ b/packages/stream/stream_client.js @@ -47,11 +47,10 @@ Meteor._Stream = function (url) { retry_count: 0 }; - self.status_listeners = {}; // context.id -> context + self.status_listeners = (Meteor.deps && new Meteor.deps._ContextSet); self.status_changed = function () { - _.each(self.status_listeners, function (context) { - context.invalidate(); - }); + if (self.status_listeners) + self.status_listeners.invalidateAll(); }; //// Retry logic @@ -120,13 +119,8 @@ _.extend(Meteor._Stream.prototype, { // Get current status. Reactive. status: function () { var self = this; - var context = Meteor.deps && Meteor.deps.Context.current; - if (context && !(context.id in self.status_listeners)) { - self.status_listeners[context.id] = context; - context.onInvalidate(function () { - delete self.status_listeners[context.id]; - }); - } + if (self.status_listeners) + self.status_listeners.addCurrentContext(); return self.current_status; }, diff --git a/packages/test-helpers/onscreendiv.js b/packages/test-helpers/onscreendiv.js index 47446c6f71..b594627e2b 100644 --- a/packages/test-helpers/onscreendiv.js +++ b/packages/test-helpers/onscreendiv.js @@ -50,11 +50,9 @@ OnscreenDiv.prototype.kill = function() { if (self.div.parentNode) self.div.parentNode.removeChild(self.div); - var cx = new Meteor.deps.Context; - cx.onInvalidate(function() { + Meteor._atFlush(function () { Spark.finalize(self.div); }); - cx.invalidate(); }; // remove the DIV from the document diff --git a/packages/test-helpers/reactivevar.js b/packages/test-helpers/reactivevar.js index 2c3c4e7dde..0c789663ef 100644 --- a/packages/test-helpers/reactivevar.js +++ b/packages/test-helpers/reactivevar.js @@ -19,19 +19,11 @@ var ReactiveVar = function(initialValue) { this._value = (typeof initialValue === "undefined" ? null : initialValue); - this._deps = {}; + this._deps = new Meteor.deps._ContextSet; }; 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.onInvalidate(function() { - delete self._deps[context.id]; - }); - } - + this._deps.addCurrentContext(); return this._value; }; @@ -43,11 +35,9 @@ ReactiveVar.prototype.set = function(newValue) { this._value = newValue; - for(var id in this._deps) - this._deps[id].invalidate(); - + this._deps.invalidateAll(); }; ReactiveVar.prototype.numListeners = function() { - return _.keys(this._deps).length; + return _.keys(this._deps._contextsById).length; }; diff --git a/packages/test-helpers/wrappedfrag.js b/packages/test-helpers/wrappedfrag.js index ad5f56e896..f019bf2fea 100644 --- a/packages/test-helpers/wrappedfrag.js +++ b/packages/test-helpers/wrappedfrag.js @@ -31,13 +31,11 @@ WrappedFrag.prototype.release = function() { // decrement frag's GC protection reference count // Clean up on flush, if hits 0. Wait to decrement // so no one else cleans it up first. - var cx = new Meteor.deps.Context; - cx.onInvalidate(function() { + Meteor._atFlush(function () { if (! --frag["_protect"]) { Spark.finalize(frag); } }); - cx.invalidate(); return this; }; diff --git a/packages/test-in-browser/driver.js b/packages/test-in-browser/driver.js index eb37196ca9..0a50601299 100644 --- a/packages/test-in-browser/driver.js +++ b/packages/test-in-browser/driver.js @@ -12,19 +12,12 @@ Meteor.startup(function () { }); Template.test_table.running = function() { - var cx = Meteor.deps.Context.current; - if (cx) { - resultDeps.push(cx); - } - + resultDeps.addCurrentContext(); return running; }; Template.test_table.passed = function() { - var cx = Meteor.deps.Context.current; - if (cx) { - resultDeps.push(cx); - } + resultDeps.addCurrentContext(); // walk whole tree to look for failed tests var walk = function (groups) { @@ -53,10 +46,7 @@ Template.test_table.passed = function() { Template.test_table.total_test_time = function() { - var cx = Meteor.deps.Context.current; - if (cx) { - resultDeps.push(cx); - } + resultDeps.addCurrentContext(); // walk whole tree to get all tests var walk = function (groups) { @@ -79,11 +69,7 @@ Template.test_table.total_test_time = function() { Template.test_table.data = function() { - var cx = Meteor.deps.Context.current; - if (cx) { - resultDeps.push(cx); - } - + resultDeps.addCurrentContext(); return resultTree; }; @@ -185,13 +171,10 @@ Template.event.is_debuggable = function() { var resultTree = []; -var resultDeps = []; +var resultDeps = new Meteor.deps._ContextSet; var _resultsChanged = function() { - _.each(resultDeps, function(cx) { - cx.invalidate(); - }); - resultDeps.length = 0; + resultDeps.invalidateAll(); }; var _testTime = function(t) {