Skip to content

Instantly share code, notes, and snippets.

@wycats
Last active January 31, 2019 15:55
Show Gist options
  • Save wycats/2918ea507ff180ae4991 to your computer and use it in GitHub Desktop.
Save wycats/2918ea507ff180ae4991 to your computer and use it in GitHub Desktop.

Object Model Reform

Summary

There are several quirks of the existing Ember object model that are based on the fact that we enlist Ember users quite a bit to help us in managing the data flow graph of an Ember object. This proposal attempts to preserve the data flow analysis, but cutting the user out of the loop of it entirely.

We have an opportunity with ES5 getters/setters, ES6 classes, and ES.later decorators/private state to drastically simplify the object model, eliminating the need for Ember.Object entirely, while still preserving observability.

The key aspect of this proposal is creating a strong separation between private properties and the public interface, limiting observability to the public interface.

Motivating Example

There has been some desire to use ES6ification as an opportunity a la Glimmer Component, and this motivating example does precisely that.

@ember class Person {
  @reader #firstName;
  @reader #lastName;

  constructor(firstName, lastName) {
    this.#firstName = firstName;
    this.#lastName = lastName;
  }
}

@ember class NuclearFamily {
  @reader #father;
  @reader #mother;
  @reader #children;

  constructor(father, mother, children) {
    this.#father = father;
    this.#mother = mother;
    this.#children = children;
  }

  get familyName() {
    return `Mr. and Mrs. ${this.#father.firstName} ${this.#father.lastName}`
  }
}

class FamilyComponent {
  @tracked #sortOrder = 'firstName';

  get #children() {
    let family = this.args.family;
    return family.children.sortBy(this.#sortOrder);
  }

  @action // bind `this` 
  sortBy(sortKey) {
    this.#sortOrder = sortKey;
  }
}
<div>
  <h1>{{@family.familyName}}</h1>

  <p>
    <span>Sort by:</span>
    <button {{on click=(action this.sortBy 'firstName')}}>First Name</button>
    <button {{on click=(action this.sortBy 'lastName')}}>Last Name</button>
  </p>

  <ul>
  <!-- templates have access to their components' private properties -->
  {{#each this.#children as |child|}}
    <li>{{child.fullName}}</li>
  {{/each}}
  </ul>
</div>

The @ember class annotation means "some public properties in this class are used in a template or accessed by a public getter in another @ember object. In essence, it makes all public properties and getters @tracked.

Before I explain how it works, things that are worth calling out:

  1. There is no Ember.Object base class; what this means practically is that there are no special methods on @ember objects, and all property access is done normally.
  2. "Computed properties" don't exist anymore. Tracked properties replace computed properties, even with private state.
  3. The component class did not need to be a special superclass.
  4. Templates are basically unchanged, but they can access the private state of their component using this.#state.

Eliminated Concepts

This proposal introduces a few concepts, but it eliminates a number of existing concepts (outside of code using Ember.Object and objects interacting with Ember.Objects of course):

  • Ember.Object
  • .set and .get
  • .notifyPropertyChange
  • observers
  • computed properties (replaced by the @uses annotation on getters)
  • Ember.Component and Ember.GlimmerComponent (we might provide either a mixin or base class for convenience)
  • .set on components
  • name pollution from the Ember object superclass

The replacements:

  • Ember.Object, with all its extra methods, is replaced with @ember
  • .set and .get are eliminated, replaced by regular setters and a distinction between public and private state.
  • .notifyPropertyChange is eliminated, tracked by setters
  • observers are eliminated entirely as a programming model concept
  • computed properties are replaced by @tracked.
  • Ember.Component and Ember.GlimmerComponent are replaced with regular ES6 classes and a handful of annotations.
  • .set on components is eliminated, replaced with explicit private properties in the component accessed by the template.

New concepts:

  • the @ember annotation
  • separation of public and private state
  • the @reader and @accessor annotations on private fields
  • the @action annotation on components

Public vs. Private

The primary shift here that drives the rest of the design is that there is a strong separation between public accesses and private state.

This proposal assumes JavaScript private state using the this.#foo notation.

When using the @ember annotation, the primary difference between public and private properties is that public properties are managed by Ember, allowing us to update the DOM efficiently on change.

This makes sense: an object's public interface is "reactive", while its private interface should not be observed by outside objects.

Ember CLI would lint against:

  • direct use of the public interface from inside a class: this.foo and this.foo = bar
  • access to public properties of the component in the template; the template is a part of the object, not a separate object communicating through a public interface. get #foo can be used for private getters.

The semantics of private state in JavaScript make linting against inappropriate access to private state unnecessary.

Mechanics

Under the hood, Ember uses getters and setters to manage the public and private state, as well as observation.

The @reader annotation

The reader annotation is a decorator that defines a public property whose getter delegates to the same-named private property.

  • It is read-only, attempts to set the property will throw an exception.
  • It is tracked, more later

The @accessor annotation

The accessor annotation is a decorator that defines a @reader as well as a setter that delegates to the same-named private property.

Dependency Tracking

Consider this getter:

get familyName() {
  return `Mr. and Mrs. ${this.#father.firstName} ${this.#father.lastName}`
}

The @ember annotation finds all getters and adds @tracked.

When the instrumented getter is accessed, Ember starts collecting dependencies.

First, the getter gets #father a private field.

Next, it accesses .firstName, which is a public reader. The getter implementation tells Ember that it was called, adding the dependency to the dependency list.

When the getter finishes, it has a list of dependencies, and it registers interest in being notified about changes (the various strategies we've been considering for making this cheaper are very applicable here).

Because we're not using Ember.Object, it would be possible to use efficient primitives from the templating engine directly, which is a topic for another RFC.

Smooth Refactoring

At the beginning of the lifecycle of an object, all properties are private, and you might have a few methods that allow other objects to interact with them.

class Person {
  #first, #last;

  constructor(first, last) {
    #first = first;
    #last = last;
  }
  
  // not private it's a public API
  isKatz() {
    return this.#last === 'Katz'
  }
}

This is not a very useful object. Let's imagine your model hook returned one of these, and you're trying to do something with it in a template:

<p>{{model.#first}} {{model.#last}}</p>

If you try to do this, JavaScript disallows you from accessing those names on another object (because private state is truly private and lexical).

The solution is simple:

class Person {
  @reader #first;
  @reader #last;

  constructor(first, last) {
    #first = first;
    #last = last;
  }
  
  // not underscored, so it's a public API
  isKatz() {
    return #last === 'Katz'
  }
}

And now you can use it in your template just fine:

<p>{{model.first}} {{model.last}}</p>

The fact that neither #first nor #last is marked @tracked means that this class doesn't have any side effects that could trigger re-renders.

If you wanted to encapsulate the code that creates the full name into a getter:

class Person {
  @reader #first;
  @reader #last;

  constructor(first, last) {
    this.#first = first;
    this.#last = last;
  }

  // the annotation makes it clear that modifying `#first` or `#last` can trigger
  // side effects.
  @tracked get fullName() {
    return `${this.#first} ${this.#last}`;
  }
  
  // not underscored, so it's a public API
  isKatz() {
    return #last === 'Katz'
  }
}

Local Reasoning

This model builds on the improved local reasoning of @tracked.

By making a property @readonly, you can be sure that it will only be mutated by the class itself, and not external code. And because it's a public property, it still can be safely accessed from the outside.

One of the properties of this system is that the annotated JavaScript class behaves exactly the same as an unannotated JavaScript class. The only difference is that the private/public split creates an implicit notification channel from manipulations of private properties to the DOM, but that's not a part of the programming model of working with your JavaScript objects. If the DOM updates, the closest component will receive a didUpdate hook, but only once per run loop, and only if the component actually survived (a parent component didn't remove it).

  • Reading a property is always benign and has no non-local side effects
  • Writing a private property that is not a @tracked is benign and has no non-local side effects.
  • Writing a private property that is @tracked can have non-local side effects, but only through an object that has an instance in its private state, and only if that property is @reader.

An Illustrative Example

class Person {
  @tracked @reader #first;
  @tracked @reader #last;
  
  constructor(first, last) {
    this.#first = first;
    this.#last = last;
  }
  
  update(fullName) {
    let [ first, last ] = fullName.split(' ');
    this.#first = first;
    this.#last = last;
  }
}
class Family {
  #father;
  #mother;

  constructor(father, mother) {
    this.#father = father;
    this.#mother = mother;
  }
  
  updateFather(name) {
    this.#father.update(name);
  }
  
  updateMother(name) {
    this.#mother.update(name);
  }
}

Now let's try to use these objects in a component.

class FamilyComponent {
  #family;
  @tracked #fatherName;
  @tracked #motherName;

  constructor(attrs) {
    // the parent component passed in a father and mother as attrs.father and attrs.mother
    this.#family = new Family(father, mother);
    
    // Copy out the names so we can update them. All of this is kosher
    // since we're reading from public properties and writing to
    // private properties on ourself.
    this.#fatherName = `${this.#father.first} ${this.#father.last};
    this.#motherName = `${this.#mother.first} ${this.#mother.last};
  }
  
  @action
  #updateFather(name) {
    this.#family.updateFather(#fatherName);
  }
  
  @action
  #updateMother(name) {
    this.#family.updateMother(#motherName);
  }
}

And a template:

<p>{{@father.first}} {{@father.last}}</p>
<p>{{@mother.first}} {{@mother.last}}</p>

<!-- use the scratch pad values we created -->
<input-field value={{this.#fatherName}} {{on enter=this.#updateFather}} />
<input-field value={{this.#motherName}} {{on enter=this.#updateMother}} />

In this example, it's quite safe to use {{mut this.#fatherName}}, because it's just internal state on the component.

When the user hits enter on the first input field, the #updateFather action will get fired. That action calls this.#family.updateFather(name);.

Next, the updateFather method calls this.#father.update(name);.

Finally, the update() method on Person modifies its this.#first and yhis.#last. Since those properties are @tracked, we know that changing them might have an effect. In this case, indeed it does!

<p>{{@father.first}} {{@father.last}}</p>
<p>{{@mother.first}} {{@mother.last}}</p>

The public first and last properties on @father can see the change, and will update with the new values.

As you can see, this is a very clear unidirectional data flow loop, and it is simply a side effect of separating out your public interface from your private state.

If you disconnect the DOM from your program, your objects will behave exactly the same as a regular JavaScript object.

Each part of this small program (the two models, the component and the template) are very easily about to reason locally about its responsibilities, but the public/private enforcement means that as you scale up, things keeps working. Since the data flow is implicit, there's no good way to screw it up.

The nice thing about this system is that the public/private split simply creates an implicit data flow channel through the public properties. However, that channel is not observable in your Ember classes; it is simply used by Ember to propagate information back to the template. Rather than having to manually create a unidirectional data flow loop, Ember does it for you!

You also aren't required to add @tracked to every intermediate getter in your program--just the ones accessed directly from the template.

The Programming Model

While internally, Ember is doing a whole bunch of dependency tracking, that is no longer a primary focus of the programming model.

  1. The absence of observers means that users will not participate in the low-level operation of the tracking system
  2. Users will be allowed to mutate private state with abandon, as if they were writing regular JavaScript code.
  3. As a result, the semantics of the tracking system are pretty much invisible to the user; they write regular JavaScript code, and we do enough tracking so that we know which parts of the DOM we need to update.

Essentially, this proposal closes the leak between the regular JavaScript programming model and the work we have historically made people do to help us update the DOM efficiently.

Private properties can always be mutated safely without having to worry about an unexpected non-local effect, and communication between objects is always done through the public interface.

@ef4
Copy link

ef4 commented Sep 13, 2015

reader may need a better name -- it may only make sense to Rubyists.

While this looks like a big change, I think it can be rolled out very unobtrusively. Unlike glimmer, there is no moment where we would need to leap into a while new model.

@jamescdavis
Copy link

This is obviously not yet an RFC, but FWIW, as a non-Rubyist, I agree with @ef4 (I had to look up attr_reader and attr_accessor). What about @readable and @writable? But maybe @writable should provide only a setter (like attr_writer)? We could have @readwritable or @accessible? Or, you could stack them:

@readable @writable #first;

That would be very explicit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment