Ember 2, Strange behaviour with isPending, isSettl

2020-03-31 07:43发布

问题:

I need to understand once for all why property like

isSettled isPending isFulfilled

are different if I'm including or not the data in my API respone.

I'm asking here this: https://discuss.emberjs.com/t/ember-2-show-a-single-loading-message-when-the-ids-where-included-in-the-original-response/12654 which leads me to this strange behaviour:

If I include in my API responde the data (ex: model.posts) these properties are immediately set to true (and .isPending to false) also if Chrome is still loading the real data (also for the first time!).

And this is a problem because I don't know if the posts[] are empty or not and I don't know what I can spy because something like that doesn't work:

{{#each model.posts}}
  My posts.
{{else}}
  {{#if model.posts.isPending}}
    <div>Loading...</div>
  {{else}}
    <div>Nothing to show.</div>
  {{/if}}
{{/each}}

It's always "Nothing to show." until Chrome loads. Because .isPending is immediately false.

Also if I use the length attributes:

{{#if (eq model.posts.length 0)}}

because the starting posts[] array and the empty one is always to length == 0.

If I loads posts differently, async, not sideloaded (but with hundreds HTTP requests, which I don't want) it works. Ember recognize an isPending...

Why this strange behaviour?

UPDATE

My API response for /category/1:

{
  "data": {
    "id": "1",
    "type": "categories",
    "attributes": {
      "name": "Book"
    },
    "relationships": {
      "posts": {
        "data": [{
          "id": "11",
          "type": "posts"
        }, {
          "id": "14",
          "type": "posts"
        }, {
          "id": "16",
          "type": "posts"
        }]
      }
    }
  },
  "included": [{
    "id": "11",
    "type": "posts",
    "attributes": {
      "style": false,
      "comments": true
    }
  }, {
    "id": "14",
    "type": "posts",
    "attributes": {
      "style": true,
      "comments": false
    }
  }, {
    "id": "16",
    "type": "posts",
    "attributes": {
      "style": true,
      "comments": false
    }
  }]
}

because I'm using include in my Rails controller.

Other strategies?

  • embedded :ids?

  • JSON API links?

How to do?

MY ONLY POOR LITTLE BABY PROBLEM:

I don't want many many many HTTP requests. Just one for category (model) and one (non blocking, with loading message) for posts after the first one for model (category)...

回答1:

Okay, I think I need to tell you first about the different ways you can load the data. In your example you have two models. I know one is called post, and for now lets call the other one my-model.

Now you have a to-many relationship between them. Probably like this in my-model:

posts: hasMany('post'),

Now obviously you want to show all posts for a given my-model. For this you probably have a route like /my-model-posts/:myModel_id. A very interesting question is how you link to this route. This because for a link you already need the my-model instance, and now the question is how you loaded that...

I will talk about this later, for now lets assume your user directly hits your app at this given url, because this is a use-case you always have to handle.

Because the naming convention model_id you don't have to write your route yourself. However lets remember that the default implementation would be equivalent to this explicit route definition:

model(params) {
  return this.store.findRecord('my-model', params.myModel_id);
}

Now the backend basically has 3 ways to respond:

sideload everything

{
  data: {
    type: 'my-model',
    id: 'foo',
    attributes: {...},
    relationships: {
      posts: {
        data: [{
          type: 'post',
          id: '1'
        },{
          type: 'post',
          id: '2'
        }]
      }
    }
  },
  included: [{
    type: 'post',
    id: '1',
    attributes: {...}
  }, {
    type: 'post',
    id: '2',
    attributes: {...}
  }]
}

sideload the ids

{
  data: {
    type: 'my-model',
    id: 'foo',
    attributes: {...},
    relationships: {
      posts: {
        data: [{
          type: 'post',
          id: '1'
        },{
          type: 'post',
          id: '2'
        }]
      }
    }
  }
}

use a related link

{
  data: {
    type: 'my-model',
    id: 'foo',
    attributes: {...},
    relationships: {
      posts: {
        links: {
          related: '/api/model/foo/posts'
        }
      }
    }
  }
}

Now you need to understand that the promise returned by findRecord will wait for this first response of your server and then resolve. Your router will wait for this response before entering the route. This is very important to understand: Your route will not be entered until this promise resolves. To indicate the loading state during this phase you can use a loading substate..

So for the first example, if you sideload everything this is enough. Remember that when the response is returned there is no more data to load. Also until this response is loaded you are in the loading substate.

The last example is also not very hard. An easy way is to show two loading spinners. You need this, because you make two requests and are in two loading states:

  1. user hits the route
  2. you make the findRecord request
  3. the loading substate is shown until the findRecord request has finished
  4. the response is returned and the template will be rendered
  5. you access model.posts and this will trigger the related link to load. While the data is loading model.posts.isPending will be true. You can use a simple {{#if model.posts.isPending}}loading...{{/if}} to indicate the loading state
  6. the response is returned and model.posts.isPending is now false. All data is now loaded.

However you can hack around to reduce this to one single loading state if you want. You could just load the posts in your afterModel hook:

afterModel(model) {
  return model.get('posts');
}

This will enforce the promise posts to be loaded before the route is entered, keeping you in the loading substate.

Another thing is to directly load the posts, if you don't care about the my-model instance:

model(params) {
  return this.store.findRecord('my-model', params.myModel_id)
    .then(m => m.get('posts'));
}

Now model will be your posts array and you will stay in the loading substate until the posts are loaded.

Last but not least lets talk about the second example: You sideload only ids. This is probably the most tricky one. If you only side load the ids, you have no single property represententing that something is still loaded. This is because there are many HTTP requests, and so many promises indicating that something is still loading. Probably the only use-case for this is when you actually want to show the data to the user ASAP, so I would recommend a loading-spinner per post:

{{#each model.posts as |post|}}
  {{#if post.isLoading}}
    loading...
  {{else}}
    ...the data...
  {{/if}}
{{else}}
  no posts
{{/each}}

Notice that if you have no posts this is something you know before you have to make a single HTTP request!

If you want a single loading spinner you could create a computed property on the my-model:

hasLoadingPosts: Ember.computed('posts.@each.isLoading', {
  get() {
    return this.get('posts').any(post => post.get('isLoading'));
  }
})

You can also keep this in the loading substate however this is a bit more tricky:

afterModel(model) {
  return model.get('posts')
    .then(posts => Ember.RSVP.all(posts.toArray()));
}

You can do something similar in the model hook:

model(params) {
  return this.store.findRecord('my-model', params.myModel_id)
    .then(m => m.get('posts'))
    .then(posts => Ember.RSVP.all(posts.toArray()));
}

Notice also that these last two promise-handling JS-snippets work in all scenarios. They don't harm if the data is already loaded, but wait for a related link as well as for sideloaded ids.

Now something very important to notice is that usually don't direct-link to such a page. Probably you have somewhere a list of my-models or something, and then a link-to to show the posts. Here you wont hit the model hook! Also you don't need this first request: The data of my-model are already in the store! And now the question is what you returned when you loaded it in the first place. Have you sideloaded everything, only the ids or used a related link? The consequences are similar to the scenarios above!

If you modified your model hook you probably should change your link-to as well, from {{#link-to 'my-model-posts' model}} to {{#link-to 'my-model-posts' model.posts}}. This is fancy, because if you used a related link the route will again wait for this promise to resolve.


Generally my recommendation is to never sideload ids only. This is also probably only useful if you don't use a relational database for the backend.

I recommend to use related links and sideload everything in rare cases. For example if you directly load a my-model I would sideload everything, and for the list only return related links.

Now just program as there were only related links and things will generally just work. If you sometimes need a loading indicator less this is fine.

Also if you just show this array of posts I would change the semantic of the route and directly return this in array in the model hook. Of course then change the link-tos as explained above.

However if you want to show some data of my-model like a title it is probably desirable to show them ASAP. Here you need isPending while you show the title but don't have the posts yet.

Both of these solutions however will break when you sideload the ids only, as explained above.