stage | start-date | release-date | release-versions | teams | prs | project-link | suite | |||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
accepted |
2024-12-19 00:00:00 UTC |
|
|
This RFC defines a new utility to add by default to the framework, based on all the prior community implementations of promise wrapping, which is byte-wise opt-in in a brand new package (@ember/reactive
).
Promises are ubiquitous, yet we don't have a default way to use them reactively in our templates.
Meanwhile, we have several competing and duplicate implementations:
TrackedAsyncData
from ember-async-dataPromiseState
from @warp-drive/emberState
from reactiveweb's remote-dataRequest
from ember-data-resourcesQuery
from ember-await
There are probably more, but the point of this RFC is to provide a common utility, that we can both unify all of the implementations and polyfill to earlier versions of ember as well.
For loading UIs, there are concerns that this proposed utility will not be concerned with directly, but also the utility still needs to enable usage of those UIs, so in How we teach this, there will be examples of various loading patterns and re-implementations of existing utilities (such as those mentioned in the Motivation section above) using the new tracking utility for promises.
The import:
import { TrackedAsyncState, trackedPromise } from '@ember/reactive';
Note
Key behaviors:
- if the passed promise is resolved, or a non-promise, we do not await, this allows values to not block render (or cause a re-render if they don't need to)
- no
@dependentKeyCompat
- promise states are all mutually exclusive
This utility wraps and instruments any promise with reactive state, TrackedAsyncState
.
Sample type declaration
export function trackedPromise<Value>(
existingPromise: Promise<Value> | Value
): TrackedAsyncState<Value> {
/* ... */
}
This utility is analgous to new Promise((resolve) => /* ... */)
, but includes the tracking of the underlying promise.
Additionally TrackedAsyncState
must be able to receive an existing promise via new TrackedAsyncState(existingPromise);
.
Sample type declaration
export class TrackedAsyncState<Value = unknown> implements PromiseLike<Value> {
constructor(existingPromise: Promise<Value>);
constructor(callback: ConstructorParameters<typeof Promise<Value>>[0]);
constructor(promiseOrCallback: /* ... */) { /* ... */ }
// private, tracked, all state is derived from this,
// since promises are not allowed to have more than one state.
#state:
| ['PENDING']
| ['REJECTED', error: unknown]
| ['RESOLVED', value: Value];
// private, the underlying promise that we're instrumenting
#promise: Promise<Value>;
// upon success, Value, otherwise null.
value: Value | null;
// upon error, unknown, otherwise null.
error: unknown;
// state helpers
isPending: boolean;
isResolved: boolean;
isRejected: boolean;
// allows TrackedAsyncStates to be awaited
then(/* ... */): PromiseLike</* ... */>
}
Unlike TrackedAsyncData, all properties are accessible at throughout the the lifecycle of the promise. This is to reduce the pit-of-frustration and eliminate extraneous errors when working with promise-data. While ember-async-data can argue that it doesn't make sense to even try accessing value
until a promise is resolved, the constraint completely prevents UI patterns where you want value and loading state to be separate, and not part of a single whollistic if/else-if set of blocks.
For simplicity, these new utilities will not be using @dependentKeyCompat
to support the @computed
era of reactivity. pre-@tracked
is before ember-source @ 3.13, which is from over 5 years ago, at the time of writing. For the broadest, most supporting libraries we have, 3.28+ is the supported range, and for speed of implementation, these tracked promise utilities can strive for the similar compatibility.
An extra feature that none of the previously mentioned implementations have is the ability to await
directly. This is made easy by only implementing a then
method -- and allows a good ergonomic bridge between reactive and non-reactive usages.
The implementation of TrackedAsyncState
is intentionally limited, as we want to encourage reliance on The Platform whenever it makes sense, and is ergonomic to do so. For example, using race
would still be done native, and can be wrapped for reactivity:
/** @type {TrackedAsyncState} */
let trackedPromise
= trackedPromise(Promise.race([promise1, promise2]));
If a promise is passed to trackedPromise
or TrackedAsyncState
multiple times, we don't want to re-do any computations.
Examples:
let a = Promise.resolve(2); // <state> "fulfilled"
<template>
{{#let (trackedPromise a) as |state|}}
{{state.value}}
{{/let}}
{{#let (trackedPromise a) as |state|}}
{{state.value}}
{{/let}}
</template>
This component renders only once, and both occurances of of trackedPromise
immediately resolve and never enter the pending states.
let a = Promise.resolve(2); // <state> "fulfilled"
let b = Promise.resolve(2); // <state> "fulfilled"
<template>
{{#let (trackedPromise a) as |state|}}
{{state.value}}
{{/let}}
{{#let (trackedPromise b) as |state|}}
{{state.value}}
{{/let}}
</template>
In this component, it also only renders once as both promises are resolved, and we can adapt the initial state returned by trackedPromise
to reflect that.
The process of making libraries support wide-ranges of ember-source
is known. ember-source
has recently been adapting its release process to use release-plan, so that the ember.js repo can publish multiple packages seemslessly, rather than always bundle everything under one package.
With those new release capabilities within the ember.js repo, Instead of a polyfill for older versions of ember, @ember/reactive
, the package (at the time of this RFC, does not exist, but would have the two exported utilities from it), would be pulished as its own type=module
package and included with ember-source, as to not add more dependencies to the package.json going forward.
Why type=module
?
This is a requirement for some optimization features of packages (webpack / vite), such as proper treeshaking -- without type=module
, the best optimization we can get is "pay for only what you import". For large projects this isn't so much of a problem, but for small projects (or highly optimized projects), the impact to network transfer/parse/eval is measurable. This RFC is also proposing that @ember/reactive
be the place for all our ecosystem's reactivity utilities will end up once they've been proven out, tested, and desire for standardation is seen.
For example, other future exports from @ember/reactive
(in future RFCs), may include:
- TrackedObject
- TrackedArray
- TrackedMap
- TrackedSet
- TrackedWeakSet
- TrackedWeakMap
- localCopy
- certain window properties
- ...and more
without the static analysis guarantees of type=module
, every consumer of @ember/reactive
would always have all of these exports in their build.
For some utilities, we can place them under sub-path-exports, such as @ember/reactive/window
, for window-specific reactive properties, but the exact specifics of each of these can be hashed out in their individual RFCs.
When a project wants to use @ember/reactive
, they would then only need to install the package separately / add it to their package.json
.
The proposed list of compatibilyt here is only meant as an example -- if implementation proves that more can be supported easier, with less work, that should be pursued, and this part is kind of implementation detail.
But for demonstration:
- apps pre [version available], would add
@ember/reactive
to theirdevDependencies
ordependencies
- importing
@ember/reactive
would be handled by ember-auto-import/embroider (as is the case with all v2 addons)
- importing
- v1 addons would not be supported
- v2 addons, for maximum compatibility, would need to add
@ember/reactive
to theirdependencies
- in consuming apps post [version available], this would be optimized away if the version declared in dependencies satisfies the range provided by the consuming app (an optimization that packagers already do, and nothing we need to worry about)
- apps post [version available], would not need to add
@ember/reactive
to theirdevDependencies
ordependencies
, as we can rely on theember-addon#renamed-modules
config in ember-source'spackage.json
.
import { trackedPromise } from '@ember/reactive';
The returned value is an instance of TrackedAsyncState
, and is for instrumenting promise state with reactive properties, so that UI can update as the state of a promise changes over time.
When a non-promise is passed, as one may do for a default value, it'll behave as if it were a resolved promise, i.e.: Promise.resolve(passedValue)
.
This is a shorthand utility for passing an existing promise to TrackedAsyncState
.
Example in a template-only component
import { trackedPromise } from '@ember/reactive';
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
<template>
{{#let (trackedPromise (wait 500)) as |state|}}
isPending: {{state.isPending}}<br>
isResolved: {{state.isResolved}}<br>
isRejected: {{state.isRejected}}<br>
value: {{state.value}}<br>
error: {{state.error}}<br>
{{/let}}
</template>
Example in a class component:
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { trackedPromise } from '@ember/reactive';
export default class Demo extends Component {
@cached
get state() {
// promise resolves after 400ms
let promise = new Promise((resolve) => {
setTimeout(resolve, 400);
});
return trackedPromise(promise);
}
<template>
isPending: {{this.state.isPending}}<br>
isResolved: {{this.state.isResolved}}<br>
isRejected: {{this.state.isRejected}}<br>
value: {{this.state.value}}<br>
error: {{this.state.error}}<br>
</template>
}
import { TrackedAsyncState } from '@ember/reactive';
Creates a tracked Promise
, with tracked
properties for implementing UI that updates based on the state of a promise.
When a non-promise is passed, as one may do for a default value, it'll behave as if it were a resolved promise, i.e.: Promise.resolve(passedValue)
.
Creating a tracked promise from a non-async API:
import { TrackedAsyncState } from '@ember/reactive';
function wait(ms) {
return new TrackedAsyncState((resolve) => setTimeout(resolve, ms));
}
<template>
{{#let (wait 500) as |state|}}
isPending: {{state.isPending}}<br>
isResolved: {{state.isResolved}}<br>
isRejected: {{state.isRejected}}<br>
value: {{state.value}}<br>
error: {{state.error}}<br>
{{/let}}
</template>
Creating a tracked promise from an existing promise:
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { TrackedAsyncState } from '@ember/reactive';
export default class Demo extends Component {
@cached
get state() {
let id = this.args.personId;
let fetchPromise =
fetch(`https://swapi.tech/api/people/${id}`)
.then(response => response.json());
return new TrackedAsyncState(fetchPromise);
}
<template>
... similar usage as above
{{this.state.isPending}}, etc
</template>
}
With @cached
, we can make any getter have stable state and referential integrity, which is essential for having multiple accesses to the getter return the same object -- in this case, the return value from trackedPromise
:
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { trackedPromise } from '@ember/reactive';
export default class Demo extends Component {
@cached
get requestState() {
let id = this.args.personId;
let fetchPromise =
fetch(`https://swapi.tech/api/people/${id}`)
.then(response => response.json());
return trackedPromise(fetchPromise);
}
// Properties can be aliased like any other tracked data
get isLoading() {
return this.requestState.isPending;
}
<template>
{{#if this.isLoading}}
... loading ...
{{else if this.requestState.value}}
<pre>{{globalThis.JSON.stringify this.requsetState.value null 2}}</pre>
{{else if this.requestState.error}}
oh no!
<br>
{{this.requestState.error}}
{{/if}}
</template>
}
In this example, we only ever show one of the states at a time, loading or the value or the error. We can separate each of these, if desired, like this:
<template>
{{#if this.isLoading}}
... loading ...
{{/if}}
{{#if this.requestState.value}}
<pre>{{globalThis.JSON.stringify this.requsetState.value null 2}}</pre>
{{/if}}
{{#if this.requestState.error}}
oh no!
<br>
{{this.requestState.error}}
{{/if}}
</template>
Doing so would allow more separation of loading / error UI, such as portaling loading / error notifications to somewhere central in your applications.
NOTE: using @cached
with promises does not enable cancellation, as there is no lifetime to attach to at the getter/property level of granularity.1
We can use TrackedAsyncState
to turn not-async APIs into reactive + async behaviors -- for example, if we want to make a promise out of setTimeout
, and cause an artificial delay / timer behavior:
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { TrackedAsyncState } from '@ember/reactive';
export default class Demo extends Component {
@cached
get () {
return new TrackedAsyncState((resolve => {
setTimeout(() => {
resolve();
}, 5_000 /* 5 seconds */);
}));
}
get showSubscribeModal() {
return this.requestState.isResolved;
}
<template>
{{#if this.showSubscribeModal}}
<dialog open>
Subscribe now!
...
</dialog>
{{/if}}
</template>
}
This pattern is useful for dropdown searches, tables (filtering), and other UIs which could otherwise cause large layout shifts / excessive repaints.
We still want a loading UI on initial data load, but want to have a more subtle subsequent loading indicator (yet still prominently visible)
import Component from '@glimmer/component';
import { tracked, cached } from '@glimmer/tracking';
import { trackedPromise } from '@ember/reactive';
import { isEmpty } from '@ember/utils';
export default class Demo extends Component {
@tracked id = 51;
updateId = (event) => this.id = event.target.value;
@cached
get request() {
let promise = fetch(`https://swapi.tech/api/peopile/${this.id}`)
.then(response => respones.json());
return trackedPromise(promise);
}
#previous;
#initial = true;
@cached
get latest() {
let { value, isPending } = this.request;
if (isPending) {
if (this.#previous === undefined && this.#initial) {
this.#initial = false;
return value;
}
return (this.#previous = isEmpty(value) ? this.#previous : value);
}
return this.#previous = value;
}
<template>
<label>
Person ID
<input type='number' value={{this.id}} {{on 'input' this.updateId}} >
</label>
{{! initially, there is no latest value as the first request is still loading}}
{{#if this.latest}}
<div class="async-state">
{{! Async state for subsequent requests, only}}
{{#if this.request.isPending}}
... loading ...
{{else if this.request.isRejected}}
error!
{{/if}}
</div>
{{! pretty print the response as JSON }}
<pre>{{globalThis.JSON.stringify this.latest null 2}}</pre>
{{else}}
{{! This block only matters during the initial request }}
{{#if this.request.isRejected}}
error loading initial data!
{{else}}
<pre> ... loading ... </pre>
{{/if}}
{{/if}}
</template>
}
I think not doing this has more drawbacks than doing it. A common problem we have is that we have too many packages and too many ways to do things. Our users long for "the ember way" to do things, and a comprehensive reactive library full of vibrant, shared utilities is one such way to bring back some of what folks are longing for.
- reclaim the
ember
package and export underember/reactive
, addember
to the package.json.- doing this would require a polyfill, as
ember
is already available in all versions of projects, but it does not have sub-path-exports that folks use.
- doing this would require a polyfill, as
- use
/reactivity
instead of/reactive
- re-use
@glimmer/tracking
- would require that
@glimmer/tracking
move in to theember-source
repo - would also require a polyfill, as prior versions of
@glimmer/tracking
would not have the new behaviors
- would require that
- whole-sale pull in parts of
@warp-drive/ember
(though, much of this RFC is already heavily influenced by theirgetPromiseState
, which is backed by a promise cache -- all of which is sort of an implementation detail as far this RFC is concerned)
none (yet)
Footnotes
-
This is where resources can help (topic for later) -- otherwise you need to use a full component just for destruction (or
invokeHelper
on a class-based helper, which is very un-ergonomic). ↩