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.

https://github.com/mupkoo/ember-async-data/blob/master/app/components/query.js

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!