RFC: absorb ember-concurrency-async and standardize around async functions
See original GitHub issueThis RFC proposes that:
- ember-concurrency-async be merged into ember-concurrency core (similar to how we merged in ember-concurrency-decorators)
- We introduce a new
@taskFn
decorator that behaves almost exactly the same as the@task
decorator, but produces directly-callable “async” functions that have safe Task-like semantics - We bring in the
taskFor
helper from ember-concurrency-ts, but we extend its (currently no-op / typing-only) behavior to extractTask
objects from@taskFn
functions. - We introduce an async-arrow function equivalent to the above for use with
task()
Background
ember-concurrency-async (ECA) is a complementary addon to ember-concurrency that allows you to use async functions instead of generator functions when defining EC tasks, e.g.
import { task } from 'ember-concurrency';
class Foo {
@task({ restartable: true }) async foo() {
await timeout(300);
// ...
}
}
The way this works is that ECA provides a babel transform to convert the async fn to a generator function (this is required because async fns are not externally cancellable, whereas generators can be used to model cancellable async functions).
This has a few benefits:
- TypeScript’s handling of async functions is much more robust than its much-lacking support of generators and the
yield
keyword. - An EC tasks behaves, in spirit, much more like an async fn than how generators are used (EC is a lot of people’s first experience with generators, and lot of people have the same question as to “why don’t we just use async fns”)
There are some downsides:
- The use of a babel transform is a bit magical; it bends the semantics of async fns just a bit which to the astute JS programmer can understandably cause some confusion
- ECA still requires the well-typed async fns that
perform()
EC tasks to useawait taskFor(this.myTask).perform()
when what you really want to do is just typeawait this.myTask()
.
There isn’t much we can do about #1
until/unless TS offers better support for generator fns (unlikely) or async fns become cancellable by way of some new EcmaScript spec (also unlikely).
Proposal: @taskFn
decorator
To alleviate some of the awkwardness around #2
above, I propose we merge ECA into EC, and we introduce a new @taskFn
decorator that can be used in place of any @task
decorator, but instead of converting the decorator generator/async function to a .perform()
-able task object with derived state properties, it “converts” it to an async function that can be directly invoked/called (rather than .perform()
ed) with Task-like safety guarantees. For example:
import { safe } from 'ember-concurrency';
export default class extends Component {
@taskFn async parentDoStuff(ms: number) {
await timeout(ms);
await this.childDoStuff();
return 'done!';
}
@taskFn async childDoStuff() {
// ...
}
}
The @taskFn
decorator will accept the same arguments that @task
decorator takes today, e.g. you could write the following:
export default class extends Component {
@taskFn({
maxConcurrency: 3,
restartable: true
})
async doStuff() {
// ...
}
}
With the @taskFn
keyword, we can entirely avoid the taskFor()
ceremony for performing tasks in a type-safe manner; instead of await taskFor(this.doStuff).perform()
you can juse to await this.doStuff()
@taskFor
helper
Because @taskFn
async functions themselves are just Function instances installed on the prototype (or instance), they won’t, by default, have any kind of “derived state” (like isRunning
, etc) that we’ve come to know and love. In order to re-expose this derived state, I propose we bring in the @taskFor
function from ember-concurrency-ts, which is currently a no-op fn used to appease TypeScript, and extend it just slightly so that you can pass it an async @taskFn
and have it return to you the hidden/internal Task
object (which you can then use to access derived state like .isRunning
).
export default class extends Component {
@taskFn async doStuff() {
// ...
}
doStuffTask = taskFor(this.doStuff);
}
With this in place you can put the following in templates:
{{#if this.doStuffTask.isRunning}}
wat
{{/if}}
With this scheme, there’d basically be two “entry points” to the same @taskFn
async fn: 1. calling the fn directly, or 2. Calling .perform()
on the Task
object returned from taskFor
.
async-arrow tasks
This is a more recent idea that emerged from discussions in the Discord e-concurrency channel (thank you @NullVoxPopuli!):
In addition to the above APIs which are somewhat async-function centric (i.e. the thing that gets installed on the host object is an async function with special task-y semantics), I propose we introduce a more class Task-centric API wherein the object that gets installed on the host object is a Task that you can .perform()
– this will always be my favorite kind of API because Tasks are so easy and self-contained to pass around and access the derived state of.
class DemoComponent {
foo = 5;
doStuffTask = task(this, async () => {
await timeout(500);
this.foo++;
});
}
This is a well-typed TS component, with extremely minimal ceremony (with one bonus being you don’t need to add types to this
– it knows that this
is a DemoComponent
).
To pass options:
class DemoComponent {
foo = 5;
doStuffTask = task(this, { restartable: true }, async () => {
await timeout(500);
this.foo++;
});
}
To make this work, we would apply the same kind of transform to async arrow fns, which would turn the above into something like:
class DemoComponent {
foo = 5;
doStuffTask = task(this, { restartable: true }, () => {
return function * () {
yield timeout(500);
this.foo++;
}
});
}
We might also want to make it possible to use this API in conjunction with the @use
resources decorator, but really the only thing it would do is allow you to omit that first this
arg that you have to pass to task()
:
class DemoComponent {
foo = 5;
@use doStuffTask = task({ restartable: true }, async () => {
await timeout(500);
this.foo++;
});
}
I prefer we land the this
-based version first since it relies less on Ember-specific machinery.
Issue Analytics
- State:
- Created a year ago
- Reactions:9
- Comments:17 (10 by maintainers)
Top GitHub Comments
Same, I like this proposal — thank you for coming up with it! 🎊
#465 implemented proposition 4, which I think is all we need at this point.