Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] Adds Array Tracking #17751

Merged
merged 1 commit into from
Apr 22, 2019
Merged

Conversation

pzuraq
Copy link
Contributor

@pzuraq pzuraq commented Mar 17, 2019

This PR adds autotracking of arrays and Ember arrays. This is useful for
arrays in particular because they have an indeterminate number of
tracked values, and it would be impossible to actually track all values
manually. The strategy is to check any value that we get back from a
tracked getter method and see if it's an array - if so, we push its tag
directly onto the stack. Later on, if the array is marked dirty via
pushObject or shiftObject or some other method, it'll invalidate the
autotrack stack that included the array.

The one scenario this strategy does not work with current is
creating an array in a getter dynamically:

class Foo {
  get bar() {
    if (!this._bar) {
      this._bar = [];
    }

    return this._bar;
  }
}

In practice, these cases should be fairly rare, since getters should
typically represent derived state from some source value, which would
be tracked. Computed properties/cached getters do add the tag for the
object as well, since their state can be more long lived, so this won't
be an issue for those.

Copy link
Contributor

@chriskrycho chriskrycho left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is 💯! There's one bit of the implementation that should be lightly tweaked from the TS perspective, and I've left a comment to that effect.

Two questions this raises for me (that are born of my ignorance, with no implied commentary on the PR):

  1. What if anything are the performance implications of doing array tracking with this?
  2. Assuming those performance implications are relatively minimal… can we implement this same behavior for POJOs, given how they're often used as dictionaries? If not, what's the difference?

packages/@ember/-internals/utils/lib/trackable-object.ts Outdated Show resolved Hide resolved
@pzuraq
Copy link
Contributor Author

pzuraq commented Mar 18, 2019

@chriskrycho great questions! I'm going to do a bit of of a dive here to add some context, and explain the purpose of this API. We think that it may make sense to make it public at some point in the future, which is why the feature is named TrackableObject and we're not just using the EMBER_ARRAY symbol. Also, apologies, this turned into a bit of a novel! 😅

What is @tracked?

Without getting into the higher level thinking behind tags/references and how they work, the basics of @tracked are:

  1. There is an autotracking stack that is created whenever you are rendering a value
  2. Accessing a tracked property pushes that property's tag onto the stack
  3. Setting a tracked property dirties that tag, telling whatever was rendered it needs to update.

The key thing with this setup is that all values that can change must have all of their gets and sets intercepted, somehow. We must entangle with the stack whenever we access the value, and we must dirty the stack whenever we later change it.

Having to do all that work manually would be a real pain, but luckily we have @tracked, which adds a getter and setter for the property that do this for us! Unfortunately, decorators only work on classes at the moment, so in order to track anything we must define a class, and decorate all properties which can change. This is a bit of boilerplate, but it has some additional benefits in ensuring that users 1. write well-defined class definitions and 2. consistently shape their classes/objects. From an OOP standpoint, this makes the most sense, and indeed, this is all about supporting OOP style design (we'll talk about functional/unidirectional design later on).

But wait, there's a problem! What if we don't know all of the fields that we could possibly want to track ahead of time? How can we support that use case?

Arrays and POJOs

Arrays and POJOs/dictionaries both fall into this category with regards to autotracking. The problem with both of these is there are an infinite number of possible keys, and we can't possibly decorate all of them. So, what are our options here?

Remember, we need to do two things:

  1. Intercept all gets of any key on the object, so we can entangle that key with the autotrack stack.
  2. Intercept all sets of any key on the object, so we can dirty that key and any autotrack stacks it is associated with.

One possible option here is to wrap all accesses to the objects with some getter/setter functions, essentially the KVO methods for Ember arrays today: objectAt, pushObject, replace, etc. This strategy works, but feels a bit clunky - it means users need to learn a whole bunch of new APIs. We can do better, and that's where the TrackableObject API comes in!

Trackable Objects

For now, lets forget about POJOs and focus just on arrays. One key thing about arrays is that whenever you update a single key in an array, you probably want to dirty the entire array. In general, if you consumed the array at any point, you probably operated on more than one key, and you'll likely need to recalculate the whole thing. Even in cases where you don't want to because it would be too expensive, like Glimmer's {{each}}, you still probably want to rely on the same diffing logic that would exist to ensure you aren't recalculating when you push a value or insert one randomly in the middle.

So, we can dramatically simplify our code by focusing on only intercepting the get for the object. If someone is using an array, and any element in that array ever changes, we want to redo everything. This is what the new API does 😄

This is basically the native ES5 Getters optimization for arrays. It means that you'll be able to use arrays in getters, and access them just like a normal array:

class Foo {
  @tracked arr = A();

  get firstElement() {
    return this.arr[0];
  }

  get rest() {
    let [first, ...rest] = this.arr;
    
    return rest;
  }

  get joined() {
    return this.arr.join(', ');
  }
}

Awesome! Now, we only need to track updates to the array so we can dirty it whenever something changes. Unfortunately, we do still need to use interceptor methods for these, like pushObject and replace, but as we saw with the ES5 Native Getters update, this is way way nicer for us, since reading an array is much more common than writing to it.

When we get native Proxy, we can probably start using that to intercept sets instead, but we will no matter what have to wrap arrays or use KVO functions to set values because of this. There's just no way around it 😕

Also, it is worth noting that for arrays, its likely that this strategy will be more performant in the long run. Consider a Proxy in the future - with a proxy, we absolutely could intercept all individual gets to the array and track them. But since arrays are most often used in mass-operations, this results in a ton of accesses going through the proxy. Every time you map over the array, or reduce, or join, you're putting every single access to every single element in the array through that proxy - and most of that is redundant! If you're mapping over an array, you want to track the whole array already.

By contrast, with this strategy we eagerly accept that you'll probably be tracking the whole array. In some cases this isn't true, but this is the exception, and we'll probably already be guarding against doing too much work (as in {{each}}s diffing). So, we only have one extra operation when we access the array, and one whenever we mutate it, which tends to be a much less common operation.

POJOs

So now this brings us back to POJOs. We definitely could make the same optimization for POJOs, it makes sense that any change on a POJO would cause us to basically treat it like a new POJO. However, there are two reasons we would want to avoid this in general:

  1. Tracking every POJOs basically means tracking every object. This would definitely start to build up, especially when tracking the object is redundant, because its tracked properties have been decorated. Arrays are relatively uncommon compared to the number of other objects out there, and doing this eagerly all the time is not ideal.
  2. We still have to intercept sets to a POJO. This means that we would still need to wrap POJOs, or require the use of a method like Ember.set.

We definitely want to move away from Ember.set and the need for interceptor methods in general, so I don't think applying this generally is the right idea just yet. I think the ideal path forward here is that apps convert as many of their POJOs as possible to explicitly tracked classes, whenever the keys on those classes are known ahead of time. From a perf perspective, and a code clarity perspective, this will be a major win.

For cases where the number of tracked properties really is infinite (maybe dynamically generated, etc), we do still support and interop with Ember.get and Ember.set, you just need to use Ember.get to enable tracking. This is better IMO, since it means users are opting into the getter/setter methods. It's also possible to create wrapper classes:

class TrackedMap {
  @tracked values = {};

  get(key) {
    return this.values[key];
  }

  set(key, value) {
    this.values[key] = value;
    
    // Trigger a change
    this.values = this.values;
  }
}

And in the long run, we'll be able to use native Proxy to intercept sets to POJOs. Some dictionary use cases may still prefer to intercept gets directly too, so I think at that point we'll want to make both the TrackableObject and autotrack stack APIs more public, so users can decide.

Is there any way we can drop these getters/setters?

Yes actually! One way we can stop worrying about all these gets/sets and wrapper objects and proxies and KVO methods is to move away from OOP practices, where every object owns its own state, and move toward unidirectional dataflow. If we don't have to intercept every single update to every single object, but can instead centralize state, and know whenever that centralized state changed, then we can use plain arrays and objects for the most part instead.

This is a major shift though. For better or worse, OOP has definitely been the standard in Ember apps, and some apps may prefer OOP patterns, even if others prefer a pure DDAU/unidirectional approach.

I think the changes in Octane are a refinement on our current patterns, and will allow us to shed a lot of technical debt and move more quickly without completely restructuring our apps. From there, we'll be more able as a community to start exploring DDAU patterns more thoroughly, and come up with ways to switch to them as they make sense.

So, TL:DR;

  • TrackableObject will allow us to fill in the gaps in tracked properties for arrays now, and POJOs in the future
  • This enables better OOP style Ember apps, which will help existing apps and the ecosystem now
  • We can (and should!) still explore better unidirectional/non-OOP patterns for data flow and change tracking as we move forward in Octane, and drop all of that tech debt and cruft!

@wycats
Copy link
Member

wycats commented Mar 18, 2019

@pzuraq We need to address this underlying issue, but I'm not sure this is exactly the right solution. Something seems off about this solution but I don't have time to dig into it right now. It's definitely not a bugfix, though.

@pzuraq pzuraq changed the title [BUGFIX] Adds Trackable Objects, Array Tracking [FEAT] Adds Trackable Objects, Array Tracking Mar 19, 2019
@pzuraq
Copy link
Contributor Author

pzuraq commented Mar 19, 2019

@wycats I considered it a bugfix since the TRACKABLE_OBJECT API was still private, and an implementation detail. This API could be made public and applied to more objects in the future, or it could just be limited to arrays and allowing us to track arrays, as it does in this PR. Happy to consider it a feature though, updated the title to reflect that.

We could scope this down to just checking if the value is an array/EmberArray for now, if we want to only focus on the array use cases and not add a generic internal API.

@pzuraq pzuraq force-pushed the bugfix/allow-tracked-to-work-with-arrays branch from 7f341da to 5fcc7e9 Compare March 19, 2019 18:07
@pzuraq pzuraq force-pushed the bugfix/allow-tracked-to-work-with-arrays branch from b7c96f8 to 1770c33 Compare April 3, 2019 01:52
This PR adds autotracking of arrays and Ember arrays. This is useful for
arrays in particular because they have an indeterminate number of
tracked values, and it would be impossible to actually track all values
manually. The strategy is to check any value that we get back from a
tracked getter method and see if it's an array - if so, we push its tag
directly onto the stack. Later on, if the array is marked dirty via
`pushObject` or `shiftObject` or some other method, it'll invalidate the
autotrack stack that included the array.

The one scenario this strategy does _not_ work with current is
_creating_ an array in a getter dynamically:

```js
class Foo {
  get bar() {
    if (!this._bar) {
      this._bar = [];
    }

    return this._bar;
  }
}
```

In practice, these cases should be fairly rare, since getters should
typically represent derived state from some source value, which would
be tracked. Computed properties/cached getters _do_ add the tag for the
object as well, since their state can be more long lived, so this won't
be an issue for those.
@pzuraq pzuraq force-pushed the bugfix/allow-tracked-to-work-with-arrays branch from 1770c33 to 8c4c3e0 Compare April 19, 2019 22:53
@pzuraq pzuraq changed the title [FEAT] Adds Trackable Objects, Array Tracking [FEAT] Adds Array Tracking Apr 19, 2019
@rwjblue
Copy link
Member

rwjblue commented Apr 22, 2019

Chatted with @wycats / @tomdale / @pzuraq and now that this is scoped down to arrays, this is good to go...

@rwjblue rwjblue merged commit e9b09dc into master Apr 22, 2019
@rwjblue rwjblue deleted the bugfix/allow-tracked-to-work-with-arrays branch April 22, 2019 18:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants