Skip to content

Latest commit

 

History

History
539 lines (403 loc) · 19.3 KB

1060-tracked-promise.md

File metadata and controls

539 lines (403 loc) · 19.3 KB
stage start-date release-date release-versions teams prs project-link suite
accepted
2024-12-19 00:00:00 UTC
data
framework
learning
accepted

Built in tracking utility for promises

Summary

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).

Motivation

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:

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.

Detailed design

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

trackedPromise

This utility wraps and instruments any promise with reactive state, TrackedAsyncState.

Sample type declaration

export function trackedPromise<Value>(
    existingPromise: Promise<Value> | Value
): TrackedAsyncState<Value> {
    /* ... */
}

TrackedAsyncState

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]));

Subtle Notes

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.

@ember/reactive

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.

Consumption

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 their devDependencies or dependencies
    • importing @ember/reactive would be handled by ember-auto-import/embroider (as is the case with all v2 addons)
  • v1 addons would not be supported
  • v2 addons, for maximum compatibility, would need to add @ember/reactive to their dependencies
    • 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 their devDependencies or dependencies, as we can rely on the ember-addon#renamed-modules config in ember-source's package.json.

How we teach this

API Docs

trackedPromise

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>
}

TrackedAsyncState

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>
}

Guides

use with fetch

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

creating reactive promises

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>
}

keeping the latest value while new content loads

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>
}

Drawbacks

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.

Alternatives

  • reclaim the ember package and export under ember/reactive, add ember 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.
  • use /reactivity instead of /reactive
  • re-use @glimmer/tracking
    • would require that @glimmer/tracking move in to the ember-source repo
    • would also require a polyfill, as prior versions of @glimmer/tracking would not have the new behaviors
  • whole-sale pull in parts of @warp-drive/ember (though, much of this RFC is already heavily influenced by their getPromiseState, which is backed by a promise cache -- all of which is sort of an implementation detail as far this RFC is concerned)

Unresolved questions

none (yet)

Footnotes

  1. 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).