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.
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
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,
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:
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:
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.
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.
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.
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:
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.
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.
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
.
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.
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.