Flicker from optimistic UI and partially simulated methods

Consider the following method:

Meteor.methods({
  update(id, changes) {
    changes.updated = new Date;
    Messages.update({_id: id}, {$set: changes});
  }
})

When I call e.g. Meteor.call('update', id, {format: 'markdown'}) on the client and observe the message in React with useTracker(() => Message.findOne(id)), I observe the following flickering sequence:

  1. The format changes to the intended value markdown.
  2. The format reverts to the original value.
  3. The format changes to the intended value markdown.

I surmise that the following optimistic UI behavior is happening:

  • Minimongo detects that the client’s and server’s changes are different, because they obtain different values for new Date.
  • Minimongo therefore unrolls the client’s changes, and then applies the server’s changes.

What seems broken to me is that Minimongo updates the client after unrolling the client’s changes but before applying the server’s changes. Minimongo has pauseObservers and resumeObservers to fix exactly this kind of flickering, but if I’m reading the source code right, this is only done when receiving updates from the server but not when rolling back expired updates from the client (?). Is this a bug, or intended behavior for a reason I’m missing?

One workaround I tried for these type of updates is performing changes.updated = new Date only if (Meteor.isServer). But the updates still seem to clash, so I get the same flickering behavior. Can you think of another workaround for setting reliable (server-defined) “last updated” dates?

I feel like this is a fundamental issue, but I couldn’t find a solution anywhere; apologies if I missed it.

2 Likes

Just curios, your withTracker lives in a React Container?
Ok, in a React (functional) component, you have props and local state. Your rendered content should be dependent on the state. Your props should be managed within the useEffect hook.

You may see flickers because your page re-renders with no use. Sometimes pages re-render multiple times and because there are no changes the developer doesn’t see it (I mean visually in UI) but that affects performance/efficiency.

I’d say, beside what is going on in the pipeline of props, can you manage the rendering at the componendDidUpdate or useEffect level?

1 Like

The React code could be as simple as this:

function Message({id}) {
  const message = useTracker(() => Message.findOne(id));
  <div>
    {message ? message.format}
  </div>
}

The issue is that message.format changes on the client from the old value to the new value, back to the old value, and then to the new value again. These are real changes, not null changes, so I don’t see how to avoid them… unless I add a slight delay with something like:

  [delayedMessage, setDelayedMessage] = useState()
  useEffect(() => {
    const t = setTimeout((() => setDelayedMessage(message)), 100);
    () => clearTimeout(t)
  });
  <div>
    {delayedMessage ? delayedMessage.format}
  </div>
1 Like

I would personally not use this structure. In React data has to stay in either a data container that wraps a component, in a global store like Redux or in a local state. You have to pass your data into a component via the component props or receive it in a component life cycle like ‘componentDidMount’ and push it into the local state. You link the local state into your view (HTML)

1 Like

Honestly the behavior you describe here doesn’t seem to make sense to me. With latency compensation, if you apply the same changes on the client as are applied on the server (as should happen with client method simulation), then this exact scenario should be avoided. Only if a different result is applied by the server should the client side cache be invalidated and the UI change to reflect this.

Do you have a minimal reproduction that I could see to try to figure out why this might happen for you?

1 Like

@copleykj I agree with you. But the changes on the client and server do differ, because of the new Date resolving to different times on client and server. I’d be happy with just the server setting this value, but this is still considered different from the client. I can put together a minimal repro.

@paulishca Isn’t the whole point of latency compensation / optimistic UI to show the user the local changes before they’ve hit the server? (My current solution is to turn off optimistic UI for this method, by not simulating it altogether. But I don’t think this is what Meteor was designed for…) Also, return values from hooks are the new notion of state.

2 Likes

I am observing similar behavior without using new Date(). By modifying the database through .rawCollection().updateOne (inside a transaction) and at the same time simulating the modification on the client through .update: clients changes are applied, then rolled-back, then server changes are applied. I can see all states in the UI. Perhaps a timing issue? Sometimes it seems I only receive the final state (I deduct this from how many times and with what arguments React hooks get fired).

PS: Skipping the simulation as @edemaine suggested works around the problem in this particular case. But it would be much better to understand how to make optimistic UI behave correctly. Perhaps the solution lies in calling Meteor.refresh for all affected documents. But I am not ready to implement this yet. I’d rather design a generic/non-manual solution.

Is there a way to disable optimistic UI on a method call from the client? I’m running into a similar issue.

I don’t really want to move the Meteor Method to server-only as sometimes it’s needed to be client/server.

What if you don’t return anything from the method? Only call it with no return.

Not returning a value wouldn’t fix my problem. I have a weird edge-case situation with optimistic UI and shared user reactivity. I’ll explain - sorry for the length:

The Problem

I have a template SharedProductViewer that multiple users in different logged in sessions can collaboratively use to review a Product together. SharedProductViewer gets updated with a new set of Product data based on an index passed into it. The index is from an array of chosen grouped Products to review. ALL the Products are already loaded into minimongo on the client for SharedProductViewer. This helps with UI quickness when switching products.

When this product switch happens, it needs to reset a bunch of user-enabled product view states (e.g. render type, layers, etc.) that are controlled by additional mongo fields on a SharedProductViewer document that powers the SharedProductViewer template. The SharedProductViewer template needs to reset basically, but in the design, it does not get destroyed or recreated when switching the index, only “rehydrated” with the new already loaded data (otherwise this wouldn’t be a problem to reset the views).

Upon switching the index, the SharedProductViewer view state fields are reset on the server in a Collection hook. This is how they get reset.

When doing a Meteor Method call to updateIndex from the client, the index, like all the viewer fields, needs to be persisted to the database to drive reactivity from one user making an update and having it affect all other collaborative users looking at SharedProductViewer. It will indeed update the index and the SharedProductViewer template updates immediately due to optimistic UI (as all the Products are already loaded into minimongo). However, the reset views which are also tied to SharedProductViewer database fields, get updated in a Collection hook when the index gets updated. Basically the server cleaning up the view upon switching the Product index as there are multiple “views” open by various logged in users collaborating. The viewer’s state isn’t tied to a user session, it’s tied to the database (a very Meteor use-case BTW).

The problem is the UI flickers when the next Product is shown in the SharedProductViewer template because it momentarily shows the enabled views because they don’t get reset until the Collection hook resets them.

I have an autorun that uses Template.currentData() to update the viewer reactively. This is the order of what happens:

  1. Meteor method call to client/server updateIndex that updates the index field only.
  2. This causes optimistic UI to trigger the autorun because it threads into Template.currentData(). This makes SharedProductViewer go to the next product with the unwanted views enabled before the server collection hook updates finish.
  3. The server Collection hooks run and reset all the views. This triggers the autorun with Template.currentData() and resets the views.

Some Solutions

If there was a way to just call updateIndex with optimistic UI disabled, this problem wouldn’t happen as all the updates would come back from the server together. As an interesting note, this problem only happens when a user updates the index from their client SharedProductViewer. If the update comes from another user, it works exactly right because all the updates are coming from the server together - i.e. there’s no local optimistic UI update to index that flickers the view.

I could make updateIndex server only, but all the Products are loaded already in the client, so I want to take advantage of that and have the client update quickly. I’d hate to lose optimistic UI and force everything to be server-only just to fix a UI flicker. :face_with_diagonal_mouth:

I tried an approach using ReactiveVar to control the views enabled in SharedProductViewer and just reset the views locally before the Meteor Method call to updateIndex. Basically “controlling” the UI myself manually on the client - and unhinging it from database fields. This doesn’t work because due to reactivity and needing the SharedProductViewer to allow reactive updates from the server from other users. I have to tie those ReactiveVars to Template.currentData() to achieve server reactivity. This then kills this approach as optimistic UI will overwrite my manual attempts control the UI. When it goes to set index in Template.currentData() it will also overwrite all the view fields from the server that have not been updated yet.

Another approach is to simply add the view resets in the mongo updater in the client/server method updateIndex. This then “adds” them from the client making them set correctly for optimistic UI and it works perfectly. But, I don’t really like this approach because this exactly duplicates what the server does and adds extra unwanted mongo code. And the Collection hooks catches it for ALL the various collaborative users when a Product index is updated.

Another approach is to just simply design it so it destroys the template and re-creates it. But this is also overkill and would cause overhead in the UI.

The solution I settled on builds on the above ReactiveVar solution and simply adds another ReactiveVar control boolean enableReactiveUpdates in the template. Before updateIndex is called, I disable enableReactiveUpdates. This bypasses the Template.currentData() call in the autorun. Then in the success callback of updateIndex I enable enableReactiveUpdates in a Tracker.afterFlush().

This approach momentarily stops Template.currentData() (as set by optimistic UI) from changing my manual resets to the UI. So the process looks like this now:

  1. Disable enableReactiveUpdates causing no optimistic UI because Template.currentData() gets by passed in the autorun.
  2. Reset all the ReactiveVar views and a ReactiveVar index that updates the UI instantly.
  3. Call updateIndex (which is just to update the database and all other collaborative viewers).
  4. In the updateIndex success callback, re-enable enableReactiveUpdates. This triggers the autorun and everything is synced up with the server.

This way still keeps the optimistic UI snappy, but doesn’t let any delayed server field values from flickering the UI.

The gotcha is you have to use Tracker.afterFlush() in the success callback of updateIndex or else it will re-enable Template.currentData() too early. The flush makes sure everything from the updateIndex method call is updated to the template. This approach does however basically make it a server-only method.

The ultimate problem to put it simply, is I’m trying to control what happens on the client, when a client update causes server side-effects that have an effect on the UI. We’re years baked in our design at this point, but I will redesign all this at some point.

I think by definition, you cannot have a method defined on the client as a stubless method.

From Ask AI on meteor.com

To call a Meteor method from the client side without the stub or without optimistic UI, you can use Meteor.call or Meteor.callAsync. These methods allow you to call server-side methods from the client.

Here’s an example of how you can call a method from the client:

Meteor.call('todos.updateText', {
  todoId: '12345',
  newText: 'This is a todo item.'
}, (err, res) => {
  if (err) {
    alert(err);
  } else {
    // success!
  }
});

In this example, ‘todos.updateText’ is the name of the method you’re calling, and the second argument is an object containing the parameters you’re passing to the method. The third argument is a callback function that handles the result of the method call.

If you want to use Meteor.callAsync, you can do it like this:

await Meteor.callAsync('removeByIDAsync', { id });

or even like this:

Meteor.callAsync('removeByIDAsync', { id }).then(result => {
   console.log(result);
});

In these examples, ‘removeByIDAsync’ is the name of the method you’re calling, and { id } is the parameter you’re passing to the method.

Please note that if you do not define a stub for your method, method calls are just like remote procedure calls in other systems, and you’ll have to wait for the results from the server. This means that the Optimistic UI feature will not be available.

Sources:

You can do it from within the method:

Meteor.methods({
  myMethod() {
    if (Meteor.isClient) return;
    // do stuff
  }
})

If you only want to do it sometimes, perhaps add an argument to the method and use it accordingly:

Meteor.methods({
  myMethod(skipSimulation) {
    if (Meteor.isClient && skipSimulation) return;
    // do stuff
  }
})
3 Likes

Very slick @edemaine! :100:

I haven’t used optimistic UI for at least 10 years and my response was wrong. I do remember having this kind of challenges in the past.

I suggested to “don’t return anything” which is different from “return nothing” :))) In day to day life they may be the same though haha.