diff --git a/packages/ddp-client/common/livedata_connection.js b/packages/ddp-client/common/livedata_connection.js index b9ee59ffcb..b7f2a46c77 100644 --- a/packages/ddp-client/common/livedata_connection.js +++ b/packages/ddp-client/common/livedata_connection.js @@ -237,8 +237,8 @@ export class Connection { // Block auto-reload while we're waiting for method responses. if (Meteor.isClient && - Package.reload && - ! options.reloadWithOutstanding) { + Package.reload && + ! options.reloadWithOutstanding) { Package.reload.Reload._onMigrate(retry => { if (! self._readyToMigrate()) { self._retryMigrate = retry; @@ -523,6 +523,13 @@ export class Connection { }); } + _getIsSimulation({isFromCallAsync, alreadyInSimulation}) { + if (!isFromCallAsync) { + return alreadyInSimulation; + } + return alreadyInSimulation && DDP._CurrentMethodInvocation._isCallAsyncMethodRunning(); + } + /** * @memberOf Meteor * @importFromPackage meteor @@ -560,9 +567,44 @@ export class Connection { "Meteor.callAsync() does not accept a callback. You should 'await' the result, or use .then()." ); } - - return this.applyAsync(name, args, { - isFromCallAsync: true + /* + * This is necessary because when you call a Promise.then, you're actually calling a bound function by Meteor. + * + * This is done by this code https://github.com/meteor/meteor/blob/17673c66878d3f7b1d564a4215eb0633fa679017/npm-packages/meteor-promise/promise_client.js#L1-L16. (All the logic below can be removed in the future, when we stop overwriting the + * Promise.) + * + * When you call a ".then()", like "Meteor.callAsync().then()", the global context (inside currentValues) + * will be from the call of Meteor.callAsync(), and not the context after the promise is done. + * + * This means that without this code if you call a stub inside the ".then()", this stub will act as a simulation + * and won't reach the server. + * + * Inside the function _getIsSimulation(), if isFromCallAsync is false, we continue to consider just the + * alreadyInSimulation, otherwise, isFromCallAsync is true, we also check the value of callAsyncMethodRunning (by + * calling DDP._CurrentMethodInvocation._isCallAsyncMethodRunning()). + * + * With this, if a stub is running inside a ".then()", it'll know it's not a simulation, because callAsyncMethodRunning + * will be false. + * + * DDP._CurrentMethodInvocation._set() is important because without it, if you have a code like: + * + * Meteor.callAsync("m1").then(() => { + * Meteor.callAsync("m2") + * }) + * + * The call the method m2 will act as a simulation and won't reach the server. That's why we reset the context here + * before calling everything else. + * + * */ + DDP._CurrentMethodInvocation._set(); + DDP._CurrentMethodInvocation._setCallAsyncMethodRunning(true); + return new Promise((resolve, reject) => { + this.applyAsync(name, args, { isFromCallAsync: true }) + .then(result => { + DDP._CurrentMethodInvocation._setCallAsyncMethodRunning(false); + resolve(result); + }) + .catch(reject); }); } @@ -586,7 +628,12 @@ export class Connection { const { stubInvocation, invocation, ...stubOptions } = this._stubCall(name, EJSON.clone(args)); if (stubOptions.hasStub) { - if (!stubOptions.alreadyInSimulation) { + if ( + !this._getIsSimulation({ + alreadyInSimulation: stubOptions.alreadyInSimulation, + isFromCallAsync: stubOptions.isFromCallAsync, + }) + ) { this._saveOriginals(); } try { @@ -617,7 +664,12 @@ export class Connection { async applyAsync(name, args, options, callback = null) { const { stubInvocation, invocation, ...stubOptions } = this._stubCall(name, EJSON.clone(args), options); if (stubOptions.hasStub) { - if (!stubOptions.alreadyInSimulation) { + if ( + !this._getIsSimulation({ + alreadyInSimulation: stubOptions.alreadyInSimulation, + isFromCallAsync: stubOptions.isFromCallAsync, + }) + ) { this._saveOriginals(); } try { @@ -680,7 +732,12 @@ export class Connection { // If we're in a simulation, stop and return the result we have, // rather than going on to do an RPC. If there was no stub, // we'll end up returning undefined. - if (stubCallValue.isFromCallAsync) { + if ( + this._getIsSimulation({ + alreadyInSimulation, + isFromCallAsync: stubCallValue.isFromCallAsync, + }) + ) { if (callback) { callback(exception, stubReturnValue); return undefined; diff --git a/packages/ddp-server/livedata_server_tests.js b/packages/ddp-server/livedata_server_tests.js index 966594be2a..aa4cc6f845 100644 --- a/packages/ddp-server/livedata_server_tests.js +++ b/packages/ddp-server/livedata_server_tests.js @@ -315,13 +315,13 @@ Tinytest.addAsync( ); Meteor.methods({ - testResolvedPromise(arg) { - const invocation1 = DDP._CurrentMethodInvocation.get(); + async testResolvedPromise(arg) { + const invocationRunningFromCallAsync1 = DDP._CurrentMethodInvocation._isCallAsyncMethodRunning(); return Promise.resolve(arg).then(result => { - const invocation2 = DDP._CurrentMethodInvocation.get(); - // This equality holds because Promise callbacks are bound to the - // dynamic environment where .then was called. - if (invocation1 !== invocation2) { + const invocationRunningFromCallAsync2 = DDP._CurrentMethodInvocation._isCallAsyncMethodRunning(); + // What matters here is that both invocations are coming from the same call, + // so both of them can be considered a simulation. + if (invocationRunningFromCallAsync1 !== invocationRunningFromCallAsync2) { throw new Meteor.Error("invocation mismatch"); } return result + " after waiting"; diff --git a/packages/minimongo/local_collection.js b/packages/minimongo/local_collection.js index 935ac06cfc..a06bf58aa1 100644 --- a/packages/minimongo/local_collection.js +++ b/packages/minimongo/local_collection.js @@ -20,7 +20,9 @@ export default class LocalCollection { // _id -> document (also containing id) this._docs = new LocalCollection._IdMap; - this._observeQueue = new Meteor._AsynchronousQueue(); + this._observeQueue = Meteor.isClient + ? new Meteor._SynchronousQueue() + : new Meteor._AsynchronousQueue(); this.next_qid = 1; // live query id generator