Client-side simulation problem?

I am calling a Meteor method which operates a bit on the input and then makes a collection update call. The Node debug inspector and console.log() calls both confirm that everything is fine server-side, and it is also fine client-side right up until the MyCollection.update() call. Immediately after that call, the client loses the information which was updated (even though that information was modified not added or removed) and looks terrible (because it expects something to always be there) until the server’s problem-free execution makes its way to the client as the actual/canonical result and everything is fine again. I can work around this by protecting the collection update call with a Meteor.isServer if-block, but that seems to defeat the purpose of Meteor’s “latency compensation”.

What might I be doing wrong or misunderstanding about this?

I think your description might be a bit too abstract to understand what’s going on – can you share some code? Ideally, where the “client loses the information” and “looks terrible” along with your method and method call?

It sounds like you need to ensure you have a client-side method to perform optimistic UI (aka latency compensation). You may be able to just use the same method on both client and server (e.g. import into both), or you could write something different for the client.

You can also make use of Meteor.isSimulation to refine the flow in your codebase (e.g if you are sharing the same method code but need different or targetted behaviour).

You could also use mdg:validated-method which helps in removal of boilerplate and may give you more predictable behaviour.

It is a shared Meteor method.

Meteor.methods({
  Inventory_Transfer: function (gameId, item, origin, destination, quantityToTransfer) {
    if (! Meteor.userId()) {
      throw new Meteor.Error('not-authorized', 'User is not logged in.');
    }

    let game = Games.findOne(gameId);
    if (_.isEmpty(game)) {
      throw new Meteor.Error('invalid-input', 'Invalid gameId.');
    }
    if (Meteor.userId() != game._host && game._players.indexOf(Meteor.userId()) == -1) {
      throw new Meteor.Error('not-authorized', 'User is not playing this game.');
    }

    item = utility.loadObject(item);
    if (!(item instanceof app.classes.Item)) {
      throw new Meteor.Error('invalid-input', 'Cannot add non-items to an Inventory.');
    }

    let originObject = game;
    if (_.startsWith(origin, 'character.')) {
      let characterId = origin.slice(10, origin.indexOf('.', 10));
      originObject = Characters.findOne({_game: gameId, _id: characterId});
      if (_.isEmpty(originObject)) {
        throw new Meteor.Error('invalid-input', 'Invalid origin.');
      }
    } else if (_.startsWith(origin, 'party.')) {
      originObject = game.party;
    } else if (_.startsWith(origin, 'currentGameEventState.')) {
      originObject = game.currentGameEventState;
    }
    origin = origin.slice(origin.lastIndexOf('.') + 1)

    let destinationObject = game;
    if (_.startsWith(destination, 'character.')) {
      let characterId = destination.slice(10, destination.indexOf('.', 10));
      destinationObject = Characters.findOne({_game: gameId, _id: characterId});
      if (_.isEmpty(destinationObject)) {
        throw new Meteor.Error('invalid-input', 'Invalid destination.');
      }
    } else if (_.startsWith(destination, 'party.')) {
      destinationObject = game.party;
    } else if (_.startsWith(destination, 'currentGameEventState.')) {
      destinationObject = game.currentGameEventState;
    }
    destination = destination.slice(destination.lastIndexOf('.') + 1)

    if (_.isEqual(originObject, destinationObject) && (origin == destination)) {
      return; // ignore attempts to transfer to and from the same place
    }
    if (!(originObject[origin] instanceof app.classes.Inventory) || !(destinationObject[destination] instanceof app.classes.Inventory)) {
      throw new Meteor.Error('invalid-input', 'Must transfer from an inventory, to an inventory.');
    }
    if (!originObject[origin].hasItem(item)) {
      throw new Meteor.Error('invalid-input', 'Origin is missing item for transfer.');
    }

    quantityToTransfer = math.min(quantityToTransfer, item.quantity);
    let itemToTransfer = utility.loadObject(_.cloneDeep(item));
    itemToTransfer.quantity = quantityToTransfer;
    originObject[origin].removeItem(itemToTransfer);
    destinationObject[destination].addItem(itemToTransfer);

    // On both client and server, these are never incorrect.
    console.log(game.party.inventory);
    console.log(game.currentGameEventState.inventory);
    if (Meteor.isServer) {
      if (originObject instanceof app.classes.Character) {
        Characters.update({ _id: originObject._id }, {
          $set: {
            [origin]: originObject[origin]
          }
        });
      }
      if (destinationObject instanceof app.classes.Character) {
        Characters.update({ _id: destinationObject._id }, {
          $set: {
            [destination]: destinationObject[destination]
          }
        });
      }
      if (!(originObject instanceof app.classes.Character) || !(destinationObject instanceof app.classes.Character)) {
        Games.update(gameId, game);
      }
    }
  }
});

If I run this without the final Games.update() call, then the client-side simulation results in game.currentGameEventState.inventory being an empty object instead of an object with an array .items. The client template feeds a jQuery UI DataTable with that .items array.

  let oldData = templateInstance.data.tableData;
  templateInstance.autorun(function () {
    let templateInstance = Template.instance();
    let templateData = Template.currentData();
    if (_.isArray(templateData.tableData) && !_.isEqual(templateData.tableData, oldData)) {
      if (_.isEmpty(templateData.tableData)) {
        // This triggers once per transfer method call, even when nothing is supposed to be empty.
        console.log(templateData.tableClass + ' data is empty.');
      }
      let activeItemRow = $('table.itemTable tbody tr.selected');
      let activeItemTable = activeItemRow.parents('table.itemTable.js-isDataTable');
      let thisTable = templateInstance.$('table.itemTable.js-isDataTable');
      let reselectItem = false;
      if (activeItemTable[0] == thisTable[0]) {
        let itemData = activeItemTable.dataTable().api().row('.selected').data();
        reselectItem = itemData;
      }
      thisTable.dataTable().api().clear().rows.add(templateData.tableData).draw();
      if (reselectItem) {
        thisTable.dataTable().api().row(function ( index, data, node ) {
            return data.matches(reselectItem);
          }).select();
      }
      oldData = templateData.tableData;
    }
  });

Without the isServer protection, the reselection fails (because the new data might actually be empty), the table contents blink (because they are temporarily empty and the table is redrawn once empty and once full again), and I thought that there was a third visual artifact but I’m failing to remember or replicate it right now.

I don’t believe that this should require different functionality between the client and server. I have other methods which make collection document updates without trouble, although they are probably all simpler (e.g. updating a specific single string or number value rather than an array inside an object at a dynamic path location).