EmberSherpa / ama

Ask me anything Ember!
22 stars 0 forks source link

How to get the resolved value of a promise in the template without setting an intermediate value? #11

Closed alexlafroscia closed 8 years ago

alexlafroscia commented 8 years ago

I know that rendering an Array or Object to a template asynchronously can be achieved with a PromiseArray or PromiseObject, but what's the right way to approach a situation where the thing you want to resolve is a String or Number?

taras commented 8 years ago

Can you please elaborate on the use case a bit more?

alexlafroscia commented 8 years ago

Basically, I've noticed there are times that you need to render something asynchronously, but that thing turns out to not be an array or object, but a string or number. For example, if you want to get the count of some thing that's gathered asynchronously, you can't just return a promise. This is very similar to some code that I'm playing with in our app right now:

// desired
import Ember from 'ember';

const { Component, RSVP, computed } = Ember;

export default Component.extend({
  numForms: computed(function() {
    return this.store.find('forms').then(function(forms) {
      return RSVP.all(forms.invoke('getCount'));
    }).then(function(counts) {
      return counts.reduce((prev, current) => (prev + current), 0);
    });
  })
});

I would love to use the template like this:

{{numForms}}

But unfortunately, returning a promise from the property doesn't render the eventual resolution. Instead, we have to do something like this:

// required
import Ember from 'ember';

const { Component, RSVP, on } = Ember;

export default Component.extend({
  getNumForms: on('init', function() {
    return this.store.find('forms').then(function(forms) {
      return RSVP.all(forms.invoke('getCount'));
    }).then((counts) => {
      const total = counts.reduce((prev, current) => (prev + current), 0);
      this.set('numForms', total);
    });
  }),

  numForms: 0
});

which does achieve the same effect, but I generally think that there should be a way to use a single computed property to make this work. Do you have any solutions? We've had great success using PromiseObject and PromiseArray for similar solutions for Objects and Arrays, but there is not analogue for "primitives" yet.

taras commented 8 years ago

I haven't seen a better solution to that pre Glimmer but with thew new helpers API you can create a helper that will automatically update the template when the promise resolves.

Here is a demo of how it works http://g.recordit.co/7WyK0mtxkC.gif

Here is the template

<h2 id="title">Promises in Glimmer</h2>

<p><button {{action 'new'}}>New Date</button></p>

<p><strong>Date</strong>: {{resolve date}}</p>

<p><strong>Formatted</strong>: {{format-date (resolve date) format="L"}}</p>

And here is the helper

import Ember from "ember";

export default Ember.Helper.extend({
  compute(params) {
    const [value] = params;
    const isPromise = value && value.then;
    if (!isPromise) {
      return value;
    }
    const isPending = value._state === 0 || value._state === undefined;
    if (isPending) {
      value.then(()=>{
        this.recompute();
      }).catch(()=>{
        this.recompute();
      });
      return 'Loading...';
    }
    const isFulfilled = value._state === 1;
    if (isFulfilled) {
      return value._result;
    }
    return `Error: ${value._result}`;
  }
});

Here is the repo: https://github.com/embersherpa/glimmer-promises

alexlafroscia commented 8 years ago

Thanks! That's what I thought of too, but unfortunately we're stuck on Ember 1.12 for now. I'm currently playing with a "old style" helper that tries to do the same thing, but without the ability to do

value.then(()=>{
  this.recompute();
});

it doesn't look like it's possible.

alexlafroscia commented 8 years ago

I'm curious: why is the conditional necessary here?

const isFulfilled = value._state === 1;
if (isFulfilled) {
  return value._result;
}
return value._result;
alexlafroscia commented 8 years ago

Oh, and I was able to create a component that has essentially the same behavior:

import Ember from 'ember';

const { computed } = Ember;

export default Ember.Component.extend({

  tagName: 'span',

  /**
   * The promise to resolve
   *
   * @property {Promise} promise
   */
  promise: null,

  /**
   * The value to display to the template
   *
   * @property {Any} value
   */
  value: computed('promise', 'initialValue', function() {
    const promise = this.get('promise');
    const initial = this.get('initialValue');

    if (!promise || !promise.then) {
      return promise;
    } else {
      promise.then(() => this.rerender());
    }

    const isPending = promise._state === 0 || promise._state === undefined;
    if (isPending) {
      return initial;
    } else if (promise._state === 1) {
      return promise._result;
    }
  }),

  /**
   * The default value to display before the promise reolves
   *
   * @property {Any} initialValue
   */
  initialValue: ''

});

where the template is just {{value}}.

You use it like

{{resolve-promise promise=somePromise initialValue='Loading...'}}

It's not quite as elegant but for now it's pretty close to the helper behavior.

taras commented 8 years ago

I'm curious: why is the conditional necessary here?

@alexlafroscia The conditional was intended for error reporting. I updated the code example to show error handling.

Nice job on the component.

The biggest difference between the helper and the component is that you can use the helper as a subexpression to evaluate value before passing it to another helper. {{format-date (resolve-promise date) format="L"}}.

appleton commented 8 years ago

I've solved this problem in the Heroku dashboard with a decorator:

/*
 * A decorator which returns a lazy loader computed property.
 * Executes the given loaderFn when it's first needed and replaces the property
 * with the result. For convenience, isLoading${propertyName} is set to true
 * until the promise completes. The loader function will only be called once,
 * subsequent calls return null until the property is resolved.
 *
 * e.g.
 *   lazyLoad(function() {
 *     return App.all();
 *   })
 *
 * @function lazyProperty - A computed property which lazily loads a value when it is first needed
 * @callback loaderFn - The callback is evaluated and the property is replaced with the result once it's resolved
 * @returns {Ember.computed} - A computed property to add to an Ember.Object
 */

function lazyProperty(loaderFn) {
  return Ember.computed(function(propertyName) {
    const loadingProperty = `isLoading${propertyName.capitalize()}`;
    if (this.get(loadingProperty)) { return null; }

    this.set(loadingProperty, true);

    Ember.RSVP.resolve(loaderFn.call(this)).then((value) => {
      this.set(propertyName, value);
    }).finally(() => this.set(loadingProperty, false));
  });
}

export default lazyProperty;

and then in your component:

import Ember from 'ember';
import lazyProperty from './wherever';

const { Component, RSVP, computed } = Ember;

export default Component.extend({
  numForms: lazyProperty(function() {
    return this.store.find('forms').then(function(forms) {
      return RSVP.all(forms.invoke('getCount'));
    }).then(function(counts) {
      return counts.reduce((prev, current) => (prev + current), 0);
    });
  })
});

Seems like pretty much what you're after and as a bonus lets you account for loading states in the UI.

taras commented 8 years ago

@appleton Thank you for sharing your solution.

I like that it provides a built in loading state, but I'm conflicted by the fact that it's a computed property that doesn't behave like a computed property because it can't have dependent properties and it doesn't evaluate to anything. It also doesn't give you the benefit of working with a promise either because you can't get the promise and bind to success and error states.

This macro is great when you need to receive the value once, kind of like an improved on('init') callback.

Thank you for sharing, I hope you don't find my comments discouraging in commenting in the future :smile:

alexlafroscia commented 8 years ago

@appleton that's really similar to the current version of the component that I came up with; instead of using a decorator, value just calls this.set('value', resultOfPromise) since calling this.rerender() didn't end up working for me. The decorator idea is really neat though, I like that you can then apply it to components wherever you need it.

appleton commented 8 years ago

@taras good point about it not including the re-computation behaviour. In my specific use cases I've not wanted this - usually I'm lazy loading a model based on user interaction (e.g. click to show some list of secondary information).

@alexlafroscia yeah, I wrote it after realising that I'd ad-hoc implemented it about three times in different places :)