mut (and set) and autotracking in Ember Octane

Understanding a surprising behavior—and fixing a refactoring hazard.

Assumed audience: Software developers working with Ember Octane.

Yesterday, while I was double-checking some new Ember Octane code in the LinkedIn app, I ran into a scenario that surprised me — and I suspect it might surprise you, too!

Here’s a minimal version of the code that surprised me — a single component which uses mut (or the set helper from ember-simple-set-helper) to change a value on the backing class when an item is clicked.1

Backing class (confusing.js):

import Component from '@glimmer/component';

export default class Confusing extends Component {
  surprising = true;
}

Template (confusing.hbs):

<button {{on "click"
  (fn (mut this.surprising) (not this.surprising))
}}>
  {{this.surprising}}
</button>

As you click the button, it will change the value from true to false and back again. (You can see this working in this Ember Twiddle.) This surprised me because surprising on the backing class is not explicitly tracked. It seems like it shouldn’t change the value! What’s more, if we changed the implementation not to use mut, but to use a regular action instead, it wouldn’t work!

import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class Confusing extends Component {
  surprising = true;
  
  @action toggle() {
    this.surprising = !this.surprising;
  }
}
<button {{on "click" this.toggle}}>
  {{this.surprising}}
</button>

You can see in this twiddle: the value does not change!

I initially suspected this was a quirk with mut, which has a lot of strange behaviors, so I went back and tried it with ember-simple-set-helper instead.2 Unfortunately, I can’t share a Twiddle for this, but the implementation looks just like the mut version, but a bit nicer in the template:

<button {{on "click" (set this.surprising (not this.surprising))}}>
  {{this.surprising}}
</button>

Once again, it works! So the problem is not specific to mut; there’s something about both mut and set which makes this work, while regular actions using normal Octane idioms don’t work. What’s up?

Under the hood, both mut and set use Ember’s set function, and when templates reference values, they use Ember’s get function. Both of these implicitly auto-track the values they consume. They have to for Ember’s backwards compatibility story to hold: this is how you can freely mix Classic code and Octane code and everything just works.”

However, as we saw above, this is a serious refactoring hazard: the second you switch from using mut or set to a normal action, everything stops working. To make this safe, we simply need to stop depending on implicit behavior of mut and set, and explicitly track the value:

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class Confusing extends Component {
  @tracked surprising = true;
  
  @action toggle() {
    this.surprising = !this.surprising;
  }
}

This has no impact on the behavior of the version using mut or set, but it is robust in the face of refactoring, and if mut is ever deprecated or a version of set is released that does not use the set function under the hood, it will keep working correctly.

Thoughts, comments, or questions? Discuss on the forum!


Notes

  1. I’m assuming the existence of a not helper like the one from ember-truth-helpers here. If you don’t have that, here’s the simplest possible implementation:

    import { helper } from '@ember/helper';
    
    export default helper(([value]) => !value);
    
    ↩︎
  2. set has better developer ergonomics than mut, as you can see from this example, and it avoids a lot of edges cases that mut has. ↩︎