diff --git a/.mailmap b/.mailmap index efd15a1605..705934494b 100644 --- a/.mailmap +++ b/.mailmap @@ -27,6 +27,7 @@ GITHUB: codeinthehole GITHUB: dandv GITHUB: davegonzalez GITHUB: ducdigital +GITHUB: duckspeaker GITHUB: emgee3 GITHUB: felixrabe GITHUB: FredericoC @@ -75,3 +76,4 @@ METEOR: sixolet METEOR: Slava METEOR: stubailo METEOR: ekatek +METEOR: mariapacana diff --git a/History.md b/History.md index 6856a8c0dd..0c02250709 100644 --- a/History.md +++ b/History.md @@ -1,4 +1,4 @@ -## v.NEXT.NEXT +## v.NEXT * The `appcache` package now defaults to functioning on all browsers that support the AppCache API, rather than a whitelist of browsers. You can still @@ -6,16 +6,90 @@ change is that `appcache` is now enabled by default on Firefox, because Firefox no longer makes a confusing popup. #2241 +* When a call to `match` fails in a method or subscription, log the + failure on the server. (This matches the behavior described in our docs) -## v.NEXT +## v0.8.3 + +#### Blaze + +* Refactor Blaze to simplify internals while preserving the public + API. `UI.Component` has been replaced with `Blaze.View.` + +* Fix performance issues and memory leaks concerning event handlers. + +* Add `UI.remove`, which removes a template after `UI.render`/`UI.insert`. + +* Add `this.autorun` to the template instance, which is like `Deps.autorun` + but is automatically stopped when the template is destroyed. + +* Create `` tags as SVG elements when they have `xlink:href` + attributes. (Previously, `` tags inside SVGs were never created as + SVG elements.) #2178 + +* Throw an error in `{{foo bar}}` if `foo` is missing or not a function. + +* Cursors returned from template helpers for #each should implement + the `observeChanges` method and don't have to be Minimongo cursors + (allowing new custom data stores for Blaze like Miniredis). + +* Remove warnings when {{#each}} iterates over a list of strings, + numbers, or other items that contains duplicates. #1980 + +#### Meteor Accounts + +* Fix regression in 0.8.2 where an exception would be thrown if + `Meteor.loginWithPassword` didn't have a callback. Callbacks to + `Meteor.loginWithPassword` are now optional again. #2255 + +* Fix OAuth popup flow in mobile apps that don't support + `window.opener`. #2302 + +* Fix "Email already exists" error with MongoDB 2.6. #2238 + + +#### mongo-livedata and minimongo * Fix performance issue where a large batch of oplog updates could block the node event loop for long periods. #2299. +* Fix oplog bug resulting in error message "Buffer inexplicably empty". #2274 + +* Fix regression from 0.8.2 that caused collections to appear empty in + reactive `findOne()` or `fetch` queries that run before a mutator + returns. #2275 + + +#### Miscellaneous + +* Stop including code by default that automatically refreshes the page + if JavaScript and CSS don't load correctly. While this code is useful + in some multi-server deployments, it can cause infinite refresh loops + if there are errors on the page. Add the `reload-safetybelt` package + to your app if you want to include this code. + +* On the server, `Meteor.startup(c)` now calls `c` immediately if the + server has already started up, matching the client behavior. #2239 + +* Add support for server-side source maps when debugging with + `node-inspector`. + +* Add `WebAppInternals.addStaticJs()` for adding static JavaScript code + to be served in the app, inline if allowed by `browser-policy`. + +* Make the `tinytest/run` method return immediately, so that `wait` + method calls from client tests don't block on server tests completing. + +* Log errors from method invocations on the client if there is no + callback provided. + * Upgraded dependencies: + - node: 0.10.29 (from 0.10.28) - less: 1.7.1 (from 1.6.1) +Patches contributed by GitHub users Cangit, cmather, duckspeaker, zol. + ## v0.8.2 diff --git a/docs/.meteor/release b/docs/.meteor/release index 100435be13..ee94dd834b 100644 --- a/docs/.meteor/release +++ b/docs/.meteor/release @@ -1 +1 @@ -0.8.2 +0.8.3 diff --git a/docs/client/api.html b/docs/client/api.html index 5e8a7b30f6..f1ac161bb2 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -2189,6 +2189,12 @@ This property provides access to the data context at the top level of the template. It is updated each time the template is re-rendered. Access is read-only and non-reactive. +{{> api_box template_autorun}} + +You can use `this.autorun` from a [`created`](#template_created) or +[`rendered`](#template_rendered) callback to reactively update the DOM +or the template instance. The Computation is automatically stopped +when the template is destroyed.

Template utilities

@@ -2205,6 +2211,15 @@ any part of the DOM for finer control than just using template inclusions. You can define helpers and event maps on `UI.body` just like on any `Template.myTemplate` object. +Helpers on `UI.body` are only available in the `` tags of your +app. To register a global helper, use +[UI.registerHelper](#ui_registerhelper). + +Event maps on `UI.body` don't apply to elements added to the body via +`UI.insert`, jQuery, or the DOM API, or to the body element itself. +To handle events on the body, window, or document, use jQuery or the +DOM API. + {{> api_box ui_render}} This returns an "rendered template" object, which can be passed to @@ -2238,7 +2253,15 @@ changes. {{> api_box ui_getelementdata}} +{{> api_box ui_dynamic}} +`UI.dynamic` allows you to include a template by name, where the name +may be calculated by a helper and may change reactively. The `data` +argument is optional, and if it is omitted, the current data context +is used. + +For example, if there is a template named "foo", `{{dstache}}> UI.dynamic +template="foo"}}` is equivalent to `{{dstache}}> foo}}`. {{#api_box eventmaps}} @@ -2281,8 +2304,8 @@ Example: // Fires when any element with the 'accept' class is clicked 'click .accept': function (event) { ... }, - // Fires when 'accept' is clicked, or a key is pressed - 'keydown, click .accept': function (event) { ... } + // Fires when 'accept' is clicked or focused, or a key is pressed + 'click .accept, focus .accept, keypress': function (event) { ... } } Most events bubble up the document tree from their originating diff --git a/docs/client/api.js b/docs/client/api.js index a4c9a3a828..cc49d6fd32 100644 --- a/docs/client/api.js +++ b/docs/client/api.js @@ -1186,6 +1186,11 @@ Template.api.accounts_ui_config = { type: "Object", descr: "To ask the user for permission to act on their behalf when offline, map the relevant external service to `true`. Currently only supported with Google. See [Meteor.loginWithExternalService](#meteor_loginwithexternalservice) for more details." }, + { + name: "forceApprovalPrompt", + type: "Boolean", + descr: "If true, forces the user to approve the app's permissions, even if previously approved. Currently only supported with Google." + }, { name: "passwordSignupFields", type: "String", @@ -1845,6 +1850,17 @@ Template.api.template_data = { descr: ["The data context of this instance's latest invocation."] }; +Template.api.template_autorun = { + id: "template_autorun", + name: "this.autorun(runFunc)", + locus: "Client", + descr: ["A version of [Deps.autorun](#deps_autorun) that is stopped when the template is destroyed."], + args: [ + {name: "runFunc", + type: "Function", + descr: "The function to run. It receives one argument: a Deps.Computation object."} + ] +}; Template.api.ui_registerhelper = { id: "ui_registerhelper", @@ -1862,6 +1878,22 @@ Template.api.ui_registerhelper = { }] }; +Template.api.ui_dynamic = { + id: "ui_dynamic", + name: "{{> UI.dynamic template=templateName [data=dataContext]}}", + locus: "Client", + descr: ["Choose a template to include dynamically, by name."], + args: [ + {name: "templateName", + type: "String", + descr: "The name of the template to include." + }, + {name: "dataContext", + type: "Object", + descr: "Optional. The data context in which to include the template." + }] +}; + Template.api.ui_body = { id: "ui_body", name: "UI.body", diff --git a/docs/client/concepts.html b/docs/client/concepts.html index 1ef9bbfd4b..ea2f5809e6 100644 --- a/docs/client/concepts.html +++ b/docs/client/concepts.html @@ -33,7 +33,7 @@ packages that most any app will use (for example `webapp`, which handles incoming HTTP connections, and `templating`, which lets you make HTML templates that automatically update live as data changes). Then there are optional packages like `email`, which lets your app -send emails, or the Meteor Accounts series (`account-password`, +send emails, or the Meteor Accounts series (`accounts-password`, `accounts-facebook`, `accounts-ui`, and others) which provide a full-featured user account system that you can drop right into your app. And beyond these "official" packages, there are hundreds of diff --git a/docs/client/docs.js b/docs/client/docs.js index bb3fc8eec8..f76df144bf 100644 --- a/docs/client/docs.js +++ b/docs/client/docs.js @@ -253,7 +253,8 @@ var toc = [ {instance: "this", name: "find", id: "template_find"}, {instance: "this", name: "firstNode", id: "template_firstNode"}, {instance: "this", name: "lastNode", id: "template_lastNode"}, - {instance: "this", name: "data", id: "template_data"} + {instance: "this", name: "data", id: "template_data"}, + {instance: "this", name: "autorun", id: "template_autorun"} ], "UI", [ "UI.registerHelper", @@ -262,7 +263,8 @@ var toc = [ "UI.renderWithData", "UI.insert", "UI.remove", - "UI.getElementData" + "UI.getElementData", + {name: "{{> UI.dynamic}}", id: "ui_dynamic"} ], {type: "spacer"}, {name: "Event maps", style: "noncode"} diff --git a/docs/lib/release-override.js b/docs/lib/release-override.js index d0fc420710..6bfbd0e614 100644 --- a/docs/lib/release-override.js +++ b/docs/lib/release-override.js @@ -1,5 +1,5 @@ // While galaxy apps are on their own special meteor releases, override // Meteor.release here. if (Meteor.isClient) { - Meteor.release = Meteor.release ? "0.8.2" : undefined; + Meteor.release = Meteor.release ? "0.8.3" : undefined; } diff --git a/examples/clock/.meteor/release b/examples/clock/.meteor/release index 100435be13..ee94dd834b 100644 --- a/examples/clock/.meteor/release +++ b/examples/clock/.meteor/release @@ -1 +1 @@ -0.8.2 +0.8.3 diff --git a/examples/leaderboard/.meteor/release b/examples/leaderboard/.meteor/release index 100435be13..ee94dd834b 100644 --- a/examples/leaderboard/.meteor/release +++ b/examples/leaderboard/.meteor/release @@ -1 +1 @@ -0.8.2 +0.8.3 diff --git a/examples/parties/.meteor/release b/examples/parties/.meteor/release index 100435be13..ee94dd834b 100644 --- a/examples/parties/.meteor/release +++ b/examples/parties/.meteor/release @@ -1 +1 @@ -0.8.2 +0.8.3 diff --git a/examples/todos/.meteor/release b/examples/todos/.meteor/release index 100435be13..ee94dd834b 100644 --- a/examples/todos/.meteor/release +++ b/examples/todos/.meteor/release @@ -1 +1 @@ -0.8.2 +0.8.3 diff --git a/examples/unfinished/benchmark/benchmark.js b/examples/unfinished/benchmark/benchmark.js index abbbde6ad8..1a206ea094 100644 --- a/examples/unfinished/benchmark/benchmark.js +++ b/examples/unfinished/benchmark/benchmark.js @@ -35,6 +35,16 @@ var randomString = function (length) { return ret; }; +var preCall = function (name) { + console.log('> ' + name); +}; + +var postCall = function (name) { + return function (err, callback) { + console.log('< ' + name + ' ' + (err ? 'ERR' : 'OK')); + }; +}; + var pickCollection = function () { return Random.choice(Collections); }; @@ -96,7 +106,8 @@ if (Meteor.isServer) { Meteor.setInterval(function () { var when = +(new Date) - PARAMS.maxAgeSeconds*1000; _.each(Collections, function (C) { - C.remove({when: {$lt: when}}); + preCall('removeMaxAge'); + C.remove({when: {$lt: when}}, postCall('removeMaxAge')); }); // Clear out 5% of the DB each time, steady state. XXX parameterize? }, 1000*PARAMS.maxAgeSeconds / 20); @@ -121,7 +132,8 @@ if (Meteor.isServer) { doc.when = +(new Date); var C = pickCollection(); - C.insert(doc); + preCall('insert'); + C.insert(doc, postCall('insert')); }, update: function (processId, field, value) { check([processId, field, value], [String]); @@ -130,15 +142,18 @@ if (Meteor.isServer) { var C = pickCollection(); // update one message. - C.update({fromProcess: processId}, {$set: modifer}, {multi: false}); + preCall('update'); + C.update({fromProcess: processId}, {$set: modifer}, {multi: false}, postCall('update')); }, remove: function (processId) { check(processId, String); var C = pickCollection(); // remove one message. var obj = C.findOne({fromProcess: processId}); - if (obj) - C.remove(obj._id); + if (obj) { + preCall('remove'); + C.remove(obj._id, postCall('remove')); + } } }); diff --git a/examples/unfinished/benchmark/run-local.sh b/examples/unfinished/benchmark/run-local.sh index 3c533ad54a..9fecfcaba9 100755 --- a/examples/unfinished/benchmark/run-local.sh +++ b/examples/unfinished/benchmark/run-local.sh @@ -1,8 +1,12 @@ #!/bin/bash PORT=9000 -NUM_CLIENTS=10 -DURATION=120 +if [ -z "$NUM_CLIENTS" ]; then + NUM_CLIENTS=10 +fi +if [ -z "$DURATION" ]; then + DURATION=120 +fi REPORT_INTERVAL=10 set -e @@ -20,7 +24,7 @@ pkill -f "$PROJDIR/.meteor/local/db" || true ../../../meteor reset || true # start the benchmark app -../../../meteor --production --settings "scenarios/${SCENARIO}.json" --port 9000 & +../../../meteor --production --settings "scenarios/${SCENARIO}.json" --port ${PORT} & OUTER_PID=$! echo "Waiting for server to come up" @@ -30,12 +34,13 @@ function wait_for_port { sleep 1 N=$(($N+1)) if [ $N -ge $2 ] ; then + curl -v "$1" || true echo "Timed out waiting for port $1" exit 2 fi done } -wait_for_port "http://localhost:9001" 60 +wait_for_port "http://localhost:${PORT}" 60 echo "Starting phantoms" diff --git a/examples/unfinished/benchmark/scenarios/scale10.json b/examples/unfinished/benchmark/scenarios/scale10.json new file mode 100644 index 0000000000..5962540e04 --- /dev/null +++ b/examples/unfinished/benchmark/scenarios/scale10.json @@ -0,0 +1,11 @@ + { + "params": { + "numCollections": 1, + "maxAgeSeconds": 60, + "insertsPerSecond": 10, + "updatesPerSecond": 10, + "removesPerSecond": 1, + "documentSize": 128, + "documentNumFields": 2 + } +} diff --git a/examples/unfinished/benchmark/scenarios/scale100.json b/examples/unfinished/benchmark/scenarios/scale100.json new file mode 100644 index 0000000000..ec0f4853e9 --- /dev/null +++ b/examples/unfinished/benchmark/scenarios/scale100.json @@ -0,0 +1,11 @@ + { + "params": { + "numCollections": 1, + "maxAgeSeconds": 60, + "insertsPerSecond": 100, + "updatesPerSecond": 100, + "removesPerSecond": 10, + "documentSize": 128, + "documentNumFields": 2 + } +} diff --git a/examples/unfinished/benchmark/scenarios/scale20.json b/examples/unfinished/benchmark/scenarios/scale20.json new file mode 100644 index 0000000000..db357f2994 --- /dev/null +++ b/examples/unfinished/benchmark/scenarios/scale20.json @@ -0,0 +1,11 @@ + { + "params": { + "numCollections": 1, + "maxAgeSeconds": 60, + "insertsPerSecond": 20, + "updatesPerSecond": 20, + "removesPerSecond": 2, + "documentSize": 128, + "documentNumFields": 2 + } +} diff --git a/examples/unfinished/benchmark/scenarios/scale40.json b/examples/unfinished/benchmark/scenarios/scale40.json new file mode 100644 index 0000000000..53cab32bd1 --- /dev/null +++ b/examples/unfinished/benchmark/scenarios/scale40.json @@ -0,0 +1,11 @@ + { + "params": { + "numCollections": 1, + "maxAgeSeconds": 60, + "insertsPerSecond": 40, + "updatesPerSecond": 40, + "removesPerSecond": 4, + "documentSize": 128, + "documentNumFields": 2 + } +} diff --git a/examples/unfinished/benchmark/scenarios/scale50.json b/examples/unfinished/benchmark/scenarios/scale50.json new file mode 100644 index 0000000000..0e4df53c12 --- /dev/null +++ b/examples/unfinished/benchmark/scenarios/scale50.json @@ -0,0 +1,11 @@ + { + "params": { + "numCollections": 1, + "maxAgeSeconds": 60, + "insertsPerSecond": 50, + "updatesPerSecond": 50, + "removesPerSecond": 5, + "documentSize": 128, + "documentNumFields": 2 + } +} diff --git a/examples/wordplay/.meteor/release b/examples/wordplay/.meteor/release index 100435be13..ee94dd834b 100644 --- a/examples/wordplay/.meteor/release +++ b/examples/wordplay/.meteor/release @@ -1 +1 @@ -0.8.2 +0.8.3 diff --git a/packages/accounts-ui-unstyled/accounts_ui.js b/packages/accounts-ui-unstyled/accounts_ui.js index 5f241ab997..6a0f0851c2 100644 --- a/packages/accounts-ui-unstyled/accounts_ui.js +++ b/packages/accounts-ui-unstyled/accounts_ui.js @@ -2,12 +2,14 @@ Accounts.ui = {}; Accounts.ui._options = { requestPermissions: {}, - requestOfflineToken: {} + requestOfflineToken: {}, + forceApprovalPrompt: {} }; +// XXX refactor duplicated code in this function Accounts.ui.config = function(options) { // validate options keys - var VALID_KEYS = ['passwordSignupFields', 'requestPermissions', 'requestOfflineToken']; + var VALID_KEYS = ['passwordSignupFields', 'requestPermissions', 'requestOfflineToken', 'forceApprovalPrompt']; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) throw new Error("Accounts.ui.config: Invalid key: " + key); @@ -56,6 +58,20 @@ Accounts.ui.config = function(options) { } }); } + + // deal with `forceApprovalPrompt` + if (options.forceApprovalPrompt) { + _.each(options.forceApprovalPrompt, function (value, service) { + if (service !== 'google') + throw new Error("Accounts.ui.config: `forceApprovalPrompt` only supported for Google login at the moment."); + + if (Accounts.ui._options.forceApprovalPrompt[service]) { + throw new Error("Accounts.ui.config: Can't set `forceApprovalPrompt` more than once for " + service); + } else { + Accounts.ui._options.forceApprovalPrompt[service] = value; + } + }); + } }; passwordSignupFields = function () { diff --git a/packages/accounts-ui-unstyled/accounts_ui_tests.js b/packages/accounts-ui-unstyled/accounts_ui_tests.js index a8f4fc40f5..5e4bef9ea0 100644 --- a/packages/accounts-ui-unstyled/accounts_ui_tests.js +++ b/packages/accounts-ui-unstyled/accounts_ui_tests.js @@ -5,7 +5,7 @@ // XXX it'd be cool to also test that the right thing happens if options -// *are* validated, but Accouns.ui._options is global state which makes this hard +// *are* validated, but Accounts.ui._options is global state which makes this hard // (impossible?) Tinytest.add('accounts-ui - config validates keys', function (test) { test.throws(function () { @@ -19,4 +19,8 @@ Tinytest.add('accounts-ui - config validates keys', function (test) { test.throws(function () { Accounts.ui.config({requestPermissions: {facebook: "not an array"}}); }); + + test.throws(function () { + Accounts.ui.config({forceApprovalPrompt: {facebook: "only google"}}); + }); }); diff --git a/packages/accounts-ui-unstyled/login_buttons_single.js b/packages/accounts-ui-unstyled/login_buttons_single.js index 79d35a4190..96e1e65624 100644 --- a/packages/accounts-ui-unstyled/login_buttons_single.js +++ b/packages/accounts-ui-unstyled/login_buttons_single.js @@ -29,6 +29,8 @@ Template._loginButtonsLoggedOutSingleLoginButton.events({ options.requestPermissions = Accounts.ui._options.requestPermissions[serviceName]; if (Accounts.ui._options.requestOfflineToken[serviceName]) options.requestOfflineToken = Accounts.ui._options.requestOfflineToken[serviceName]; + if (Accounts.ui._options.forceApprovalPrompt[serviceName]) + options.forceApprovalPrompt = Accounts.ui._options.forceApprovalPrompt[serviceName]; loginWithService(options, callback); } diff --git a/packages/blaze/builtins.js b/packages/blaze/builtins.js index 524171af48..d65b33b2a2 100644 --- a/packages/blaze/builtins.js +++ b/packages/blaze/builtins.js @@ -169,6 +169,7 @@ Blaze.InOuterTemplateScope = function (templateView, contentFunc) { parentView = parentView.parentView; view.onCreated(function () { + this.originalParentView = this.parentView; this.parentView = parentView; }); return view; diff --git a/packages/blaze/domrange.js b/packages/blaze/domrange.js index 055e71ec52..ba71fc20d3 100644 --- a/packages/blaze/domrange.js +++ b/packages/blaze/domrange.js @@ -29,6 +29,28 @@ Blaze.DOMRange = function (nodeAndRangeArray) { }; var DOMRange = Blaze.DOMRange; +// In IE 8, don't use empty text nodes as placeholders +// in empty DOMRanges, use comment nodes instead. Using +// empty text nodes in modern browsers is great because +// it doesn't clutter the web inspector. In IE 8, however, +// it seems to lead in some roundabout way to the OAuth +// pop-up crashing the browser completely. In the past, +// we didn't use empty text nodes on IE 8 because they +// don't accept JS properties, so just use the same logic +// even though we don't need to set properties on the +// placeholder anymore. +DOMRange._USE_COMMENT_PLACEHOLDERS = (function () { + var result = false; + var textNode = document.createTextNode(""); + try { + textNode.someProp = true; + } catch (e) { + // IE 8 + result = true; + } + return result; +})(); + // static methods DOMRange._insert = function (rangeOrNode, parentElement, nextNode, _isMove) { var m = rangeOrNode; @@ -118,7 +140,10 @@ DOMRange.prototype.attach = function (parentElement, nextNode, _isMove) { DOMRange._insert(members[i], parentElement, nextNode, _isMove); } } else { - var placeholder = document.createTextNode(""); + var placeholder = ( + DOMRange._USE_COMMENT_PLACEHOLDERS ? + document.createComment("") : + document.createTextNode("")); this.emptyRangePlaceholder = placeholder; parentElement.insertBefore(placeholder, nextNode || null); } diff --git a/packages/blaze/lookup.js b/packages/blaze/lookup.js index 31f5701fde..3bc97a5f6b 100644 --- a/packages/blaze/lookup.js +++ b/packages/blaze/lookup.js @@ -6,7 +6,9 @@ var bindIfIsFunction = function (x, target) { }; }; -var bindToCurrentDataIfIsFunction = function (x) { +// If `x` is a function, binds the value of `this` for that function +// to the current data context. +var bindDataContext = function (x) { if (typeof x === 'function') { return function () { var data = Blaze.getCurrentData(); @@ -22,6 +24,8 @@ var wrapHelper = function (f) { return Blaze.wrapCatchingExceptions(f, 'template helper'); }; +// !!! FIX THIS COMMENT !!! +// // Implements {{foo}} where `name` is "foo" // and `component` is the component the tag is found in // (the lexical "self," on which to look for methods). @@ -45,11 +49,11 @@ Blaze.View.prototype.lookup = function (name, _options) { return Blaze._parentData(name.length - 1, true /*_functionWrapped*/); } else if (template && (name in template)) { - return wrapHelper(bindToCurrentDataIfIsFunction(template[name])); + return wrapHelper(bindDataContext(template[name])); } else if (lookupTemplate && Template.__lookup__(name)) { return Template.__lookup__(name); } else if (UI._globalHelpers[name]) { - return wrapHelper(bindToCurrentDataIfIsFunction(UI._globalHelpers[name])); + return wrapHelper(bindDataContext(UI._globalHelpers[name])); } else { return function () { var isCalledAsFunction = (arguments.length > 0); diff --git a/packages/blaze/materializer.js b/packages/blaze/materializer.js index 84b95003dd..fc71f11f66 100644 --- a/packages/blaze/materializer.js +++ b/packages/blaze/materializer.js @@ -49,13 +49,19 @@ Blaze.DOMMaterializer.def({ var rawAttrs = tag.attrs; var children = tag.children; - if (tagName === 'textarea' && ! (rawAttrs && ('value' in rawAttrs))) { - // turn TEXTAREA contents into a value attribute. - // Reactivity in the form of nested Views won't work here - // because the Views have already been instantiated. To - // get Views in a textarea they need to be wrapped in a - // function and provided as the "value" attribute by the - // compiler. + if (tagName === 'textarea' && tag.children.length && + ! (rawAttrs && ('value' in rawAttrs))) { + // Provide very limited support for TEXTAREA tags with children + // rather than a "value" attribute. + // Reactivity in the form of Views nested in the tag's children + // won't work. Compilers should compile textarea contents into + // the "value" attribute of the tag, wrapped in a function if there + // is reactivity. + if (typeof rawAttrs === 'function' || + HTML.isArray(rawAttrs)) { + throw new Error("Can't have reactive children of TEXTAREA node; " + + "use the 'value' attribute instead."); + } rawAttrs = _.extend({}, rawAttrs || null); rawAttrs.value = Blaze._expand(children, self.parentView); children = []; diff --git a/packages/blaze/view.js b/packages/blaze/view.js index 9b4e703e91..7597f97a63 100644 --- a/packages/blaze/view.js +++ b/packages/blaze/view.js @@ -312,14 +312,21 @@ Blaze.HTMLJSExpander.def({ } }); +// Return Blaze.currentView, but only if it is being rendered +// (i.e. we are in its render() method). +var currentViewIfRendering = function () { + var view = Blaze.currentView; + return (view && view.isInRender) ? view : null; +}; + Blaze._expand = function (htmljs, parentView) { - parentView = parentView || Blaze.currentView; + parentView = parentView || currentViewIfRendering(); return (new Blaze.HTMLJSExpander( {parentView: parentView})).visit(htmljs); }; Blaze._expandAttributes = function (attrs, parentView) { - parentView = parentView || Blaze.currentView; + parentView = parentView || currentViewIfRendering(); return (new Blaze.HTMLJSExpander( {parentView: parentView})).visitAttributes(attrs); }; @@ -383,7 +390,7 @@ Blaze.runTemplate = function (t/*, args*/) { }; Blaze.render = function (content, parentView) { - parentView = parentView || Blaze.currentView; + parentView = parentView || currentViewIfRendering(); var view; if (typeof content === 'function') { @@ -401,7 +408,7 @@ Blaze.render = function (content, parentView) { Blaze.toHTML = function (htmljs, parentView) { if (typeof htmljs === 'function') throw new Error("Blaze.toHTML doesn't take a function, just HTMLjs"); - parentView = parentView || Blaze.currentView; + parentView = parentView || currentViewIfRendering(); return HTML.toHTML(Blaze._expand(htmljs, parentView)); }; @@ -414,7 +421,7 @@ Blaze.toText = function (htmljs, parentView, textMode) { textMode = parentView; parentView = null; } - parentView = parentView || Blaze.currentView; + parentView = parentView || currentViewIfRendering(); if (! textMode) throw new Error("textMode required"); @@ -529,7 +536,11 @@ Blaze._addEventMap = function (view, eventMap, thisInHandler) { function (evt) { if (! range.containsElement(evt.currentTarget)) return null; - return handler.apply(thisInHandler || this, arguments); + var handlerThis = thisInHandler || this; + var handlerArgs = arguments; + return Blaze.withCurrentView(view, function () { + return handler.apply(handlerThis, handlerArgs); + }); }, range, function (r) { return r.parentRange; diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 0ff5dd81f3..e7b00b6fde 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -1456,6 +1456,16 @@ var wrapInternalException = function (exception, context) { if (!exception || exception instanceof Meteor.Error) return exception; + // tests can set the 'expected' flag on an exception so it won't go to the + // server log + if (!exception.expected) { + Meteor._debug("Exception " + context, exception.stack); + if (exception.sanitizedError) { + Meteor._debug("Sanitized and reported to the client as:", exception.sanitizedError.message); + Meteor._debug(); + } + } + // Did the error contain more details that could have been useful if caught in // server code (or if thrown from non-client-originated code), but also // provided a "sanitized" version with more context than 500 Internal server @@ -1467,11 +1477,6 @@ var wrapInternalException = function (exception, context) { "is not a Meteor.Error; ignoring"); } - // tests can set the 'expected' flag on an exception so it won't go to the - // server log - if (!exception.expected) - Meteor._debug("Exception " + context, exception.stack); - return new Meteor.Error(500, "Internal server error"); }; diff --git a/packages/livedata/livedata_tests.js b/packages/livedata/livedata_tests.js index 07bd42e779..6cf075a703 100644 --- a/packages/livedata/livedata_tests.js +++ b/packages/livedata/livedata_tests.js @@ -526,16 +526,18 @@ if (Meteor.isClient) { ]); testAsyncMulti("livedata - publisher errors", (function () { - // Use a separate connection so that we can safely check to see if - // conn._subscriptions is empty. - var conn = new LivedataTest.Connection('/', - {reloadWithOutstanding: true}); - var collName = Random.id(); - var coll = new Meteor.Collection(collName, {connection: conn}); + var conn, collName, coll; var errorFromRerun; var gotErrorFromStopper = false; return [ function (test, expect) { + // Use a separate connection so that we can safely check to see if + // conn._subscriptions is empty. + conn = new LivedataTest.Connection('/', + {reloadWithOutstanding: true}); + collName = Random.id(); + coll = new Meteor.Collection(collName, {connection: conn}); + var testSubError = function (options) { conn.subscribe("publisherErrors", collName, options, { onReady: expect(), diff --git a/packages/livedata/stream_client_nodejs.js b/packages/livedata/stream_client_nodejs.js index 718ee3fe41..a473fc5059 100644 --- a/packages/livedata/stream_client_nodejs.js +++ b/packages/livedata/stream_client_nodejs.js @@ -54,7 +54,7 @@ _.extend(LivedataTest.ClientStream.prototype, { // But _launchConnection calls _cleanup which closes previous connections. // It's our belief that this stifles future 'open' events, but maybe // we are wrong? - throw new Error("Got open from inactive client"); + throw new Error("Got open from inactive client " + !!self.client); } if (self._forcedToDisconnect) { diff --git a/packages/meteor/fiber_stubs_client.js b/packages/meteor/fiber_stubs_client.js index 3babd0e08f..c11118d0a6 100644 --- a/packages/meteor/fiber_stubs_client.js +++ b/packages/meteor/fiber_stubs_client.js @@ -14,6 +14,7 @@ Meteor._SynchronousQueue = function () { var self = this; self._tasks = []; self._running = false; + self._runTimeout = null; }; _.extend(Meteor._SynchronousQueue.prototype, { @@ -25,6 +26,15 @@ _.extend(Meteor._SynchronousQueue.prototype, { var tasks = self._tasks; self._tasks = []; self._running = true; + + if (self._runTimeout) { + // Since we're going to drain the queue, we can forget about the timeout + // which tries to run it. (But if one of our tasks queues something else, + // the timeout will be correctly re-created.) + clearTimeout(self._runTimeout); + self._runTimeout = null; + } + try { while (!_.isEmpty(tasks)) { var t = tasks.shift(); @@ -47,12 +57,12 @@ _.extend(Meteor._SynchronousQueue.prototype, { queueTask: function (task) { var self = this; - var wasEmpty = _.isEmpty(self._tasks); self._tasks.push(task); // Intentionally not using Meteor.setTimeout, because it doesn't like runing // in stubs for now. - if (wasEmpty) - setTimeout(_.bind(self.flush, self), 0); + if (!self._runTimeout) { + self._runTimeout = setTimeout(_.bind(self.flush, self), 0); + } }, flush: function () { diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index d9c13dff08..25fc10f8b6 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -339,6 +339,21 @@ _.extend(LocalCollection.Cursor.prototype, { query.movedBefore = wrapCallback(options.movedBefore); } + if (!options._suppress_initial && !self.collection.paused) { + // XXX unify ordered and unordered interface + var each = ordered + ? _.bind(_.each, null, query.results) + : _.bind(query.results.forEach, query.results); + each(function (doc) { + var fields = EJSON.clone(doc); + + delete fields._id; + if (ordered) + query.addedBefore(doc._id, fields, null); + query.added(doc._id, fields); + }); + } + var handle = new LocalCollection.ObserveHandle; _.extend(handle, { collection: self.collection, @@ -358,30 +373,9 @@ _.extend(LocalCollection.Cursor.prototype, { handle.stop(); }); } - - if (!options._suppress_initial && !self.collection.paused) { - // XXX unify ordered and unordered interface - var each = ordered - ? _.bind(_.each, null, query.results) - : _.bind(query.results.forEach, query.results); - each(function (doc) { - var fields = EJSON.clone(doc); - - delete fields._id; - if (ordered) - query.addedBefore(doc._id, fields, null); - query.added(doc._id, fields); - }); - - // run the observe callbacks resulting from the initial contents - // before we leave the observe. - if (self.collection._observeQueue.safeToRunTask()) { - self.collection._observeQueue.drain(); - } else if (options.added || options.addedBefore) { - // See #2315. - throw Error("observeChanges called from an observe callback on the same collection cannot differentiate between initial and later adds"); - } - } + // run the observe callbacks resulting from the initial contents + // before we leave the observe. + self.collection._observeQueue.drain(); return handle; } diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 90b43cb094..c7eaba4acb 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -3109,28 +3109,3 @@ Tinytest.add("minimongo - fetch in observe", function (test) { observe.stop(); computation.stop(); }); - -Tinytest.add("minimongo - observe in observe", function (test) { - var coll = new LocalCollection; - coll.insert({foo: 2}); - - var observe1AddedCalled = false; - var observe1 = coll.find({foo: 1}).observeChanges({ - added: function (id, fields) { - observe1AddedCalled = true; - test.equal(fields, {foo: 1}); - - // It would be even better if this didn't throw; see #2315. - test.throws(function () { - coll.find({foo: 2}).observeChanges({ - added: function () { - } - }); - }); - } - }); - test.isFalse(observe1AddedCalled); - coll.insert({foo: 1}); - test.isTrue(observe1AddedCalled); - observe1.stop(); -}); diff --git a/packages/minimongo/observe.js b/packages/minimongo/observe.js index 38bcb4514c..191b78333f 100644 --- a/packages/minimongo/observe.js +++ b/packages/minimongo/observe.js @@ -159,7 +159,8 @@ LocalCollection._observeFromObserveChanges = function (cursor, observeCallbacks) var oldDoc = self.docs.get(id); var doc = EJSON.clone(oldDoc); LocalCollection._applyChanges(doc, fields); - observeCallbacks.changed(transform(doc), transform(oldDoc)); + observeCallbacks.changed(transform(doc), + transform(EJSON.clone(oldDoc))); } }, removed: function (id) { @@ -176,32 +177,5 @@ LocalCollection._observeFromObserveChanges = function (cursor, observeCallbacks) var handle = cursor.observeChanges(changeObserver.applyChange); suppressed = false; - if (changeObserver.ordered) { - // Fetches the current list of documents, in order, as an array. Can be - // called at any time. Internal API assumed by the `observe-sequence` - // package (used by Meteor UI for `#each` blocks). Only defined on ordered - // observes (those that listen on `addedAt` or similar). Continues to work - // after `stop()` is called on the handle. - // - // Because we already materialize the full OrderedDict of all documents, it - // seems nice to provide access to the view rather than making the data - // consumer reconstitute it. This gives the consumer a shot at doing - // something smart with the feed like proxying it, since firing callbacks - // like `changed` and `movedTo` basically requires omniscience (knowing old - // and new documents, old and new indices, and the correct value for - // `before`). - // - // NOTE: If called from an observe callback for a certain change, the result - // is *not* guaranteed to be a snapshot of the cursor up to that - // change. This is because the callbacks are invoked before updating docs. - handle._fetch = function () { - var docsArray = []; - changeObserver.docs.forEach(function (doc) { - docsArray.push(transform(EJSON.clone(doc))); - }); - return docsArray; - }; - } - return handle; }; diff --git a/packages/mongo-livedata/oplog_observe_driver.js b/packages/mongo-livedata/oplog_observe_driver.js index ac03d24cb0..1ba2e56d49 100644 --- a/packages/mongo-livedata/oplog_observe_driver.js +++ b/packages/mongo-livedata/oplog_observe_driver.js @@ -168,411 +168,453 @@ OplogObserveDriver = function (options) { _.extend(OplogObserveDriver.prototype, { _addPublished: function (id, doc) { var self = this; - var fields = _.clone(doc); - delete fields._id; - self._published.set(id, self._sharedProjectionFn(doc)); - self._multiplexer.added(id, self._projectionFn(fields)); + Meteor._noYieldsAllowed(function () { + var fields = _.clone(doc); + delete fields._id; + self._published.set(id, self._sharedProjectionFn(doc)); + self._multiplexer.added(id, self._projectionFn(fields)); - // After adding this document, the published set might be overflowed - // (exceeding capacity specified by limit). If so, push the maximum element - // to the buffer, we might want to save it in memory to reduce the amount of - // Mongo lookups in the future. - if (self._limit && self._published.size() > self._limit) { - // XXX in theory the size of published is no more than limit+1 - if (self._published.size() !== self._limit + 1) { - throw new Error("After adding to published, " + - (self._published.size() - self._limit) + - " documents are overflowing the set"); + // After adding this document, the published set might be overflowed + // (exceeding capacity specified by limit). If so, push the maximum + // element to the buffer, we might want to save it in memory to reduce the + // amount of Mongo lookups in the future. + if (self._limit && self._published.size() > self._limit) { + // XXX in theory the size of published is no more than limit+1 + if (self._published.size() !== self._limit + 1) { + throw new Error("After adding to published, " + + (self._published.size() - self._limit) + + " documents are overflowing the set"); + } + + var overflowingDocId = self._published.maxElementId(); + var overflowingDoc = self._published.get(overflowingDocId); + + if (EJSON.equals(overflowingDocId, id)) { + throw new Error("The document just added is overflowing the published set"); + } + + self._published.remove(overflowingDocId); + self._multiplexer.removed(overflowingDocId); + self._addBuffered(overflowingDocId, overflowingDoc); } - - var overflowingDocId = self._published.maxElementId(); - var overflowingDoc = self._published.get(overflowingDocId); - - if (EJSON.equals(overflowingDocId, id)) { - throw new Error("The document just added is overflowing the published set"); - } - - self._published.remove(overflowingDocId); - self._multiplexer.removed(overflowingDocId); - self._addBuffered(overflowingDocId, overflowingDoc); - } + }); }, _removePublished: function (id) { var self = this; - self._published.remove(id); - self._multiplexer.removed(id); - if (! self._limit || self._published.size() === self._limit) - return; + Meteor._noYieldsAllowed(function () { + self._published.remove(id); + self._multiplexer.removed(id); + if (! self._limit || self._published.size() === self._limit) + return; - if (self._published.size() > self._limit) - throw Error("self._published got too big"); + if (self._published.size() > self._limit) + throw Error("self._published got too big"); - // OK, we are publishing less than the limit. Maybe we should look in the - // buffer to find the next element past what we were publishing before. + // OK, we are publishing less than the limit. Maybe we should look in the + // buffer to find the next element past what we were publishing before. - if (!self._unpublishedBuffer.empty()) { - // There's something in the buffer; move the first thing in it to - // _published. - var newDocId = self._unpublishedBuffer.minElementId(); - var newDoc = self._unpublishedBuffer.get(newDocId); - self._removeBuffered(newDocId); - self._addPublished(newDocId, newDoc); - return; - } + if (!self._unpublishedBuffer.empty()) { + // There's something in the buffer; move the first thing in it to + // _published. + var newDocId = self._unpublishedBuffer.minElementId(); + var newDoc = self._unpublishedBuffer.get(newDocId); + self._removeBuffered(newDocId); + self._addPublished(newDocId, newDoc); + return; + } - // There's nothing in the buffer. This could mean one of a few things. + // There's nothing in the buffer. This could mean one of a few things. - // (a) We could be in the middle of re-running the query (specifically, we - // could be in _publishNewResults). In that case, _unpublishedBuffer is - // empty because we clear it at the beginning of _publishNewResults. In this - // case, our caller already knows the entire answer to the query and we - // don't need to do anything fancy here. Just return. - if (self._phase === PHASE.QUERYING) - return; + // (a) We could be in the middle of re-running the query (specifically, we + // could be in _publishNewResults). In that case, _unpublishedBuffer is + // empty because we clear it at the beginning of _publishNewResults. In + // this case, our caller already knows the entire answer to the query and + // we don't need to do anything fancy here. Just return. + if (self._phase === PHASE.QUERYING) + return; - // (b) We're pretty confident that the union of _published and - // _unpublishedBuffer contain all documents that match selector. Because - // _unpublishedBuffer is empty, that means we're confident that _published - // contains all documents that match selector. So we have nothing to do. - if (self._safeAppendToBuffer) - return; + // (b) We're pretty confident that the union of _published and + // _unpublishedBuffer contain all documents that match selector. Because + // _unpublishedBuffer is empty, that means we're confident that _published + // contains all documents that match selector. So we have nothing to do. + if (self._safeAppendToBuffer) + return; - // (c) Maybe there are other documents out there that should be in our - // buffer. But in that case, when we emptied _unpublishedBuffer in - // _removeBuffered, we should have called _needToPollQuery, which will - // either put something in _unpublishedBuffer or set _safeAppendToBuffer (or - // both), and it will put us in QUERYING for that whole time. So in fact, we - // shouldn't be able to get here. + // (c) Maybe there are other documents out there that should be in our + // buffer. But in that case, when we emptied _unpublishedBuffer in + // _removeBuffered, we should have called _needToPollQuery, which will + // either put something in _unpublishedBuffer or set _safeAppendToBuffer + // (or both), and it will put us in QUERYING for that whole time. So in + // fact, we shouldn't be able to get here. - throw new Error("Buffer inexplicably empty"); + throw new Error("Buffer inexplicably empty"); + }); }, _changePublished: function (id, oldDoc, newDoc) { var self = this; - self._published.set(id, self._sharedProjectionFn(newDoc)); - var changed = LocalCollection._makeChangedFields(_.clone(newDoc), oldDoc); - changed = self._projectionFn(changed); - if (!_.isEmpty(changed)) - self._multiplexer.changed(id, changed); + Meteor._noYieldsAllowed(function () { + self._published.set(id, self._sharedProjectionFn(newDoc)); + var changed = LocalCollection._makeChangedFields(_.clone(newDoc), oldDoc); + changed = self._projectionFn(changed); + if (!_.isEmpty(changed)) + self._multiplexer.changed(id, changed); + }); }, _addBuffered: function (id, doc) { var self = this; - self._unpublishedBuffer.set(id, self._sharedProjectionFn(doc)); + Meteor._noYieldsAllowed(function () { + self._unpublishedBuffer.set(id, self._sharedProjectionFn(doc)); - // If something is overflowing the buffer, we just remove it from cache - if (self._unpublishedBuffer.size() > self._limit) { - var maxBufferedId = self._unpublishedBuffer.maxElementId(); + // If something is overflowing the buffer, we just remove it from cache + if (self._unpublishedBuffer.size() > self._limit) { + var maxBufferedId = self._unpublishedBuffer.maxElementId(); - self._unpublishedBuffer.remove(maxBufferedId); + self._unpublishedBuffer.remove(maxBufferedId); - // Since something matching is removed from cache (both published set and - // buffer), set flag to false - self._safeAppendToBuffer = false; - } + // Since something matching is removed from cache (both published set and + // buffer), set flag to false + self._safeAppendToBuffer = false; + } + }); }, // Is called either to remove the doc completely from matching set or to move // it to the published set later. _removeBuffered: function (id) { var self = this; - self._unpublishedBuffer.remove(id); - // To keep the contract "buffer is never empty in STEADY phase unless the - // everything matching fits into published" true, we poll everything as soon - // as we see the buffer becoming empty. - if (! self._unpublishedBuffer.size() && ! self._safeAppendToBuffer) - self._needToPollQuery(); + Meteor._noYieldsAllowed(function () { + self._unpublishedBuffer.remove(id); + // To keep the contract "buffer is never empty in STEADY phase unless the + // everything matching fits into published" true, we poll everything as + // soon as we see the buffer becoming empty. + if (! self._unpublishedBuffer.size() && ! self._safeAppendToBuffer) + self._needToPollQuery(); + }); }, // Called when a document has joined the "Matching" results set. // Takes responsibility of keeping _unpublishedBuffer in sync with _published // and the effect of limit enforced. _addMatching: function (doc) { var self = this; - var id = doc._id; - if (self._published.has(id)) - throw Error("tried to add something already published " + id); - if (self._limit && self._unpublishedBuffer.has(id)) - throw Error("tried to add something already existed in buffer " + id); + Meteor._noYieldsAllowed(function () { + var id = doc._id; + if (self._published.has(id)) + throw Error("tried to add something already published " + id); + if (self._limit && self._unpublishedBuffer.has(id)) + throw Error("tried to add something already existed in buffer " + id); - var limit = self._limit; - var comparator = self._comparator; - var maxPublished = (limit && self._published.size() > 0) ? - self._published.get(self._published.maxElementId()) : null; - var maxBuffered = (limit && self._unpublishedBuffer.size() > 0) ? - self._unpublishedBuffer.get(self._unpublishedBuffer.maxElementId()) : null; - // The query is unlimited or didn't publish enough documents yet or the new - // document would fit into published set pushing the maximum element out, - // then we need to publish the doc. - var toPublish = ! limit || self._published.size() < limit || - comparator(doc, maxPublished) < 0; + var limit = self._limit; + var comparator = self._comparator; + var maxPublished = (limit && self._published.size() > 0) ? + self._published.get(self._published.maxElementId()) : null; + var maxBuffered = (limit && self._unpublishedBuffer.size() > 0) + ? self._unpublishedBuffer.get(self._unpublishedBuffer.maxElementId()) + : null; + // The query is unlimited or didn't publish enough documents yet or the + // new document would fit into published set pushing the maximum element + // out, then we need to publish the doc. + var toPublish = ! limit || self._published.size() < limit || + comparator(doc, maxPublished) < 0; - // Otherwise we might need to buffer it (only in case of limited query). - // Buffering is allowed if the buffer is not filled up yet and all matching - // docs are either in the published set or in the buffer. - var canAppendToBuffer = !toPublish && self._safeAppendToBuffer && - self._unpublishedBuffer.size() < limit; + // Otherwise we might need to buffer it (only in case of limited query). + // Buffering is allowed if the buffer is not filled up yet and all + // matching docs are either in the published set or in the buffer. + var canAppendToBuffer = !toPublish && self._safeAppendToBuffer && + self._unpublishedBuffer.size() < limit; - // Or if it is small enough to be safely inserted to the middle or the - // beginning of the buffer. - var canInsertIntoBuffer = !toPublish && maxBuffered && - comparator(doc, maxBuffered) <= 0; + // Or if it is small enough to be safely inserted to the middle or the + // beginning of the buffer. + var canInsertIntoBuffer = !toPublish && maxBuffered && + comparator(doc, maxBuffered) <= 0; - var toBuffer = canAppendToBuffer || canInsertIntoBuffer; + var toBuffer = canAppendToBuffer || canInsertIntoBuffer; - if (toPublish) { - self._addPublished(id, doc); - } else if (toBuffer) { - self._addBuffered(id, doc); - } else { - // dropping it and not saving to the cache - self._safeAppendToBuffer = false; - } + if (toPublish) { + self._addPublished(id, doc); + } else if (toBuffer) { + self._addBuffered(id, doc); + } else { + // dropping it and not saving to the cache + self._safeAppendToBuffer = false; + } + }); }, // Called when a document leaves the "Matching" results set. // Takes responsibility of keeping _unpublishedBuffer in sync with _published // and the effect of limit enforced. _removeMatching: function (id) { var self = this; - if (! self._published.has(id) && ! self._limit) - throw Error("tried to remove something matching but not cached " + id); + Meteor._noYieldsAllowed(function () { + if (! self._published.has(id) && ! self._limit) + throw Error("tried to remove something matching but not cached " + id); - if (self._published.has(id)) { - self._removePublished(id); - } else if (self._unpublishedBuffer.has(id)) { - self._removeBuffered(id); - } + if (self._published.has(id)) { + self._removePublished(id); + } else if (self._unpublishedBuffer.has(id)) { + self._removeBuffered(id); + } + }); }, _handleDoc: function (id, newDoc) { var self = this; - var matchesNow = newDoc && self._matcher.documentMatches(newDoc).result; + Meteor._noYieldsAllowed(function () { + var matchesNow = newDoc && self._matcher.documentMatches(newDoc).result; - var publishedBefore = self._published.has(id); - var bufferedBefore = self._limit && self._unpublishedBuffer.has(id); - var cachedBefore = publishedBefore || bufferedBefore; + var publishedBefore = self._published.has(id); + var bufferedBefore = self._limit && self._unpublishedBuffer.has(id); + var cachedBefore = publishedBefore || bufferedBefore; - if (matchesNow && !cachedBefore) { - self._addMatching(newDoc); - } else if (cachedBefore && !matchesNow) { - self._removeMatching(id); - } else if (cachedBefore && matchesNow) { - var oldDoc = self._published.get(id); - var comparator = self._comparator; - var minBuffered = self._limit && self._unpublishedBuffer.size() && - self._unpublishedBuffer.get(self._unpublishedBuffer.minElementId()); + if (matchesNow && !cachedBefore) { + self._addMatching(newDoc); + } else if (cachedBefore && !matchesNow) { + self._removeMatching(id); + } else if (cachedBefore && matchesNow) { + var oldDoc = self._published.get(id); + var comparator = self._comparator; + var minBuffered = self._limit && self._unpublishedBuffer.size() && + self._unpublishedBuffer.get(self._unpublishedBuffer.minElementId()); - if (publishedBefore) { - // Unlimited case where the document stays in published once it matches - // or the case when we don't have enough matching docs to publish or the - // changed but matching doc will stay in published anyways. - // XXX: We rely on the emptiness of buffer. Be sure to maintain the fact - // that buffer can't be empty if there are matching documents not - // published. Notably, we don't want to schedule repoll and continue - // relying on this property. - var staysInPublished = ! self._limit || - self._unpublishedBuffer.size() === 0 || - comparator(newDoc, minBuffered) <= 0; + if (publishedBefore) { + // Unlimited case where the document stays in published once it + // matches or the case when we don't have enough matching docs to + // publish or the changed but matching doc will stay in published + // anyways. + // + // XXX: We rely on the emptiness of buffer. Be sure to maintain the + // fact that buffer can't be empty if there are matching documents not + // published. Notably, we don't want to schedule repoll and continue + // relying on this property. + var staysInPublished = ! self._limit || + self._unpublishedBuffer.size() === 0 || + comparator(newDoc, minBuffered) <= 0; - if (staysInPublished) { - self._changePublished(id, oldDoc, newDoc); - } else { - // after the change doc doesn't stay in the published, remove it - self._removePublished(id); - // but it can move into buffered now, check it - var maxBuffered = self._unpublishedBuffer.get(self._unpublishedBuffer.maxElementId()); + if (staysInPublished) { + self._changePublished(id, oldDoc, newDoc); + } else { + // after the change doc doesn't stay in the published, remove it + self._removePublished(id); + // but it can move into buffered now, check it + var maxBuffered = self._unpublishedBuffer.get( + self._unpublishedBuffer.maxElementId()); - var toBuffer = self._safeAppendToBuffer || - (maxBuffered && comparator(newDoc, maxBuffered) <= 0); + var toBuffer = self._safeAppendToBuffer || + (maxBuffered && comparator(newDoc, maxBuffered) <= 0); - if (toBuffer) { - self._addBuffered(id, newDoc); + if (toBuffer) { + self._addBuffered(id, newDoc); + } else { + // Throw away from both published set and buffer + self._safeAppendToBuffer = false; + } + } + } else if (bufferedBefore) { + oldDoc = self._unpublishedBuffer.get(id); + // remove the old version manually instead of using _removeBuffered so + // we don't trigger the querying immediately. if we end this block + // with the buffer empty, we will need to trigger the query poll + // manually too. + self._unpublishedBuffer.remove(id); + + var maxPublished = self._published.get( + self._published.maxElementId()); + var maxBuffered = self._unpublishedBuffer.size() && + self._unpublishedBuffer.get( + self._unpublishedBuffer.maxElementId()); + + // the buffered doc was updated, it could move to published + var toPublish = comparator(newDoc, maxPublished) < 0; + + // or stays in buffer even after the change + var staysInBuffer = (! toPublish && self._safeAppendToBuffer) || + (!toPublish && maxBuffered && + comparator(newDoc, maxBuffered) <= 0); + + if (toPublish) { + self._addPublished(id, newDoc); + } else if (staysInBuffer) { + // stays in buffer but changes + self._unpublishedBuffer.set(id, newDoc); } else { // Throw away from both published set and buffer self._safeAppendToBuffer = false; + // Normally this check would have been done in _removeBuffered but + // we didn't use it, so we need to do it ourself now. + if (! self._unpublishedBuffer.size()) { + self._needToPollQuery(); + } } - } - } else if (bufferedBefore) { - oldDoc = self._unpublishedBuffer.get(id); - // remove the old version manually instead of using _removeBuffered so - // we don't trigger the querying immediately. if we end this block with - // the buffer empty, we will need to trigger the query poll manually - // too. - self._unpublishedBuffer.remove(id); - - var maxPublished = self._published.get(self._published.maxElementId()); - var maxBuffered = self._unpublishedBuffer.size() && self._unpublishedBuffer.get(self._unpublishedBuffer.maxElementId()); - - // the buffered doc was updated, it could move to published - var toPublish = comparator(newDoc, maxPublished) < 0; - - // or stays in buffer even after the change - var staysInBuffer = (! toPublish && self._safeAppendToBuffer) || - (!toPublish && maxBuffered && comparator(newDoc, maxBuffered) <= 0); - - if (toPublish) { - self._addPublished(id, newDoc); - } else if (staysInBuffer) { - // stays in buffer but changes - self._unpublishedBuffer.set(id, newDoc); } else { - // Throw away from both published set and buffer - self._safeAppendToBuffer = false; - // Normally this check would have been done in _removeBuffered but we - // didn't use it, so we need to do it ourself now. - if (! self._unpublishedBuffer.size()) { - self._needToPollQuery(); - } + throw new Error("cachedBefore implies either of publishedBefore or bufferedBefore is true."); } - } else { - throw new Error("cachedBefore implies either of publishedBefore or bufferedBefore is true."); } - } + }); }, _fetchModifiedDocuments: function () { var self = this; - self._registerPhaseChange(PHASE.FETCHING); - // Defer, because nothing called from the oplog entry handler may yield, but - // fetch() yields. - Meteor.defer(finishIfNeedToPollQuery(function () { - while (!self._stopped && !self._needToFetch.empty()) { - if (self._phase !== PHASE.FETCHING) - throw new Error("phase in fetchModifiedDocuments: " + self._phase); + Meteor._noYieldsAllowed(function () { + self._registerPhaseChange(PHASE.FETCHING); + // Defer, because nothing called from the oplog entry handler may yield, + // but fetch() yields. + Meteor.defer(finishIfNeedToPollQuery(function () { + while (!self._stopped && !self._needToFetch.empty()) { + if (self._phase === PHASE.QUERYING) { + // While fetching, we decided to go into QUERYING mode, and then we + // saw another oplog entry, so _needToFetch is not empty. But we + // shouldn't fetch these documents until AFTER the query is done. + break; + } - self._currentlyFetching = self._needToFetch; - var thisGeneration = ++self._fetchGeneration; - self._needToFetch = new LocalCollection._IdMap; - var waiting = 0; - var fut = new Future; - // This loop is safe, because _currentlyFetching will not be updated - // during this loop (in fact, it is never mutated). - self._currentlyFetching.forEach(function (cacheKey, id) { - waiting++; - self._mongoHandle._docFetcher.fetch( - self._cursorDescription.collectionName, id, cacheKey, - finishIfNeedToPollQuery(function (err, doc) { - try { - if (err) { - Meteor._debug("Got exception while fetching documents: " + - err); - // If we get an error from the fetcher (eg, trouble connecting - // to Mongo), let's just abandon the fetch phase altogether - // and fall back to polling. It's not like we're getting live - // updates anyway. - if (self._phase !== PHASE.QUERYING) { - self._needToPollQuery(); + // Being in steady phase here would be surprising. + if (self._phase !== PHASE.FETCHING) + throw new Error("phase in fetchModifiedDocuments: " + self._phase); + + self._currentlyFetching = self._needToFetch; + var thisGeneration = ++self._fetchGeneration; + self._needToFetch = new LocalCollection._IdMap; + var waiting = 0; + var fut = new Future; + // This loop is safe, because _currentlyFetching will not be updated + // during this loop (in fact, it is never mutated). + self._currentlyFetching.forEach(function (cacheKey, id) { + waiting++; + self._mongoHandle._docFetcher.fetch( + self._cursorDescription.collectionName, id, cacheKey, + finishIfNeedToPollQuery(function (err, doc) { + try { + if (err) { + Meteor._debug("Got exception while fetching documents: " + + err); + // If we get an error from the fetcher (eg, trouble + // connecting to Mongo), let's just abandon the fetch phase + // altogether and fall back to polling. It's not like we're + // getting live updates anyway. + if (self._phase !== PHASE.QUERYING) { + self._needToPollQuery(); + } + } else if (!self._stopped && self._phase === PHASE.FETCHING + && self._fetchGeneration === thisGeneration) { + // We re-check the generation in case we've had an explicit + // _pollQuery call (eg, in another fiber) which should + // effectively cancel this round of fetches. (_pollQuery + // increments the generation.) + self._handleDoc(id, doc); } - } else if (!self._stopped && self._phase === PHASE.FETCHING - && self._fetchGeneration === thisGeneration) { - // We re-check the generation in case we've had an explicit - // _pollQuery call (eg, in another fiber) which should - // effectively cancel this round of fetches. (_pollQuery - // increments the generation.) - self._handleDoc(id, doc); + } finally { + waiting--; + // Because fetch() never calls its callback synchronously, + // this is safe (ie, we won't call fut.return() before the + // forEach is done). + if (waiting === 0) + fut.return(); } - } finally { - waiting--; - // Because fetch() never calls its callback synchronously, this - // is safe (ie, we won't call fut.return() before the forEach is - // done). - if (waiting === 0) - fut.return(); - } - })); - }); - fut.wait(); - // Exit now if we've had a _pollQuery call (here or in another fiber). - if (self._phase === PHASE.QUERYING) - return; - self._currentlyFetching = null; - } - // We're done fetching, so we can be steady, unless we've had a _pollQuery - // call (here or in another fiber). - if (self._phase !== PHASE.QUERYING) - self._beSteady(); - })); + })); + }); + fut.wait(); + // Exit now if we've had a _pollQuery call (here or in another fiber). + if (self._phase === PHASE.QUERYING) + return; + self._currentlyFetching = null; + } + // We're done fetching, so we can be steady, unless we've had a + // _pollQuery call (here or in another fiber). + if (self._phase !== PHASE.QUERYING) + self._beSteady(); + })); + }); }, _beSteady: function () { var self = this; - self._registerPhaseChange(PHASE.STEADY); - var writes = self._writesToCommitWhenWeReachSteady; - self._writesToCommitWhenWeReachSteady = []; - self._multiplexer.onFlush(function () { - _.each(writes, function (w) { - w.committed(); + Meteor._noYieldsAllowed(function () { + self._registerPhaseChange(PHASE.STEADY); + var writes = self._writesToCommitWhenWeReachSteady; + self._writesToCommitWhenWeReachSteady = []; + self._multiplexer.onFlush(function () { + _.each(writes, function (w) { + w.committed(); + }); }); }); }, _handleOplogEntryQuerying: function (op) { var self = this; - self._needToFetch.set(idForOp(op), op.ts.toString()); + Meteor._noYieldsAllowed(function () { + self._needToFetch.set(idForOp(op), op.ts.toString()); + }); }, _handleOplogEntrySteadyOrFetching: function (op) { var self = this; - var id = idForOp(op); - // If we're already fetching this one, or about to, we can't optimize; make - // sure that we fetch it again if necessary. - if (self._phase === PHASE.FETCHING && - ((self._currentlyFetching && self._currentlyFetching.has(id)) || - self._needToFetch.has(id))) { - self._needToFetch.set(id, op.ts.toString()); - return; - } - - if (op.op === 'd') { - if (self._published.has(id) || (self._limit && self._unpublishedBuffer.has(id))) - self._removeMatching(id); - } else if (op.op === 'i') { - if (self._published.has(id)) - throw new Error("insert found for already-existing ID in published"); - if (self._unpublishedBuffer && self._unpublishedBuffer.has(id)) - throw new Error("insert found for already-existing ID in buffer"); - - // XXX what if selector yields? for now it can't but later it could have - // $where - if (self._matcher.documentMatches(op.o).result) - self._addMatching(op.o); - } else if (op.op === 'u') { - // Is this a modifier ($set/$unset, which may require us to poll the - // database to figure out if the whole document matches the selector) or a - // replacement (in which case we can just directly re-evaluate the - // selector)? - var isReplace = !_.has(op.o, '$set') && !_.has(op.o, '$unset'); - // If this modifier modifies something inside an EJSON custom type (ie, - // anything with EJSON$), then we can't try to use - // LocalCollection._modify, since that just mutates the EJSON encoding, - // not the actual object. - var canDirectlyModifyDoc = - !isReplace && modifierCanBeDirectlyApplied(op.o); - - var publishedBefore = self._published.has(id); - var bufferedBefore = self._limit && self._unpublishedBuffer.has(id); - - if (isReplace) { - self._handleDoc(id, _.extend({_id: id}, op.o)); - } else if ((publishedBefore || bufferedBefore) && canDirectlyModifyDoc) { - // Oh great, we actually know what the document is, so we can apply - // this directly. - var newDoc = self._published.has(id) ? - self._published.get(id) : - self._unpublishedBuffer.get(id); - newDoc = EJSON.clone(newDoc); - - newDoc._id = id; - LocalCollection._modify(newDoc, op.o); - self._handleDoc(id, self._sharedProjectionFn(newDoc)); - } else if (!canDirectlyModifyDoc || - self._matcher.canBecomeTrueByModifier(op.o) || - (self._sorter && self._sorter.affectedByModifier(op.o))) { + Meteor._noYieldsAllowed(function () { + var id = idForOp(op); + // If we're already fetching this one, or about to, we can't optimize; + // make sure that we fetch it again if necessary. + if (self._phase === PHASE.FETCHING && + ((self._currentlyFetching && self._currentlyFetching.has(id)) || + self._needToFetch.has(id))) { self._needToFetch.set(id, op.ts.toString()); - if (self._phase === PHASE.STEADY) - self._fetchModifiedDocuments(); + return; } - } else { - throw Error("XXX SURPRISING OPERATION: " + op); - } + + if (op.op === 'd') { + if (self._published.has(id) || + (self._limit && self._unpublishedBuffer.has(id))) + self._removeMatching(id); + } else if (op.op === 'i') { + if (self._published.has(id)) + throw new Error("insert found for already-existing ID in published"); + if (self._unpublishedBuffer && self._unpublishedBuffer.has(id)) + throw new Error("insert found for already-existing ID in buffer"); + + // XXX what if selector yields? for now it can't but later it could + // have $where + if (self._matcher.documentMatches(op.o).result) + self._addMatching(op.o); + } else if (op.op === 'u') { + // Is this a modifier ($set/$unset, which may require us to poll the + // database to figure out if the whole document matches the selector) or + // a replacement (in which case we can just directly re-evaluate the + // selector)? + var isReplace = !_.has(op.o, '$set') && !_.has(op.o, '$unset'); + // If this modifier modifies something inside an EJSON custom type (ie, + // anything with EJSON$), then we can't try to use + // LocalCollection._modify, since that just mutates the EJSON encoding, + // not the actual object. + var canDirectlyModifyDoc = + !isReplace && modifierCanBeDirectlyApplied(op.o); + + var publishedBefore = self._published.has(id); + var bufferedBefore = self._limit && self._unpublishedBuffer.has(id); + + if (isReplace) { + self._handleDoc(id, _.extend({_id: id}, op.o)); + } else if ((publishedBefore || bufferedBefore) && + canDirectlyModifyDoc) { + // Oh great, we actually know what the document is, so we can apply + // this directly. + var newDoc = self._published.has(id) + ? self._published.get(id) : self._unpublishedBuffer.get(id); + newDoc = EJSON.clone(newDoc); + + newDoc._id = id; + LocalCollection._modify(newDoc, op.o); + self._handleDoc(id, self._sharedProjectionFn(newDoc)); + } else if (!canDirectlyModifyDoc || + self._matcher.canBecomeTrueByModifier(op.o) || + (self._sorter && self._sorter.affectedByModifier(op.o))) { + self._needToFetch.set(id, op.ts.toString()); + if (self._phase === PHASE.STEADY) + self._fetchModifiedDocuments(); + } + } else { + throw Error("XXX SURPRISING OPERATION: " + op); + } + }); }, + // Yields! _runInitialQuery: function () { var self = this; if (self._stopped) throw new Error("oplog stopped surprisingly early"); - self._runQuery(); + self._runQuery(); // yields if (self._stopped) throw new Error("oplog stopped quite early"); @@ -580,7 +622,7 @@ _.extend(OplogObserveDriver.prototype, { // stop() to be called.) self._multiplexer.ready(); - self._doneQuerying(); + self._doneQuerying(); // yields }, // In various circumstances, we may just want to stop processing the oplog and @@ -599,24 +641,26 @@ _.extend(OplogObserveDriver.prototype, { // changes. Will put off implementing this until driver 1.4 is out. _pollQuery: function () { var self = this; + Meteor._noYieldsAllowed(function () { + if (self._stopped) + return; - if (self._stopped) - return; + // Yay, we get to forget about all the things we thought we had to fetch. + self._needToFetch = new LocalCollection._IdMap; + self._currentlyFetching = null; + ++self._fetchGeneration; // ignore any in-flight fetches + self._registerPhaseChange(PHASE.QUERYING); - // Yay, we get to forget about all the things we thought we had to fetch. - self._needToFetch = new LocalCollection._IdMap; - self._currentlyFetching = null; - ++self._fetchGeneration; // ignore any in-flight fetches - self._registerPhaseChange(PHASE.QUERYING); - - // Defer so that we don't block. We don't need finishIfNeedToPollQuery here - // because SwitchedToQuery is not called in QUERYING mode. - Meteor.defer(function () { - self._runQuery(); - self._doneQuerying(); + // Defer so that we don't yield. We don't need finishIfNeedToPollQuery + // here because SwitchedToQuery is not thrown in QUERYING mode. + Meteor.defer(function () { + self._runQuery(); + self._doneQuerying(); + }); }); }, + // Yields! _runQuery: function () { var self = this; var newResults, newBuffer; @@ -639,7 +683,7 @@ _.extend(OplogObserveDriver.prototype, { // buffer if such is needed. var cursor = self._cursorForQuery({ limit: self._limit * 2 }); try { - cursor.forEach(function (doc, i) { + cursor.forEach(function (doc, i) { // yields if (!self._limit || i < self._limit) newResults.set(doc._id, doc); else @@ -654,6 +698,9 @@ _.extend(OplogObserveDriver.prototype, { } } + if (self._stopped) + return; + self._publishNewResults(newResults, newBuffer); }, @@ -671,65 +718,70 @@ _.extend(OplogObserveDriver.prototype, { // _fetchModifiedDocuments does this.) _needToPollQuery: function () { var self = this; - if (self._stopped) - return; + Meteor._noYieldsAllowed(function () { + if (self._stopped) + return; - // If we're not already in the middle of a query, we can query now (possibly - // pausing FETCHING). - if (self._phase !== PHASE.QUERYING) { - self._pollQuery(); - throw new SwitchedToQuery; - } + // If we're not already in the middle of a query, we can query now + // (possibly pausing FETCHING). + if (self._phase !== PHASE.QUERYING) { + self._pollQuery(); + throw new SwitchedToQuery; + } - // We're currently in QUERYING. Set a flag to ensure that we run another - // query when we're done. - self._requeryWhenDoneThisQuery = true; + // We're currently in QUERYING. Set a flag to ensure that we run another + // query when we're done. + self._requeryWhenDoneThisQuery = true; + }); }, + // Yields! _doneQuerying: function () { var self = this; if (self._stopped) return; - self._mongoHandle._oplogHandle.waitUntilCaughtUp(); - + self._mongoHandle._oplogHandle.waitUntilCaughtUp(); // yields if (self._stopped) return; if (self._phase !== PHASE.QUERYING) throw Error("Phase unexpectedly " + self._phase); - if (self._requeryWhenDoneThisQuery) { - self._requeryWhenDoneThisQuery = false; - self._pollQuery(); - } else if (self._needToFetch.empty()) { - self._beSteady(); - } else { - self._fetchModifiedDocuments(); - } + Meteor._noYieldsAllowed(function () { + if (self._requeryWhenDoneThisQuery) { + self._requeryWhenDoneThisQuery = false; + self._pollQuery(); + } else if (self._needToFetch.empty()) { + self._beSteady(); + } else { + self._fetchModifiedDocuments(); + } + }); }, _cursorForQuery: function (optionsOverwrite) { var self = this; + return Meteor._noYieldsAllowed(function () { + // The query we run is almost the same as the cursor we are observing, + // with a few changes. We need to read all the fields that are relevant to + // the selector, not just the fields we are going to publish (that's the + // "shared" projection). And we don't want to apply any transform in the + // cursor, because observeChanges shouldn't use the transform. + var options = _.clone(self._cursorDescription.options); - // The query we run is almost the same as the cursor we are observing, with - // a few changes. We need to read all the fields that are relevant to the - // selector, not just the fields we are going to publish (that's the - // "shared" projection). And we don't want to apply any transform in the - // cursor, because observeChanges shouldn't use the transform. - var options = _.clone(self._cursorDescription.options); + // Allow the caller to modify the options. Useful to specify different + // skip and limit values. + _.extend(options, optionsOverwrite); - // Allow the caller to modify the options. Useful to specify different skip - // and limit values. - _.extend(options, optionsOverwrite); - - options.fields = self._sharedProjection; - delete options.transform; - // We are NOT deep cloning fields or selector here, which should be OK. - var description = new CursorDescription( - self._cursorDescription.collectionName, - self._cursorDescription.selector, - options); - return new Cursor(self._mongoHandle, description); + options.fields = self._sharedProjection; + delete options.transform; + // We are NOT deep cloning fields or selector here, which should be OK. + var description = new CursorDescription( + self._cursorDescription.collectionName, + self._cursorDescription.selector, + options); + return new Cursor(self._mongoHandle, description); + }); }, @@ -738,57 +790,62 @@ _.extend(OplogObserveDriver.prototype, { // Replace self._unpublishedBuffer with newBuffer. // // XXX This is very similar to LocalCollection._diffQueryUnorderedChanges. We - // should really: (a) Unify IdMap and OrderedDict into Unordered/OrderedDict (b) - // Rewrite diff.js to use these classes instead of arrays and objects. + // should really: (a) Unify IdMap and OrderedDict into Unordered/OrderedDict + // (b) Rewrite diff.js to use these classes instead of arrays and objects. _publishNewResults: function (newResults, newBuffer) { var self = this; + Meteor._noYieldsAllowed(function () { - // If the query is limited and there is a buffer, shut down so it doesn't - // stay in a way. - if (self._limit) { - self._unpublishedBuffer.clear(); - } + // If the query is limited and there is a buffer, shut down so it doesn't + // stay in a way. + if (self._limit) { + self._unpublishedBuffer.clear(); + } - // First remove anything that's gone. Be careful not to modify - // self._published while iterating over it. - var idsToRemove = []; - self._published.forEach(function (doc, id) { - if (!newResults.has(id)) - idsToRemove.push(id); + // First remove anything that's gone. Be careful not to modify + // self._published while iterating over it. + var idsToRemove = []; + self._published.forEach(function (doc, id) { + if (!newResults.has(id)) + idsToRemove.push(id); + }); + _.each(idsToRemove, function (id) { + self._removePublished(id); + }); + + // Now do adds and changes. + // If self has a buffer and limit, the new fetched result will be + // limited correctly as the query has sort specifier. + newResults.forEach(function (doc, id) { + self._handleDoc(id, doc); + }); + + // Sanity-check that everything we tried to put into _published ended up + // there. + // XXX if this is slow, remove it later + if (self._published.size() !== newResults.size()) { + throw Error("failed to copy newResults into _published!"); + } + self._published.forEach(function (doc, id) { + if (!newResults.has(id)) + throw Error("_published has a doc that newResults doesn't; " + id); + }); + + // Finally, replace the buffer + newBuffer.forEach(function (doc, id) { + self._addBuffered(id, doc); + }); + + self._safeAppendToBuffer = newBuffer.size() < self._limit; }); - _.each(idsToRemove, function (id) { - self._removePublished(id); - }); - - // Now do adds and changes. - // If self has a buffer and limit, the new fetched result will be - // limited correctly as the query has sort specifier. - newResults.forEach(function (doc, id) { - self._handleDoc(id, doc); - }); - - // Sanity-check that everything we tried to put into _published ended up - // there. - // XXX if this is slow, remove it later - if (self._published.size() !== newResults.size()) { - throw Error("failed to copy newResults into _published!"); - } - self._published.forEach(function (doc, id) { - if (!newResults.has(id)) - throw Error("_published has a doc that newResults doesn't; " + id); - }); - - // Finally, replace the buffer - newBuffer.forEach(function (doc, id) { - self._addBuffered(id, doc); - }); - - self._safeAppendToBuffer = newBuffer.size() < self._limit; }, // This stop function is invoked from the onStop of the ObserveMultiplexer, so // it shouldn't actually be possible to call it until the multiplexer is // ready. + // + // It's important to check self._stopped after every call in this file that + // can yield! stop: function () { var self = this; if (self._stopped) @@ -804,7 +861,7 @@ _.extend(OplogObserveDriver.prototype, { // to get flushed (and it's probably not valid to call methods on the // dying multiplexer). _.each(self._writesToCommitWhenWeReachSteady, function (w) { - w.committed(); + w.committed(); // maybe yields? }); self._writesToCommitWhenWeReachSteady = null; @@ -822,16 +879,18 @@ _.extend(OplogObserveDriver.prototype, { _registerPhaseChange: function (phase) { var self = this; - var now = new Date; + Meteor._noYieldsAllowed(function () { + var now = new Date; - if (self._phase) { - var timeDiff = now - self._phaseStartTime; - Package.facts && Package.facts.Facts.incrementServerFact( - "mongo-livedata", "time-spent-in-" + self._phase + "-phase", timeDiff); - } + if (self._phase) { + var timeDiff = now - self._phaseStartTime; + Package.facts && Package.facts.Facts.incrementServerFact( + "mongo-livedata", "time-spent-in-" + self._phase + "-phase", timeDiff); + } - self._phase = phase; - self._phaseStartTime = now; + self._phase = phase; + self._phaseStartTime = now; + }); } }); diff --git a/packages/oauth/end_of_login_response.html b/packages/oauth/end_of_login_response.html index 81f24b22f0..f4480867d3 100644 --- a/packages/oauth/end_of_login_response.html +++ b/packages/oauth/end_of_login_response.html @@ -4,19 +4,28 @@ if (##SET_CREDENTIAL_TOKEN##) { var credentialToken = ##TOKEN##; var credentialSecret = ##SECRET##; - try { - localStorage[##LOCAL_STORAGE_PREFIX## + credentialToken] = credentialSecret; - } catch (err) { - // localStorage didn't work; try window.opener. - window.opener && - window.opener.Package.oauth.OAuth._handleCredentialSecret( - credentialToken, credentialSecret); - // If window.opener isn't set, we can't do much else, but at least - // close the popup instead of having it hang around on a blank page. + if (window.opener && window.opener.Package && + window.opener.Package.oauth) { + window.opener.Package.oauth.OAuth._handleCredentialSecret( + credentialToken, credentialSecret); + } else { + try { + localStorage[##LOCAL_STORAGE_PREFIX## + credentialToken] = credentialSecret; + } catch (err) { + // We can't do much else, but at least close the popup instead + // of having it hang around on a blank page. + } } } window.close(); - + +

+ Login completed. + Click here to close this window. +

+ + diff --git a/packages/oauth/oauth_client.js b/packages/oauth/oauth_client.js index 8ba5e48aa0..dd05c2e85d 100644 --- a/packages/oauth/oauth_client.js +++ b/packages/oauth/oauth_client.js @@ -92,17 +92,16 @@ OAuth._handleCredentialSecret = function (credentialToken, secret) { // Used by accounts-oauth, which needs both a credentialToken and the // corresponding to credential secret to call the `login` method over DDP. OAuth._retrieveCredentialSecret = function (credentialToken) { - // Check localStorage first, then check the secrets collected by - // OAuth._handleCredentialSecret. This matches what we do in + // First check the secrets collected by OAuth._handleCredentialSecret, + // then check localStorage. This matches what we do in // end_of_login_response.html. - var localStorageKey = OAuth._localStorageTokenPrefix + - credentialToken; - var secret = Meteor._localStorage.getItem(localStorageKey); - - if (secret) { + var secret = credentialSecrets[credentialToken]; + if (! secret) { + var localStorageKey = OAuth._localStorageTokenPrefix + + credentialToken; + secret = Meteor._localStorage.getItem(localStorageKey); Meteor._localStorage.removeItem(localStorageKey); } else { - secret = credentialSecrets[credentialToken]; delete credentialSecrets[credentialToken]; } return secret; diff --git a/packages/oauth/oauth_server.js b/packages/oauth/oauth_server.js index b4f0c3e5c1..d49444e08a 100644 --- a/packages/oauth/oauth_server.js +++ b/packages/oauth/oauth_server.js @@ -11,7 +11,6 @@ var registeredServices = {}; // Internal: Maps from service version to handler function. The // 'oauth1' and 'oauth2' packages manipulate this directly to register // for callbacks. -// OAuth._requestHandlers = {}; diff --git a/packages/observe-sequence/observe_sequence.js b/packages/observe-sequence/observe_sequence.js index b4a7dc9b71..b9273f2ab4 100644 --- a/packages/observe-sequence/observe_sequence.js +++ b/packages/observe-sequence/observe_sequence.js @@ -82,11 +82,10 @@ ObserveSequence = { Deps.nonreactive(function () { var seqArray; // same structure as `lastSeqArray` above. - // If we were previously observing a cursor, replace lastSeqArray with - // more up-to-date information (specifically, the state of the observe - // before it was stopped, which may be older than the DB). if (activeObserveHandle) { - lastSeqArray = _.map(activeObserveHandle._fetch(), function (doc) { + // If we were previously observing a cursor, replace lastSeqArray with + // more up-to-date information. Then stop the old observe. + lastSeqArray = _.map(lastSeq.fetch(), function (doc) { return {_id: doc._id, item: doc}; }); activeObserveHandle.stop(); @@ -94,78 +93,19 @@ ObserveSequence = { } if (!seq) { - seqArray = []; - diffArray(lastSeqArray, seqArray, callbacks); + seqArray = seqChangedToEmpty(lastSeqArray, callbacks); } else if (seq instanceof Array) { - var idsUsed = {}; - seqArray = _.map(seq, function (item, index) { - var id; - if (typeof item === 'string') { - // ensure not empty, since other layers (eg DomRange) assume this as well - id = "-" + item; - } else if (typeof item === 'number' || - typeof item === 'boolean' || - item === undefined) { - id = item; - } else if (typeof item === 'object') { - id = (item && item._id) || index; - } else { - throw new Error("{{#each}} doesn't support arrays with " + - "elements of type " + typeof item); - } - - var idString = idStringify(id); - if (idsUsed[idString]) { - if (typeof item === 'object' && '_id' in item) - warn("duplicate id " + id + " in", seq); - id = Random.id(); - } else { - idsUsed[idString] = true; - } - - return { _id: id, item: item }; - }); - - diffArray(lastSeqArray, seqArray, callbacks); + seqArray = seqChangedToArray(lastSeqArray, seq, callbacks); } else if (isStoreCursor(seq)) { - var cursor = seq; - seqArray = []; - - var initial = true; // are we observing initial data from cursor? - activeObserveHandle = cursor.observe({ - addedAt: function (document, atIndex, before) { - if (initial) { - // keep track of initial data so that we can diff once - // we exit `observe`. - if (before !== null) - throw new Error("Expected initial data from observe in order"); - seqArray.push({ _id: document._id, item: document }); - } else { - callbacks.addedAt(document._id, document, atIndex, before); - } - }, - changedAt: function (newDocument, oldDocument, atIndex) { - callbacks.changedAt(newDocument._id, newDocument, oldDocument, - atIndex); - }, - removedAt: function (oldDocument, atIndex) { - callbacks.removedAt(oldDocument._id, oldDocument, atIndex); - }, - movedTo: function (document, fromIndex, toIndex, before) { - callbacks.movedTo( - document._id, document, fromIndex, toIndex, before); - } - }); - initial = false; - - // diff the old sequnce with initial data in the new cursor. this will - // fire `addedAt` callbacks on the initial data. - diffArray(lastSeqArray, seqArray, callbacks); - + var result /* [seqArray, activeObserveHandle] */ = + seqChangedToCursor(lastSeqArray, seq, callbacks); + seqArray = result[0]; + activeObserveHandle = result[1]; } else { throw badSequenceError(); } + diffArray(lastSeqArray, seqArray, callbacks); lastSeq = seq; lastSeqArray = seqArray; }); @@ -306,3 +246,73 @@ var diffArray = function (lastSeqArray, seqArray, callbacks) { } }); }; + +seqChangedToEmpty = function (lastSeqArray, callbacks) { + return []; +}; + +seqChangedToArray = function (lastSeqArray, array, callbacks) { + var idsUsed = {}; + var seqArray = _.map(array, function (item, index) { + var id; + if (typeof item === 'string') { + // ensure not empty, since other layers (eg DomRange) assume this as well + id = "-" + item; + } else if (typeof item === 'number' || + typeof item === 'boolean' || + item === undefined) { + id = item; + } else if (typeof item === 'object') { + id = (item && item._id) || index; + } else { + throw new Error("{{#each}} doesn't support arrays with " + + "elements of type " + typeof item); + } + + var idString = idStringify(id); + if (idsUsed[idString]) { + if (typeof item === 'object' && '_id' in item) + warn("duplicate id " + id + " in", array); + id = Random.id(); + } else { + idsUsed[idString] = true; + } + + return { _id: id, item: item }; + }); + + return seqArray; +}; + +seqChangedToCursor = function (lastSeqArray, cursor, callbacks) { + var initial = true; // are we observing initial data from cursor? + var seqArray = []; + + var observeHandle = cursor.observe({ + addedAt: function (document, atIndex, before) { + if (initial) { + // keep track of initial data so that we can diff once + // we exit `observe`. + if (before !== null) + throw new Error("Expected initial data from observe in order"); + seqArray.push({ _id: document._id, item: document }); + } else { + callbacks.addedAt(document._id, document, atIndex, before); + } + }, + changedAt: function (newDocument, oldDocument, atIndex) { + callbacks.changedAt(newDocument._id, newDocument, oldDocument, + atIndex); + }, + removedAt: function (oldDocument, atIndex) { + callbacks.removedAt(oldDocument._id, oldDocument, atIndex); + }, + movedTo: function (document, fromIndex, toIndex, before) { + callbacks.movedTo( + document._id, document, fromIndex, toIndex, before); + } + }); + initial = false; + + return [seqArray, observeHandle]; +}; diff --git a/packages/observe-sequence/observe_sequence_tests.js b/packages/observe-sequence/observe_sequence_tests.js index 340de1179c..5fdb311989 100644 --- a/packages/observe-sequence/observe_sequence_tests.js +++ b/packages/observe-sequence/observe_sequence_tests.js @@ -439,9 +439,8 @@ Tinytest.add('observe-sequence - cursor to same cursor', function (test) { }, [ {addedAt: ["13", {_id: "13", rank: 1}, 0, null]}, {addedAt: ["24", {_id: "24", rank: 2}, 1, null]}, - // even if the cursor changes to the same cursor, we diff to see if we - // missed anything during the invalidation, which leads to these - // 'changedAt' events. + // even if the cursor changes to the same cursor, we do a diff, + // which leads to these 'changedAt' events. {changedAt: ["13", {_id: "13", rank: 1}, {_id: "13", rank: 1}, 0]}, {changedAt: ["24", {_id: "24", rank: 2}, {_id: "24", rank: 2}, 1]}, {addedAt: ["78", {_id: "78", rank: 3}, 2, null]} diff --git a/packages/spacebars-tests/package.js b/packages/spacebars-tests/package.js index 655103e0bf..812b56083f 100644 --- a/packages/spacebars-tests/package.js +++ b/packages/spacebars-tests/package.js @@ -13,6 +13,7 @@ Package.on_test(function (api) { api.use('test-helpers'); api.use('showdown'); api.use('minimongo'); + api.use('deps'); api.use('templating', 'client'); api.add_files([ diff --git a/packages/spacebars-tests/template_tests.html b/packages/spacebars-tests/template_tests.html index bfd11eef8f..4d32086cb1 100644 --- a/packages/spacebars-tests/template_tests.html +++ b/packages/spacebars-tests/template_tests.html @@ -922,3 +922,41 @@ Hi there! + + + + + + + + + + + + + + + + diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index dffe1f750d..c748505f84 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -2604,3 +2604,163 @@ _.each([1, 2, 3], function (n) { } ); }); + +Tinytest.add('spacebars-tests - template_tests - current view in event handler', function (test) { + var tmpl = Template.spacebars_test_current_view_in_event; + + var currentView; + var currentData; + + tmpl.events({ + 'click span': function () { + currentView = Blaze.getCurrentView(); + currentData = Blaze.getCurrentData(); + } + }); + + var div = renderToDiv(tmpl, 'blah'); + test.equal(canonicalizeHtml(div.innerHTML), 'blah'); + document.body.appendChild(div); + clickElement(div.querySelector('span')); + $(div).remove(); + + test.isTrue(currentView); + test.equal(currentData, 'blah'); +}); + + +Tinytest.add( + "spacebars-tests - template_tests - textarea attrs", function (test) { + var tmplNoContents = { + tmpl: Template.spacebars_test_textarea_attrs, + hasTextAreaContents: false + }; + var tmplWithContents = { + tmpl: Template.spacebars_test_textarea_attrs_contents, + hasTextAreaContents: true + }; + var tmplWithContentsAndMoreAttrs = { + tmpl: Template.spacebars_test_textarea_attrs_array_contents, + hasTextAreaContents: true + }; + + _.each( + [tmplNoContents, tmplWithContents, + tmplWithContentsAndMoreAttrs], + function (tmplInfo) { + + var id = new ReactiveVar("textarea-" + Random.id()); + var name = new ReactiveVar("one"); + var attrs = new ReactiveVar({ + id: "textarea-" + Random.id() + }); + + var div = renderToDiv(tmplInfo.tmpl, { + attrs: function () { + return attrs.get(); + }, + name: function () { + return name.get(); + } + }); + + // Check that the id and value attribute are as we expect. + // We can't check div.innerHTML because Chrome at least doesn't + // appear to put textarea value attributes in innerHTML. + var textarea = div.querySelector("textarea"); + test.equal(textarea.id, attrs.get().id); + test.equal( + textarea.value, tmplInfo.hasTextAreaContents ? "Hello one" : ""); + // One of the templates has a separate attribute in addition to + // an attributes dictionary. + if (tmplInfo === tmplWithContentsAndMoreAttrs) { + test.equal($(textarea).attr("class"), "bar"); + } + + // Change the id, check that the attribute updates reactively. + attrs.set({ id: "textarea-" + Random.id() }); + Deps.flush(); + test.equal(textarea.id, attrs.get().id); + + // Change the name variable, check that the textarea value + // updates reactively. + name.set("two"); + Deps.flush(); + test.equal( + textarea.value, tmplInfo.hasTextAreaContents ? "Hello two" : ""); + + if (tmplInfo === tmplWithContentsAndMoreAttrs) { + test.equal($(textarea).attr("class"), "bar"); + } + + }); + + }); + +Tinytest.add( + "spacebars-tests - template_tests - this.autorun", + function (test) { + var tmpl = Template.spacebars_test_autorun; + var tmplInner = Template.spacebars_test_autorun_inner; + + // Keep track of the value of `UI._templateInstance()` inside the + // autorun each time it runs. + var autorunTemplateInstances = []; + var actualTemplateInstance; + var returnedComputation; + var computationArg; + + var show = new ReactiveVar(true); + var rv = new ReactiveVar("foo"); + + tmplInner.created = function () { + actualTemplateInstance = this; + returnedComputation = this.autorun(function (c) { + computationArg = c; + rv.get(); + autorunTemplateInstances.push(UI._templateInstance()); + }); + }; + + tmpl.helpers({ + show: function () { + return show.get(); + } + }); + + var div = renderToDiv(tmpl); + test.equal(autorunTemplateInstances.length, 1); + test.equal(autorunTemplateInstances[0], actualTemplateInstance); + + // Test that the autorun returned a computation and received a + // computation as an argument. + test.isTrue(returnedComputation instanceof Deps.Computation); + test.equal(returnedComputation, computationArg); + + // Make sure the autorun re-runs when `rv` changes, and that it has + // the correct current view. + rv.set("bar"); + Deps.flush(); + test.equal(autorunTemplateInstances.length, 2); + test.equal(autorunTemplateInstances[1], actualTemplateInstance); + + // If the inner template is destroyed, the autorun should be stopped. + show.set(false); + Deps.flush(); + rv.set("baz"); + Deps.flush(); + + test.equal(autorunTemplateInstances.length, 2); + test.equal(rv.numListeners(), 0); + } +); + +// Test that argument in {{> UI.contentBlock arg}} is evaluated in +// the proper data context. +Tinytest.add( + "spacebars-tests - template_tests - contentBlock argument", + function (test) { + var tmpl = Template.spacebars_test_contentBlock_arg; + var div = renderToDiv(tmpl); + test.equal(canonicalizeHtml(div.innerHTML), 'AAA BBB'); + }); diff --git a/packages/spacebars/dynamic.html b/packages/spacebars/dynamic.html index 3e93b50538..054e208f6a 100644 --- a/packages/spacebars/dynamic.html +++ b/packages/spacebars/dynamic.html @@ -17,10 +17,6 @@ render the template. --> the template to render) and a `data` property, which can be falsey. --> diff --git a/packages/spacebars/spacebars-runtime.js b/packages/spacebars/spacebars-runtime.js index 7d02d7f9b9..33ffd83549 100644 --- a/packages/spacebars/spacebars-runtime.js +++ b/packages/spacebars/spacebars-runtime.js @@ -205,7 +205,32 @@ Spacebars.dot = function (value, id1/*, id2, ...*/) { }; Spacebars.TemplateWith = function (argFunc, contentBlock) { - var w = Blaze.With(argFunc, contentBlock); + var w; + + // This is a little messy. When we compile `{{> UI.contentBlock}}`, we + // wrap it in Blaze.InOuterTemplateScope in order to skip the intermediate + // parent Views in the current template. However, when there's an argument + // (`{{> UI.contentBlock arg}}`), the argument needs to be evaluated + // in the original scope. There's no good order to nest + // Blaze.InOuterTemplateScope and Spacebars.TemplateWith to achieve this, + // so we wrap argFunc to run it in the "original parentView" of the + // Blaze.InOuterTemplateScope. + // + // To make this better, reconsider InOuterTemplateScope as a primitive. + // Longer term, evaluate expressions in the proper lexical scope. + var wrappedArgFunc = function () { + var viewToEvaluateArg = null; + if (w.parentView && w.parentView.kind === 'InOuterTemplateScope') { + viewToEvaluateArg = w.parentView.originalParentView; + } + if (viewToEvaluateArg) { + return Blaze.withCurrentView(viewToEvaluateArg, argFunc); + } else { + return argFunc(); + } + }; + + w = Blaze.With(wrappedArgFunc, contentBlock); w.__isTemplateWith = true; return w; }; diff --git a/packages/spiderable/phantom_script.js b/packages/spiderable/phantom_script.js index 00835824fb..a4208d1547 100644 --- a/packages/spiderable/phantom_script.js +++ b/packages/spiderable/phantom_script.js @@ -1,22 +1,26 @@ // 'url' is assigned to in a statement before this. var page = require('webpage').create(); -page.open(url); -setInterval(function() { - var ready = page.evaluate(function () { - if (typeof Meteor !== 'undefined' - && typeof(Meteor.status) !== 'undefined' - && Meteor.status().connected) { - Deps.flush(); - return DDP._allSubscriptionsReady(); - } - return false; - }); - if (ready) { - var out = page.content; - out = out.replace(/]+>(.|\n|\r)*?<\/script\s*>/ig, ''); - out = out.replace('', ''); - console.log(out); +page.open(url, function(status) { + if (status === 'fail') phantom.exit(); - } -}, 100); + setInterval(function() { + var ready = page.evaluate(function () { + if (typeof Meteor !== 'undefined' + && typeof(Meteor.status) !== 'undefined' + && Meteor.status().connected) { + Deps.flush(); + return DDP._allSubscriptionsReady(); + } + return false; + }); + if (ready) { + var out = page.content; + out = out.replace(/]+>(.|\n|\r)*?<\/script\s*>/ig, ''); + out = out.replace('', ''); + console.log(out); + phantom.exit(); + } + }, 100); +}); + diff --git a/packages/templating/templating.js b/packages/templating/templating.js index b871b9c180..e74c1506ac 100644 --- a/packages/templating/templating.js +++ b/packages/templating/templating.js @@ -37,6 +37,9 @@ Template.__updateTemplateInstance = function (view) { data: null, firstNode: null, lastNode: null, + autorun: function (f) { + return view.autorun(f); + }, __view__: view }; } diff --git a/packages/test-helpers/canonicalize_html.js b/packages/test-helpers/canonicalize_html.js index 28dd2520f0..1d159b85ae 100644 --- a/packages/test-helpers/canonicalize_html.js +++ b/packages/test-helpers/canonicalize_html.js @@ -2,6 +2,7 @@ canonicalizeHtml = function(html) { var h = html; // kill IE-specific comments inserted by DomRange h = h.replace(//g, ''); + h = h.replace(//g, ''); // ignore exact text of comments h = h.replace(//g, ''); // make all tags lowercase diff --git a/packages/test-in-console/driver.js b/packages/test-in-console/driver.js index 50e03156f3..35208a0b74 100644 --- a/packages/test-in-console/driver.js +++ b/packages/test-in-console/driver.js @@ -8,18 +8,43 @@ TEST_STATUS = { FAILURES: null }; +// xUnit format uses XML output +var XML_CHAR_MAP = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''' +}; +// Escapes a string for insertion into XML +var escapeXml = function (s) { + return s.replace(/[<>&"']/g, function (c) { + return XML_CHAR_MAP[c]; + }); +} + +// Returns a human name for a test var getName = function (result) { return (result.server ? "S: " : "C: ") + result.groupPath.join(" - ") + " - " + result.test; }; +// Calls console.log, but returns silently if console.log is not available var log = function (/*arguments*/) { if (typeof console !== 'undefined') { console.log.apply(console, arguments); } }; +// Logs xUnit output, if xunit output is enabled +// Output is sent to console.log, prefixed with a magic string 'XUNIT ' +// By grepping for that prefix, the xUnit output can be extracted +var xunit = function (s) { + if (xunitEnabled) { + log('XUNIT ' + s); + } +}; var passed = 0; var failed = 0; @@ -31,6 +56,10 @@ var hrefPath = document.location.href.split("/"); var platform = decodeURIComponent(hrefPath.length && hrefPath[hrefPath.length - 1]); if (!platform) platform = "local"; + +// We enable xUnit output when platform is xunit +var xunitEnabled = (platform == 'xunit'); + var doReport = Meteor && Meteor.settings && Meteor.settings.public && @@ -82,10 +111,13 @@ Meteor.startup(function () { status: "PENDING", events: [], server: !!results.server, - testPath: testPath + testPath: testPath, + test: results.test }; report(name, false); } + // Loop through events, and record status for each test + // Also log result if test has finished _.each(results.events, function (event) { resultSet[name].events.push(event); switch (event.type) { @@ -136,6 +168,7 @@ Meteor.startup(function () { }); }, + // After test completion, log a quick summary function () { if (failed > 0) { log("~~~~~~~ THERE ARE FAILURES ~~~~~~~"); @@ -153,6 +186,43 @@ Meteor.startup(function () { TEST_STATUS.DONE = DONE = true; } }); + + // Also log xUnit output + xunit(''); + _.each(resultSet, function (result, name) { + var classname = result.testPath.join('.').replace(/ /g, '-') + (result.server ? "-server" : "-client"); + var name = result.test.replace(/ /g, '-') + (result.server ? "-server" : "-client"); + var time = ""; + var error = ""; + _.each(result.events, function (event) { + switch (event.type) { + case "finish": + var timeMs = event.timeMs; + if (timeMs !== undefined) { + time = (timeMs / 1000) + ""; + } + break; + case "fail": + var details = event.details || {}; + error = (details.message || '?') + " filename=" + (details.filename || '?') + " line=" + (details.line || '?'); + } + }); + switch (event.status) { + case "FAIL": + error = error || '?'; + break; + case "EXPECTED": + error = "Expected failure"; + break; + } + + xunit(''); + if (error) { + xunit(' ' + escapeXml(error) + ''); + } + xunit(''); + }); + xunit(''); }, ["tinytest"]); }); diff --git a/packages/tinytest/tinytest_client.js b/packages/tinytest/tinytest_client.js index f1c1f677d2..6749c172e2 100644 --- a/packages/tinytest/tinytest_client.js +++ b/packages/tinytest/tinytest_client.js @@ -2,23 +2,40 @@ // the server. Sets a 'server' flag on test results that came from the // server. // -Tinytest._runTestsEverywhere = function (onReport, onComplete, pathPrefix) { +// Options: +// serial if true, will not run tests in parallel. Currently this means +// running the server tests before running the client tests. +// Default is currently true (serial operation), but we will likely +// change this to false in future. +Tinytest._runTestsEverywhere = function (onReport, onComplete, pathPrefix, options) { var runId = Random.id(); var localComplete = false; + var localStarted = false; var remoteComplete = false; var done = false; + options = _.extend({ + serial: true + }, options); + var serial = !!options.serial; + var maybeDone = function () { if (!done && localComplete && remoteComplete) { done = true; onComplete && onComplete(); } + if (serial && remoteComplete && !localStarted) { + startLocalTests(); + } }; - Tinytest._runTests(onReport, function () { - localComplete = true; - maybeDone(); - }, pathPrefix); + var startLocalTests = function() { + localStarted = true; + Tinytest._runTests(onReport, function () { + localComplete = true; + maybeDone(); + }, pathPrefix); + }; var handle; @@ -59,4 +76,8 @@ Tinytest._runTestsEverywhere = function (onReport, onComplete, pathPrefix) { // XXX better report error throw new Error("Test server returned an error"); }); + + if (!serial) { + startLocalTests(); + } }; diff --git a/scripts/admin/banner.txt b/scripts/admin/banner.txt index c72d6b8ddb..6573d75782 100644 --- a/scripts/admin/banner.txt +++ b/scripts/admin/banner.txt @@ -1,7 +1,5 @@ -=> Meteor 0.8.2: Switch `accounts-password` to use bcrypt on the - server. User accounts will seamlessly transition to bcrypt on the - next login, but this transition is one-way, so you cannot downgrade a - production app once you upgrade to 0.8.2. +=> Meteor 0.8.3: Performance improvements and a big refactoring of the + Blaze internals. This release is being downloaded in the background. Update your - project to Meteor 0.8.2 by running 'meteor update'. + project to Meteor 0.8.3 by running 'meteor update'. diff --git a/scripts/admin/install-engine.sh b/scripts/admin/install-engine.sh index 52b7692a62..463ea985ce 100644 --- a/scripts/admin/install-engine.sh +++ b/scripts/admin/install-engine.sh @@ -141,7 +141,7 @@ Couldn't write the launcher script. Please either: (1) Run the following as root: cp ~/.meteor/tools/latest/launch-meteor /usr/bin/meteor - (2) Add ~/.meteor to your path, or + (2) Add "$HOME/.meteor" to your path, or (3) Rerun this command to try again. Then to get started, take a look at 'meteor --help' or see the docs at @@ -153,7 +153,7 @@ else Now you need to do one of the following: - (1) Add ~/.meteor to your path, or + (1) Add "$HOME/.meteor" to your path, or (2) Run this command as root: cp ~/.meteor/tools/latest/launch-meteor /usr/bin/meteor diff --git a/scripts/admin/notices.json b/scripts/admin/notices.json index 3b002c98b5..4e93ea80c9 100644 --- a/scripts/admin/notices.json +++ b/scripts/admin/notices.json @@ -149,6 +149,9 @@ ] } }, + { + "release": "0.8.3" + }, { "release": "NEXT" } diff --git a/scripts/admin/publish-release/server/publish-release.js b/scripts/admin/publish-release/server/publish-release.js index 75caa7c2cf..d33885113d 100644 --- a/scripts/admin/publish-release/server/publish-release.js +++ b/scripts/admin/publish-release/server/publish-release.js @@ -59,7 +59,15 @@ var configureS3 = function () { return {accessKey: accessKey, secretKey: secretKey}; }; - var s3Credentials = getS3Credentials(); + var s3Credentials; + if (process.env.AWS_ACCESS_KEY_ID) { + s3Credentials = {}; + s3Credentials.accessKey = process.env.AWS_ACCESS_KEY_ID; + s3Credentials.secretKey = process.env.AWS_SECRET_ACCESS_KEY; + } else { + s3Credentials = getS3Credentials(); + } + var s3 = new S3({ accessKeyId: s3Credentials.accessKey, secretAccessKey: s3Credentials.secretKey, diff --git a/tools/selftest.js b/tools/selftest.js index ac707e7f7d..10880661ad 100644 --- a/tools/selftest.js +++ b/tools/selftest.js @@ -1026,6 +1026,7 @@ _.extend(Run.prototype, { self._ensureStarted(); var timeout = self.baseTimeout + self.extraTime; + timeout *= utils.timeoutScaleFactor; self.extraTime = 0; return self.stdoutMatcher.match(pattern, timeout, _strict); }), @@ -1036,6 +1037,7 @@ _.extend(Run.prototype, { self._ensureStarted(); var timeout = self.baseTimeout + self.extraTime; + timeout *= utils.timeoutScaleFactor; self.extraTime = 0; return self.stderrMatcher.match(pattern, timeout, _strict); }), @@ -1081,6 +1083,7 @@ _.extend(Run.prototype, { self._ensureStarted(); var timeout = self.baseTimeout + self.extraTime; + timeout *= utils.timeoutScaleFactor; self.extraTime = 0; self.expectExit(); @@ -1098,6 +1101,7 @@ _.extend(Run.prototype, { if (self.exitStatus === undefined) { var timeout = self.baseTimeout + self.extraTime; + timeout *= utils.timeoutScaleFactor; self.extraTime = 0; var fut = new Future; diff --git a/tools/skel/~name~.html b/tools/skel/~name~.html index 8a6749bfed..ce4a85e82d 100644 --- a/tools/skel/~name~.html +++ b/tools/skel/~name~.html @@ -3,11 +3,12 @@ +

Welcome to Meteor!

+ {{> hello}} diff --git a/tools/skel/~name~.js b/tools/skel/~name~.js index 50433270a2..f8bee6b4ce 100644 --- a/tools/skel/~name~.js +++ b/tools/skel/~name~.js @@ -1,13 +1,17 @@ if (Meteor.isClient) { - Template.hello.greeting = function () { - return "Welcome to ~name~."; - }; + // counter starts at 0 + Session.setDefault("counter", 0); + + Template.hello.helpers({ + counter: function () { + return Session.get("counter"); + } + }); Template.hello.events({ - 'click input': function () { - // template data, if any, is available in 'this' - if (typeof console !== 'undefined') - console.log("You pressed the button"); + 'click button': function () { + // increment the counter when button is clicked + Session.set("counter", Session.get("counter") + 1); } }); } diff --git a/tools/test-utils.js b/tools/test-utils.js index bbcf5df71b..9bea1296fa 100644 --- a/tools/test-utils.js +++ b/tools/test-utils.js @@ -2,6 +2,7 @@ var _ = require('underscore'); var release = require('./release.js'); var uniload = require('./uniload.js'); var config = require('./config.js'); +var utils = require('./utils.js'); var randomString = function (charsCount) { var chars = 'abcdefghijklmnopqrstuvwxyz'; @@ -12,7 +13,7 @@ var randomString = function (charsCount) { return str; }; -exports.accountsCommandTimeoutSecs = 15; +exports.accountsCommandTimeoutSecs = 15 * utils.timeoutScaleFactor; exports.randomString = randomString; diff --git a/tools/utils.js b/tools/utils.js index dee445b4da..fad9f7570f 100644 --- a/tools/utils.js +++ b/tools/utils.js @@ -357,3 +357,9 @@ exports.ensureOnlyExactVersions = function (dependencies) { }); }; +// Allow a simple way to scale up all timeouts from the command line +var timeoutScaleFactor = 1.0; +if (process.env.TIMEOUT_SCALE_FACTOR) { + timeoutScaleFactor = parseFloat(process.env.TIMEOUT_SCALE_FACTOR); +} +exports.timeoutScaleFactor = timeoutScaleFactor;