Async Data and Autotracking in Ember Octane

Async Data and Autotracking in Ember Octane:

Last week, I described the use of a load helper and associated AsyncData type to move away from Ember’s ObjectProxy and PromiseProxyMixin .

In this post, I’ll dig into the implementation of load and AsyncData . When you get to the end of this post, you should not only understand how this particular helper and data type work, but also have a better idea of how to think about both handling asynchronous data in JavaScript in general and how to put that to practice in Ember Octane with autotracking specifically.

Give it a read! Comments welcome here!

4 Likes

Thanks, @chriskrycho! How does this intersect with the new destroyables API and the new invokeHelper? Are they complimentary or do they replace some of the code you wrote for this?

1 Like

Thanks for the kind words. And I’m glad you asked! Part of what has me excited about something like load is that it does work so nicely with the upcoming invokeHelper. I ultimately envision the result being something that looks like this (where load itself does the right things with invokeHelper under the hood):

import Component from '@glimmer/component';
import { use } from '@glimmer/resource'; // NOTE: made up this import
import { inject as service } from '@ember/service';
import { load } from 'my-app/helpers/load';

export default class MyComponent extends Component {
  @use someData = load(this.store.queryRecord('user', this.args.userId));
}

In short, load will simply be tweaked to work as a helper invokable in both JS and the template, and you won’t need to do the extra work combing getters with load (as I showed in Migrating Off of PromiseProxyMixin in Ember Octane):

import Component from '@glimmer/component';
import { use } from '@glimmer/resource'; // NOTE: made up this import
import { inject as service } from '@ember/service';
import { load } from 'my-app/helpers/load';

export default class MyComponent extends Component {
  get someData() {
    return load(this.store.queryRecord('user', this.args.userId));
  }
}

Just as importantly, it will compose nicely with the new caching and memoization APIs, so it’s a bigger difference than just going from 3 lines to 1 line.

2 Likes

@chriskrycho thank you for the post. This pattern looks really nice and like a better alternative to the ProxyMixin.

However, I tried it in one of our apps, running Ember 3.20 and it turns into an infinite re-loading. Here is a snippet of the code.

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

export default class PeopleComponent extends Component {
  @service('store') store;

  get query() {
    return this.store.query('person', { filter: { id: this.args.value } });
  }

  get people() {
    return load(this.query);
  }
}
{{#let (load this.query) as |data|}}
  {{#if data.isLoaded}}
    {{#each data.value as |person|}}
      {{person.firstName}}
    {{/each}}
  {{/if}}
{{/let}}
{{#if this.people.isLoaded}}
  {{#each this.people.value as |person|}}
    {{person.firstName}}
  {{/each}}
{{/if}}

Both template example, keep refetching the data and the state never changes to LOADED.

Huh. That’s super weird; we’ve never hit that. Is the query itself just a normal Ember Data query? And it looks like it’s returning an array of data? Is it actually returning a PromiseArrayProxy?

Yes. It is a normal Ember Data query. I have tried similar thing, just manually setting the value to result.toArray() as well, which produces the same result.

I’ll try to reproduce it later today and see if I can understand what the problem is. If you can create an Ember Twiddle or push up a small example app to GitHub or GitLab or something which reproduces it, that would be super helpful.

Are you using the version from the end of the blog post, the version from the gist I posted which has our full implementation, or your own implementation?

I will try and create an example later today.

I am using the version from the blog post, but I have tried to implement something similar as well.

Here is what I have currently and is working

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { computed } from '@ember/object';
import { inject as service } from '@ember/service';

export default class PeopleComponent extends Component {
  @service('store') store;

  @computed('args.ids')
  get people() {
    return this.fetchPeople(this.args.ids);
  }

  fetchPeople(ids) {
    let data = new AsyncData();
    
    this.store.query('person', { filter: { id: ids } }).then((result) => {
      data.resolve(result.toArray());
    });

    return data;
  }
}

class AsyncData {
  @tracked state = 'loading';
  @tracked value = [];

  get isLoading() {
    return this.state == 'loading';
  }

  get isLoaded() {
    return this.state === 'loaded';
  }

  resolve(value) {
    this.state = 'loaded';
    this.value = value;
  }
}

I built a quick demo that you can find here:

The helper version is working ok - loads the records and it does that just once, but the property based one is giving an error because it makes more than one requests.

Which makes me wonder why it did not work before that… :slight_smile:

PS Actually which the first button that you click makes two requests, doesn’t matter which one is it. After that - just one

I added one new component Query which uses Store.query for fetching, and this is causing the infinite loop that I was talking about.

Iiiiinteresting. I will poke at it tomorrow! Thanks for the clear reproduction!

@mupkoo the repo appears to be private! As it happens I was able to reproduce this independently—it seems to be specific to Store.query, and I’ll see if I can figure out what’s going on!

:man_facepalming: I made it public

Been there. :joy: I spoke with @pzuraq about this yesterday evening, and the problem is that Store.query uses Ember.get in a way that basically auto-tracks its own state when it’s created. Apparently other Ember Data methods used to have the same issue. The trick here is to do a lightweight form of caching to avoid recomputing unnecessarily. The reason your version with @computed works is because @computed both observes and caches any updates to its dependent keys. You can get the same effect yourself (and with much better performance) by caching yourself.

You can do that manually, like this:

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { load } from 'my-app/helpers/load';

export default class PeopleComponent extends Component {
  @service('store') store;

  _previousId;
  _previousData;

  get people() {
    if (this._previousId !== this.args.id) {
      let promise = this.store.query('person', { filter: { id: ids } }).then((result) => {
        data.resolve(result.toArray());
      });

      this._previousId = this.args.id;
      this._previousData = load(promise);
    }

    return this.#previousData;
  }
}

Or you can use the polyfill for the proposed @cached decorator:

import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { load } from 'my-app/helpers/load';

export default class PeopleComponent extends Component {
  @service('store') store;

  @cached
  get people() {
    let promise = this.store.query('person', { filter: { id: ids } }).then((result) => {
      data.resolve(result.toArray());
    });

    return load(promise);
  }
}

Well if it is that, then I actually don’t think that @cached will help here, because the value is still getting entangled and dirtied, which will dirty the state of the cache too. I’ll dig in real quick today to see if I can find the source of it.

Ahhh, interesting. And the manual caching avoids that because it’s not using tracking?

The manual caching avoids it because even if it is called “many times” the cycle is absorbed by the manual cache check.

Ah, that makes sense!

Hi, using the last example posted by chriskrycho , as far as I can understand, when people getter will be fired, it will return the this._previoudData as a cache. However, if inside the template we have something like this:

<OtherComponent @people={{this.people}}/>

and inside other-component.js we have something like:

get someProperty() {
  return this.doSomeHeavyComputation(this.args.people);
}

doSomeHeavyComputation will be called right? It does not matter that people getter served the results from cache, someProperty getter will recompute. At least, that is what I noticed from testing.

So, this is a pretty big issue for me, because I have multiple attributes being propagated to child components, all of which are cached manually, but the child components all recompute.

I used to have an observer, something like this:

didChange: observer('userId', function() {
   fetch(url, {userId: this.args.userId}).then((response) => {
       this.profiles = response.profiles;
       this.progress = response.progress;
       this.otherStuff = response.otherStuff;
  });
})

and I updated this to:

get userData() {
   if(this.args.userId !== this._lastUserId) {
       this._lastUserData = load(fetch(...));
       this._lastUserId = this.args.userId;
    }
    return this._lastUserData;
}

get profiles() {
   switch(this.userData.state) {
        case 'LOADED':
            this._profiles = this.userData.value.profiles;
            break;
    }
    return this._profiles;
}

get progress() {
   switch(this.userData.state) {
        case 'LOADED':
            this._progress= this.userData.value.progress;
            break;
    }
    return this._progress;
}

The issue here is that, right after this.args.userId updates, get userData is called and reloads data from the server. This means that profiles and progress both get called, because this.userData.state changes, and they both serve data from cache, as state is LOADING. But this propagates to child components, and other getters/rerenders happen.

Is there a way I can avoid this, besides checking for identical values being received within each component (kinda like, applying a cache mechanism inside child components too)? The observer seemed much more simple, in this case. Thank you so much.

If you need caching of a separate getter, you should explicitly opt into caching there as well! One point of the design in Octane is that you only pay for caching when you need it rather than having caching everywhere that is difficult to opt out of even when it’s cheaper just to not cache. Think of it this way: every dependent key in your old system, both for computed properties and observers, was also an explicit cache key as well. In Octane, you get to drop those nearly everywhere, and have the much lighter-weight @cached for the places you do still need caching.

3 Likes