Skip to content

Commit

Permalink
Merge pull request #615 from emberjs/autotracking-memoization
Browse files Browse the repository at this point in the history
  • Loading branch information
rwjblue authored May 8, 2020
2 parents a2957f1 + 1183351 commit f0a9b20
Showing 1 changed file with 356 additions and 0 deletions.
356 changes: 356 additions & 0 deletions text/0615-autotracking-memoization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
- Start Date: 2020-04-17
- Relevant Team(s): Ember.js
- RFC PR: https://github.com/emberjs/rfcs/pull/615
- Tracking: (leave this empty)

# Autotracking Memoization

## Summary

Provides a low-level primitive for memoizing the result of a function based on
autotracking, allowing users to create their own reactive systems that can
respond to changes in autotracked state.

```js
import { tracked } from '@glimmer/tracking';
import { createCache, getValue } from '@glimmer/tracking/primitives/cache';

let computeCount = 0;

class Person {
@tracked firstName = 'Jen';
@tracked lastName = 'Weber';

#fullName = createCache(() => {
++computeCount;
return `${this.firstName} ${this.lastName}`;
})

get fullName() {
return getValue(this.#fullName);
}
}

let person = new Person();

console.log(person.fullName); // Jen Weber
console.log(count); // 1;
console.log(person.fullName); // Jen Weber
console.log(count); // 1;

person.firstName = 'Jennifer';

console.log(person.fullName); // Jennifer Weber
console.log(count); // 2;
```

## Motivation

Autotracking is the fundamental reactivity model within Ember Octane, and has
been highly successful so far in its usage and adoption. However, users today
can only integrate with autotracking via the `@tracked` decorator, which allows
them to _create_ tracked root state. There is no way to write code that responds
to changes in that root state directly - the only way to do so is indirectly via
Ember's templating layer.

An example of where users might want to do this is the [`@cached`](https://github.com/emberjs/rfcs/pull/566)
decorator for getters. This decorator only reruns its code when the tracked
state it accessed during its previous computation changes. Currently, it would
be quite difficult and error prone to build this decorator with public APIs.

For a more involved example, we can take a look at [ember-concurrency](http://ember-concurrency.com/),
which allows users to define async tasks. Ember Concurrency has an `observes()`
API for tasks, which allows tasks to rerun when a property changes. This API is
not documented or encouraged, and instead lifecycle hooks are recommended to
rerun tasks. However, in Octane, lifecycle hooks on components are no longer
available, removing that as an option. A more ergonomic, autotracked version of
concurrency tasks could be created if users had a way to react to changes in
autotracked state.

Data layers like Ember Data could also benefit from this capability. These
layers tend to have to keep state in sync between multiple levels of caching,
which traditionally was done with computed properties and eventing systems. The
ability to use autotracking to replace these systems, and to define their own
reactive semantics, could help complex libraries and data layers out immensely.

## Detailed design

This RFC proposes four functions to be added to Ember's public API:

```ts
interface Cache<T = unknown> {}

function createCache<T>(fn: () => T): Cache<T>;

function getValue<T>(cache: Cache<T>): T

function isConst(cache: Cache): boolean;
```

These functions are exposed as exports of the `@glimmer/tracking/primitives/cache`
module:

```ts
import {
createCache,
getValue,
isCache,
isConst,
} from '@glimmer/tracking/primitives/cache';
```

### Usage

`createCache` receives a function, and returns a cache instance for that function.
Users can call `getValue()` with the cache instance as an argument to run the
function and get the value of its output. The cache will then return the same
value whenever `getValue` is called again, until one of the tracked values that
was _consumed_ while it was running previously has been _dirtied_.

```ts
class State {
@tracked value;
}

let state = new State();
let computeCount = 0;

let counter = createCache(() => {
// consume the state
state.value;

return ++computeCount;
});

getValue(counter); // 1
getValue(counter); // 1

state.value = 'foo';

getValue(counter); // 2
```

Getting the value of a cache also _consumes_ the cache. This means caches can be
nested, and whenever you use a cache inside of another cache, the outer cache
will dirty if the inner cache dirties.

```ts
let inner = createCache(() => { /* ... */ })

let outer = createCache(() => {
/* ... */

inner();
});
```

This can be used to break up different parts of a execution so that only the
pieces that changed are rerun.

### Constant Caches

Caches will only recompute if any of the tracked inputs that were consumed
previously change. If there _were_ no consumed tracked inputs, then they will
never recompute.

```ts
let computeCount = 0;

let counter = createCache(() => {
return ++computeCount;
});

getValue(counter); // 1
getValue(counter); // 1
getValue(counter); // 1

// ...
```

When this happens, it often means that optimizations can be made in the code
surrounding the computation. For instance, in the Glimmer VM, we don't emit
updating bytecodes if we detect that a memoized function can never change, because
it means that this piece of DOM will never update.

In order to check if a memoized function is constant or not, users can use the
`isConst` function:

```ts
import { createCache, getValue, isConst } from '@glimmer/tracking/primitives/cache';

class State {
@tracked value;
}

let state = new State();
let computeCount = 0;

let counter = createCache(() => {
// consume the state
state.value;

return ++computeCount;
});


let constCounter = createCache(() => {
return ++computeCount;
});

getValue(counter);
getValue(constCounter);

isConst(counter); // false
isConst(constCounter); // true
```

It is not possible to know whether or not a cache is constant before its
first usage, so `isConst` will throw an error if the cache has never been
accessed before.

```ts

let constCounter = createCache(() => {
return count++;
});

isConst(constCounter); // throws an error, `constCounter` has not been used
```

This helps users avoid missing optimization opportunities by mistake, since most
optimizations happen on the first run only. If a user calls `isConst` on the
function prior to the first run, they may assume that the function is
non-constant on accident.

## How we teach this

This topic is one that is meant for advanced users and library authors. It
should be covered in detail in the Autotracking In-Depth guide in the Ember
guides.

This guide should cover how memoization works, and various techniques for using
memoization. It should cover a variety of ways to use memoization to accomplish
common tasks of other reactivity systems. Pull-based reactivity is unfamiliar to
many programmers, so we should try to familiarize them with as many common
examples as possible.

Some possibilities include:

- Building the `@cached` decorator from scratch.
- Building a data layer that syncs changes to models to localStorage or a
backend in real time, as the changes occur (note: requires polling of some
kind, or a component to do this).
- Building a `RemoteData` implementation, a helpful wrapper that sends a fetch
request to a remote url and loads data whenever the url input changes.

### API Docs

#### `createCache`

Receives a function, and returns a wrapped version of it that memoizes based on
_autotracking_. The function will only rerun whenever any tracked values used
within it have changed. Otherwise, it will return the previous value.

```ts
import { tracked } from '@glimmer/tracking';
import { createCache, getValue } from '@glimmer/tracking/primitives/cache';

class State {
@tracked value;
}

let state = new State();
let computeCount = 0;

let counter = createCache(() => {
// consume the state. Now, `counter` will
// only rerun if `state.value` changes.
state.value;

return ++computeCount;
});

getValue(counter); // 1

// returns the same value because no tracked state has changed
getValue(counter); // 1

state.value = 'foo';

// reruns because a tracked value used in the function has changed,
// incermenting the counter
getValue(counter); // 2
```

#### `getValue`

Gets the value of a cache created with `createCache`.

```ts
import { tracked } from '@glimmer/tracking';
import { createCache, getValue } from '@glimmer/tracking/primitives/cache';

let computeCount = 0;

let counter = createCache(() => {
return ++computeCount;
});

getValue(counter); // 1
```

#### `isConst`

Can be used to check if a memoized function is _constant_. If no tracked state
was used while running a memoized function, it will never rerun, because nothing
can invalidate its result. `isConst` can be used to determine if a memoized
function is constant or not, in order to optimize code surrounding that
function.

```ts
import { tracked } from '@glimmer/tracking';
import { createCache, getValue, isConst } from '@glimmer/tracking/primitives/cache';

class State {
@tracked value;
}

let state = new State();
let computeCount = 0;

let counter = createCache(() => {
// consume the state
state.value;

return computeCount++;
});


let constCounter = createCache(() => {
return computeCount++;
});

getValue(counter);
getValue(constCounter);

isConst(counter); // false
isConst(constCounter); // true
```

If called on a cache that hasn't been accessed yet, it will throw an
error. This is because there's no way to know if the function will be constant
or not yet, and so this helps prevent missing an optimization opportunity on
accident.

## Alternatives

- Stick with higher level APIs and don't expose the primitives. This could lead
to an explosion of high level complexity, as Ember tries to provide every type
of construct for users to use, rather than a low level primitive.

- Expose a more functional or more object-oriented API. This would be a somewhat
higher level API than the one proposed here, which may be a bit more
ergonomic, but also would be less flexible. Since this is a new primitive and
we aren't sure what features it may need in the future, the current design
keeps the implementation open and lets us experiment without foreclosing on a
possible higher level design in the future.

0 comments on commit f0a9b20

Please sign in to comment.