aurelia: [RFC]: async/pending/fulfilled/rejected template controllers
š¬ RFC
This RFC proposes a way to work with promises directly in the markup in a declarative way.
š¦ Context
It is a common use case that before showing a view, data for that view is asynchronously fetched. While that promise can be returned from the lifecycle hooks, and Aurelia will wait for that promise, it necessarily delays (partial) rendering of the view. Sometimes this also means the delay of visual cues to the end-user. In some cases, such delay might be justified where the view is made solely to present the data being fetched.
In other cases, it might not; and as developer I want to display the subset of data that has already been fetched while some parts of the view wait for some specific promises, still not settled. I am of this opinion that this pattern offers better UX.
To this end, the idea is to have a set of strongly coupled template controllers named, async
, pending
, fulfilled
, and rejected
. āStrongly coupledā behause:
pending
,fulfilled
, orrejected
should not work in isolation, andasync
without at least one of those might not make much sense. That is possibly an edge case especially in context of working with promises in view.
Following are some examples to describe the idea.
š» Examples
Example#1: The basic use-case
<template async.bind="answerPromise">
<span pending> Still computing... š» </span>
<span fulfilled> 42 š </span>
<span rejected> What was question? š” </span>
</template>
Although it is self explanatory, for completeness here goes the short explanation. The view of the pending
template controller will be shown while the promise is running. Once it is settled, then either the fulfilled
or the rejected
view will be shown depending on whether the promise was resolved or rejected, respectively.
Example#2: Accessing the resolved data or rejection reason
<template async.bind="answerPromise">
<span pending> Still computing... š» </span>
<span fulfilled.from-view="resolvedData"> ${resolvedData} š </span>
<span rejected.from-view="reason"> ${reason.message} š” </span>
</template>
Note that the usage of from-view
is intentional as binding in the other direction is not very useful IMO. Consequently, the binding mode for async
should always be to-view
.
Example#3: Optional pending
/fulfilled
/rejected
These three template controllers should be optional. This means that following examples should work.
<template async.bind="promise1">
<span pending> The promise is still running, and I will vanish once it settles. Who knows how it is settled! š¤ </span>
</template>
<template async.bind="promise2">
<span fulfilled> Nice! The promise is now resolved. What do you mean that you didn't know about that promise? š® </span>
</template>
<template async.bind="promise3">
<span rejected> If you are seeing this, then a very bad thing happened!! š± </span>
</template>
<template async.bind="promise4">
<span> It will always be shown. </span>
</template>
Example#4: Forbid usage of pending
/fulfilled
/rejected
in isolation
The following are some examples of incorrect usage, and Aurelia should throw errors in those cases.
<span pending> Does not work! </span>
<span fulfilled> Does not work! </span>
<span rejected> Does not work! </span>
Example#5: Handling multiple promises with different modes
To work with multiple promises with different settlement modes, an optional @bindable resolve
can be supported, where resolve
takes one of the following values: all
, allSettled
, any
, and race
.
<template async="value.bind: [promise1, promise2]; resolve: all">
<template fulfilled.from-view="dataArray"></template>
</template>
Example#6: Nested async.bind
s.
Purely from academical standpoint this example gives a way to chain promises. Might not be a real-life use case though.
<template async.bind="fetchData">
<template fulfilled.from-view="data" async.bind="data.json()">
<template fulfilled.from-view="json"> ${json.foo} </template>
</template>
</template>
Open questions
- Most important question first. Do the names look right? Should the
async
be renamed topromise
? Or any of the rest for that matter? - Should binding the resolved data or rejected reason to view is needed at all? Usually the devs have access to the resolved data or the reason already in the view-model.
- Should error be thrown in following cases:
<template async.bind="promise">w/o any of the other TCs </template>
.async.from-view
fulfilled.to-view
rejected.to-view
(Very) Loose solution idea
- Create four new template controllers with appropriate binding modes for
value
. - āLinkā those accordingly.
- The
async
TC tracks the promise and accordingly activates the views of other TCs.
As in Aurelia template controller is used to dynamically/conditionally render/attach views, using template controllers here seems like a natural choice. For completeness let me mention the obvious that it can and should not be dealt with binding behavior or value converter as those primarily deals in the realm of data binding.
Fin!
Edits:
- 17.10.2020
- Renamed
unsettled
topending
,then
tofulfilled
, andcatch
torejected
. Justification: reference#1, reference#2. - Added
mode
example. - Added nested
async.bind
example. - Added brief reasoning to use template controller over binding behavior or value converter.
- Renamed
- 20.10.2020
- Renamed
mode
toresolve
. - Corrected some typos.
- Renamed
About this issue
- Original URL
- State: closed
- Created 4 years ago
- Reactions: 12
- Comments: 38 (35 by maintainers)
@Sayan751
That infra is already there. Whether to do it via
@attributePattern
or via@bindingCommand
though depends on how you want to implement it.The most obvious way that comes to mind is having 4 different template controllers for these 4 scenarios (probably one of those few cases where inheritance makes sense). Then you could āredirectā with attribute patterns:
Inheritance could be avoided by using binding commands. Then youād have to create an accompanying instruction and renderer that transforms the
any
,all
etc into a hydration operation that creates a binding and a meta property on the template controller to indicate which kind it is. A little more involved, maybe an optimization worth looking into at a later time.But attribute patterns should be an easy way to get the ball rolling?
Side note: this implementation means users could also do
promise-all.bind
instead ofpromise.all
. Thatās not a problem though imo. And if you wanted to support multi-attr bindings in the future, that could be accomplished as well if it was really needed:Another example that we can do is a containerless/templateless custom element for fetching data
resolving promises is a capability we absolutely need. although you can do everything from the VM, allowing binding directly to a promise can help to reduce boilerplate code (as already mentioned by other). but itās absolutely needed in cases where you pass a value through a value-converter that return a promise. I find myself copying to every project I create a special binding-behaviour that handle promises just for this specific task.
but: on the other hand, I think we should not take it too far. for example: the next scenario is too far - in my opinion.
Itās obvious that such channing should happened in the VM, and the view can be bounded to the resulting Promise. you donāt lose any capabilities - with a cleaner way of working.
I would also argue that allowing
all, any, race.
etc. are also an abuse and should not be implemented at all. all of those are functions that produce a single promise at the end. so itās enough to allow a binding to that Promise and by that - allowing all of the scenarios mentioned and more.so for instance:
instead of
we can do:
or better yet as a developer, move the
Promise.all([promise1, promise2])"
part to the VM - save this expression as a variable, and bind the async to that.to summarize: we need to find the fine path between nothing at all (what we have right now in V1), and absolute crazy stuffā¦
I really liked this: by @bigopon
this is clean, understandable⦠and have all the power we need.
I think our aim shouldnāt be āmimic the way promise worksā in html. With that in mind, we should simply the
async/unsettled/fulfilled
combo to just 2, with more natural name:await/then
. The following example illustrates my comment:Doing it with
await
keyword, we avoid occupying the wordasync
, and can save it for later use with observables. At least we donāt give the wrong impression that any kind of async will work. Itās only promise that is intended for this feature.In order to have
await.any
/await.race
/await.all
, we need to enhance the template compiler, so that custom attribute can participate in the compilation process as well. In case some commands have special meanings when used with it. In this case.any
/.all
/.race
is clearer.I like the clear
unsettled.from-view="..."
, in our implementation, we can make this default binding mode and simply using.bind
will suffice.Yeah it could be done but I donāt know what problem that would solve that would make it worth the perf impact of the extra parsing/binding overhead. These things can easily be accomplished programmatically as well. Iāve never seen a situation where I needed to dynamically change the event handled, only ignore it under certain circumstances (thatās what the event handler method is forā¦)
If itās an avenue that must be explored, though, letās do that in a separate RFC to stay on-topic š
noooooo lol, this is even more crazy than previous suggestions.
Besides what @Sayan751 's noted, we should also be aware all these resources are optional, which mean if you donāt use it, it wonāt be in your final bundle, if that is also one of the concerns. All our our resources in v2 can be simply dropped if not needed, and itās simpler to do so.
@akircher You are right that we can keep the resolving and catching of the promise in view-model and use a
if
/show
in view to display the results accordingly. This is a perfectly valid solution.However, if you have such instances in abundance, then it considerably increases the amount of boilerplate code. A natural progression thereafter can be a custom element that monitors the bindable promise, and has 2 slots for fulfilled and rejected content. And you can use that custom element to get rid of the boilerplate code. Again a valid solution.
One inherent limitation of the custom element approach is how it is styled, and whether or not it can be used seamlessly (style-wise) everywhere. On the other hand, having template controllers gives you freedom of using this construct anywhere possible, and those elements can be styled anyway wanted.
Even with these new template controllers, you might still want to encapsulate the code in a custom element (as said before) and use that. However, wherever you canāt use that custom element due to styles/layout issues, the new template controllers can be directly. IMO this brings more flexibility to play.
Apologies if I am missing the use case. But before bikeshedding the keywords, do we really need this much declarative power in html? To me, it seems simpler, more readable, and more powerful (since you can differentiate show vs if) to keep the promise resolving and catching in JS and simply put a
show.bind
orif.bind
in the html.I like the idea of renaming
mode
toresolve
. Seems more natural to me.One down side of using
(
is that following with space will break, and user might be confused why it didnāt work.There are different individual preferences and we wonāt be able to satisfy all. Fortunately, it is trivial to re-configure the binding syntax. We can provide an OOTB example or two on the most popular alternatives to the one we went with, so folks with strong alternative preferences can still have it their way. No problem with that.
Itās tricky to find a truly consistent syntax since the other control flow TCās are all statements:
And
So what syntactic concept are we trying to mimic for promises?
In neither case I see a 100% convincing parallel in anything thatās not super verbose (certainly not looking forward to nesting a try/catch in an async in a template), but option 1 does feel a lot more like it resembles the actual code. Maybe someone else has a way to present
async/await
in a more natural manner but so far I donāt think that will be the right terminology to use here.The examples provided by @Sayan751 with
async
andpending
/fulfilled
/rejected
donāt directly mimic a syntactic structure that is seen in real code unless you imagine the promiseās internal slots being accessible. Though withpromise.resolve
it is a bit easier to display it in psuedo code I think:When taking that liberty of imagining the internal slots being accessible, I do think @Sayan751ās variant is the best in terms of consistency.
@Sayan751 You could also have a single template controller registered with 4 aliases, and then inject the instruction (just like compose does) to look at the original name that was declared, and use the
res
name from the instruction to determine the behavior. So the aliases would bepromise.all
, etc. Youād still need the attribute pattern to effectively turnpromise.all
intopromise.all.bind
then.Cool. Just to further clarify then: attribute patterns are a template compiler extensibility point and theyāre used in the broadest sense for determining resources and their bindings. This is also the api one would use to emulate the Vue or Angular syntax.
It might help to think of the parallel with configured routes. You register a route pattern to map from a url segment to a component / redirect / param, in much the same way you register an attribute pattern to map from an attribute to a either a binding, a custom attribute + binding, or simply a custom attribute. If you would not register the
DotSeperatedAttributeSyntax
thenif.bind
would cause the compiler to look for something declared as either@templateController('if.bind')
or@customAttribute('if.bind')
, neither of which would exist, so it then assumesif.bind
is just an ordinary native attribute and do nothing with it š.The only thing to be careful with is clashing. Come to think of it, itās probably not good to throw in the default case, because that would make it impossible for anyone to declare a binding with the name
promise
. This is probably a better and more compatible idea:Just had some more thoughts on this. For
await.bind
, itās a bit off to use await to express an async piece of UI for the reason in your comments, and pending state.Which means the following:
is probably not the best.
But if we are to map our naming 100% to Promises, and the real meaning of its internal states, we will have to do:
It feels a bit unnecessarily detailed.
Though with my proposal, we cannot express the following as thereās no nesting:
So maybe we cannot use
await.bind
. I donāt have a better naming pairs now, so I think while thereās some issue withasync
in terms of naming, itās probably not bad to go ahead with it. If we really want to have another name, I thinkresolve
is a better name thanpromise
.For
Maybe rename
mode
to resolve?