From 1a84db0322971e1009f813bf7b85d62172148dde Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Thu, 9 Apr 2020 17:16:49 -0700 Subject: [PATCH 01/10] adds Autotracking Memoization RFC --- text/0000-autotracking-memoization.md | 390 ++++++++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 text/0000-autotracking-memoization.md diff --git a/text/0000-autotracking-memoization.md b/text/0000-autotracking-memoization.md new file mode 100644 index 0000000000..b11a363a1b --- /dev/null +++ b/text/0000-autotracking-memoization.md @@ -0,0 +1,390 @@ +- Start Date: 2020-04-17 +- Relevant Team(s): Ember.js +- RFC PR: (after opening the RFC PR, update this with a link to it and update the file name) +- 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 { memoizeTracked } from '@glimmer/tracking/primitives'; + +let count = 0; + +class Person { + @tracked firstName = 'Jen'; + @tracked lastName = 'Weber'; + + fullName = memoizeTracked(() => { + count++; + return `${this.firstName} ${this.lastName}`; + }) +} + +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 +not be possible 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 two functions to be added to Ember's public API: + +```ts +function memoizeTracked(fn: (...args: Args) => T): (...args: Args) => T; + +function isConst(fn: () => unknown): boolean; +``` + +These functions are exposed as exports of the `@glimmer/tracking/primitives` +module: + +```ts +import { + memoizeTracked, + isConst +} from '@glimmer/tracking/primitives'; +``` + +### Usage + +`memoizeTracked` receives a function, and returns the same function, memoized. +The function will only rerun when the tracked values that have been _consumed_ +while it was running have been _dirtied_. Otherwise, it will return the +previously computed value. + +```ts +class State { + @tracked value; +} + +let state = new State(); +let count = 0; + +let counter = memoizeTracked(() => { + // consume the state + state.value; + + return count++; +}); + +counter(); // 1 +counter(); // 1 + +state.value = 'foo'; + +counter(); // 2 +``` + +Memoized functions are wrapped transparently, so they still accept the same +arguments and return the same value as the original function. This gives +`memoizeTracked` more flexibility in general and helps to clean up common usage +patterns, such as memoizing a method on a class. + +```js +import { tracked } from '@glimmer/tracking'; +import { memoizeTracked } from '@glimmer/tracking/primitives'; + +class Person { + @tracked firstName = 'Jen'; + @tracked lastName = 'Weber'; + + fullName = memoizeTracked(() => { + return `${this.firstName} ${this.lastName}`; + }) +} +``` + +The results of memoized functions are _also_ consumed, so they can be nested. + +```ts +let inner = memoizeTracked(() => { /* ... */ }) + +let outer = memoizeTracked(() => { + /* ... */ + + inner(); +}); +``` + +In this example, the outer function will be invalidated whenever the inner +function invalidates. This can be used to optimize a tracked function. + +Arguments passed to the function are _not_ memoized. `memoizeTracked` only +memoizes based on autotracking, so it may or may not rerun if the same set of +arguments is passed to the function, or if a different set is passed. + +#### Function Name + +The `memoizeTracked` name was chose for two main reasons: + +1. It's a fairly verbose name, which discourages common usage. It is meant to be + a low-level API, and shouldn't make its way into common usage in app code. +2. The function name begins with `memo` instead of `track`, which means that + auto-import completion won't show it when users type `@tracked`. This will + make it less likely for users to stumble upon it without any context. + +### Constant Functions + +Memoized functions 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 count = 0; + +let counter = memoizeTracked(() => { + return count++; +}); + +counter(); // 1 +counter(); // 1 +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 is constant, 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 { memoizeTracked, isConst } from '@glimmer/tracking/primitives'; + +class State { + @tracked value; +} + +let state = new State(); +let count = 0; + +let counter = memoizeTracked(() => { + // consume the state + state.value; + + return count++; +}); + + +let constCounter = memoizeTracked(() => { + return count++; +}); + +counter(); +constCounter(); + +isConst(counter); // false +isConst(constCounter); // true +``` + +It is not possible to know whether or not a function is constant before its +first usage, so `isConst` will throw an error if the function has never been +called before. + +```ts + +let constCounter = memoizeTracked(() => { + 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. + +If called on a normal, non-memoized function, `isConst` will always return +`false`. This gives the user some flexibility in how they structure their code, +allowing them to memoize some functions but not others and still optimize them +with `isConst`. + +## 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 + +#### `memoizeTracked` + +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 { memoizeTracked } from '@glimmer/tracking/primitives'; + +class State { + @tracked value; +} + +let state = new State(); +let count = 0; + +let counter = memoizeTracked(() => { + // consume the state. Now, `counter` will + // only rerun if `state.value` changes. + state.value; + + return count++; +}); + +counter(); // 1 + +// returns the same value because no tracked state has changed +counter(); // 1 + +state.value = 'foo'; + +// reruns because a tracked value used in the function has changed, +// incermenting the counter +counter(); // 2 +``` + +#### `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 { memoizeTracked, isConst } from '@glimmer/tracking/primitives'; + +class State { + @tracked value; +} + +let state = new State(); +let count = 0; + +let counter = memoizeTracked(() => { + // consume the state + state.value; + + return count++; +}); + + +let constCounter = memoizeTracked(() => { + return count++; +}); + +counter(); +constCounter(); + +isConst(counter); // false +isConst(constCounter); // true +``` + +If called on a function that is _not_ memoized, `isConst` will always return +`false`, since that function will always rerun. + +If called on a memoized function that hasn't been run 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. + +## Drawbacks + +- The usage of the term `memoize` may mislead users. It is a _correct_ usage, as + in it meets the definition of memoization: + + > In computing, memoization or memoisation is an optimization technique used primarily to speed up computer programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again. + + The main difference being that most implementations of memoization that users + are familiar with consider the inputs to be the _arguments_ to the function. + Autotracking takes a somewhat novel approach here by saying it memoizes based + on the inputs used _during_ the calculation. + + This terminology difference is teachable, and given this is a low-level API + which is not expected to be used commonly by average users, it makes more + sense than alternatives like `autotrackedFunction` since it describes what the + function becomes - memoized. + +## 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 + +- `memoizeTracked` could return an object with a `value()` function instead of + the function itself. This would give a more natural place for setting metadata + such as `isConst`, but would sacrifice some of the ergonomics of the API. It + also would require more object creations, and given this is a very commonly + used, low-level API, it would make sense for it to avoid a design that could + be limited in terms of performance in this way. + +- `trackedMemoize` is a somewhat more accurate name that may make more sense to + the average user. It does however collide with the `tracked` import, and will + likely pop up in autocomplete tooling because of this, which would not be + ideal. As a low-level API, this function should generally not be very visible + to average users. From 7af1cf0d1cb53ccf964576bb68571f306619e429 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 17 Apr 2020 17:23:33 -0700 Subject: [PATCH 02/10] update name --- ...tracking-memoization.md => 0615-autotracking-memoization.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename text/{0000-autotracking-memoization.md => 0615-autotracking-memoization.md} (99%) diff --git a/text/0000-autotracking-memoization.md b/text/0615-autotracking-memoization.md similarity index 99% rename from text/0000-autotracking-memoization.md rename to text/0615-autotracking-memoization.md index b11a363a1b..0b61d8c0ce 100644 --- a/text/0000-autotracking-memoization.md +++ b/text/0615-autotracking-memoization.md @@ -1,6 +1,6 @@ - Start Date: 2020-04-17 - Relevant Team(s): Ember.js -- RFC PR: (after opening the RFC PR, update this with a link to it and update the file name) +- RFC PR: https://github.com/emberjs/rfcs/pull/615 - Tracking: (leave this empty) # Autotracking Memoization From 222d30ec125d22bb91abf0ea9e4988a9553e0709 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 24 Apr 2020 13:14:47 -0700 Subject: [PATCH 03/10] update naming --- text/0615-autotracking-memoization.md | 84 +++++++++++++-------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/text/0615-autotracking-memoization.md b/text/0615-autotracking-memoization.md index 0b61d8c0ce..98228f901d 100644 --- a/text/0615-autotracking-memoization.md +++ b/text/0615-autotracking-memoization.md @@ -13,7 +13,7 @@ respond to changes in autotracked state ```js import { tracked } from '@glimmer/tracking'; -import { memoizeTracked } from '@glimmer/tracking/primitives'; +import { cache } from '@glimmer/tracking/primitives'; let count = 0; @@ -21,7 +21,7 @@ class Person { @tracked firstName = 'Jen'; @tracked lastName = 'Weber'; - fullName = memoizeTracked(() => { + fullName = cache(() => { count++; return `${this.firstName} ${this.lastName}`; }) @@ -74,9 +74,9 @@ reactive semantics, could help complex libraries and data layers out immensely. This RFC proposes two functions to be added to Ember's public API: ```ts -function memoizeTracked(fn: (...args: Args) => T): (...args: Args) => T; +function cache(fn: (...args: Args) => T): (...args: Args) => T; -function isConst(fn: () => unknown): boolean; +function isConstCache(fn: () => unknown): boolean; ``` These functions are exposed as exports of the `@glimmer/tracking/primitives` @@ -84,14 +84,14 @@ module: ```ts import { - memoizeTracked, - isConst + cache, + isConstCache } from '@glimmer/tracking/primitives'; ``` ### Usage -`memoizeTracked` receives a function, and returns the same function, memoized. +`cache` receives a function, and returns the same function, memoized. The function will only rerun when the tracked values that have been _consumed_ while it was running have been _dirtied_. Otherwise, it will return the previously computed value. @@ -104,7 +104,7 @@ class State { let state = new State(); let count = 0; -let counter = memoizeTracked(() => { +let counter = cache(() => { // consume the state state.value; @@ -121,29 +121,29 @@ counter(); // 2 Memoized functions are wrapped transparently, so they still accept the same arguments and return the same value as the original function. This gives -`memoizeTracked` more flexibility in general and helps to clean up common usage +`cache` more flexibility in general and helps to clean up common usage patterns, such as memoizing a method on a class. ```js import { tracked } from '@glimmer/tracking'; -import { memoizeTracked } from '@glimmer/tracking/primitives'; +import { cache } from '@glimmer/tracking/primitives'; class Person { @tracked firstName = 'Jen'; @tracked lastName = 'Weber'; - fullName = memoizeTracked(() => { + fullName = cache(() => { return `${this.firstName} ${this.lastName}`; - }) + }); } ``` The results of memoized functions are _also_ consumed, so they can be nested. ```ts -let inner = memoizeTracked(() => { /* ... */ }) +let inner = cache(() => { /* ... */ }) -let outer = memoizeTracked(() => { +let outer = cache(() => { /* ... */ inner(); @@ -153,13 +153,13 @@ let outer = memoizeTracked(() => { In this example, the outer function will be invalidated whenever the inner function invalidates. This can be used to optimize a tracked function. -Arguments passed to the function are _not_ memoized. `memoizeTracked` only +Arguments passed to the function are _not_ memoized. `cache` only memoizes based on autotracking, so it may or may not rerun if the same set of arguments is passed to the function, or if a different set is passed. #### Function Name -The `memoizeTracked` name was chose for two main reasons: +The `cache` name was chose for two main reasons: 1. It's a fairly verbose name, which discourages common usage. It is meant to be a low-level API, and shouldn't make its way into common usage in app code. @@ -176,7 +176,7 @@ they will never recompute. ```ts let count = 0; -let counter = memoizeTracked(() => { +let counter = cache(() => { return count++; }); @@ -193,10 +193,10 @@ updating bytecodes if we detect that a memoized function is constant, 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: +`isConstCache` function: ```ts -import { memoizeTracked, isConst } from '@glimmer/tracking/primitives'; +import { cache, isConstCache } from '@glimmer/tracking/primitives'; class State { @tracked value; @@ -205,7 +205,7 @@ class State { let state = new State(); let count = 0; -let counter = memoizeTracked(() => { +let counter = cache(() => { // consume the state state.value; @@ -213,39 +213,39 @@ let counter = memoizeTracked(() => { }); -let constCounter = memoizeTracked(() => { +let constCounter = cache(() => { return count++; }); counter(); constCounter(); -isConst(counter); // false -isConst(constCounter); // true +isConstCache(counter); // false +isConstCache(constCounter); // true ``` It is not possible to know whether or not a function is constant before its -first usage, so `isConst` will throw an error if the function has never been +first usage, so `isConstCache` will throw an error if the function has never been called before. ```ts -let constCounter = memoizeTracked(() => { +let constCounter = cache(() => { return count++; }); -isConst(constCounter); // throws an error, `constCounter` has not been used +isConstCache(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 +optimizations happen on the first run only. If a user calls `isConstCache` on the function prior to the first run, they may assume that the function is non-constant on accident. -If called on a normal, non-memoized function, `isConst` will always return +If called on a normal, non-memoized function, `isConstCache` will always return `false`. This gives the user some flexibility in how they structure their code, allowing them to memoize some functions but not others and still optimize them -with `isConst`. +with `isConstCache`. ## How we teach this @@ -270,7 +270,7 @@ Some possibilities include: ### API Docs -#### `memoizeTracked` +#### `cache` 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 @@ -278,7 +278,7 @@ within it have changed. Otherwise, it will return the previous value. ```ts import { tracked } from '@glimmer/tracking'; -import { memoizeTracked } from '@glimmer/tracking/primitives'; +import { cache } from '@glimmer/tracking/primitives'; class State { @tracked value; @@ -287,7 +287,7 @@ class State { let state = new State(); let count = 0; -let counter = memoizeTracked(() => { +let counter = cache(() => { // consume the state. Now, `counter` will // only rerun if `state.value` changes. state.value; @@ -307,17 +307,17 @@ state.value = 'foo'; counter(); // 2 ``` -#### `isConst` +#### `isConstCache` 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 +can invalidate its result. `isConstCache` 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 { memoizeTracked, isConst } from '@glimmer/tracking/primitives'; +import { cache, isConstCache } from '@glimmer/tracking/primitives'; class State { @tracked value; @@ -326,7 +326,7 @@ class State { let state = new State(); let count = 0; -let counter = memoizeTracked(() => { +let counter = cache(() => { // consume the state state.value; @@ -334,18 +334,18 @@ let counter = memoizeTracked(() => { }); -let constCounter = memoizeTracked(() => { +let constCounter = cache(() => { return count++; }); counter(); constCounter(); -isConst(counter); // false -isConst(constCounter); // true +isConstCache(counter); // false +isConstCache(constCounter); // true ``` -If called on a function that is _not_ memoized, `isConst` will always return +If called on a function that is _not_ memoized, `isConstCache` will always return `false`, since that function will always rerun. If called on a memoized function that hasn't been run yet, it will throw an @@ -376,9 +376,9 @@ accident. to an explosion of high level complexity, as Ember tries to provide every type of construct for users to use, rather than -- `memoizeTracked` could return an object with a `value()` function instead of +- `cache` could return an object with a `value()` function instead of the function itself. This would give a more natural place for setting metadata - such as `isConst`, but would sacrifice some of the ergonomics of the API. It + such as `isConstCache`, but would sacrifice some of the ergonomics of the API. It also would require more object creations, and given this is a very commonly used, low-level API, it would make sense for it to avoid a design that could be limited in terms of performance in this way. From 914495e05efed0672901a9aa2f2ea732202f5bcc Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 24 Apr 2020 13:59:43 -0700 Subject: [PATCH 04/10] fix up --- text/0615-autotracking-memoization.md | 29 ++++----------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/text/0615-autotracking-memoization.md b/text/0615-autotracking-memoization.md index 98228f901d..b2d178297d 100644 --- a/text/0615-autotracking-memoization.md +++ b/text/0615-autotracking-memoization.md @@ -159,13 +159,9 @@ arguments is passed to the function, or if a different set is passed. #### Function Name -The `cache` name was chose for two main reasons: - -1. It's a fairly verbose name, which discourages common usage. It is meant to be - a low-level API, and shouldn't make its way into common usage in app code. -2. The function name begins with `memo` instead of `track`, which means that - auto-import completion won't show it when users type `@tracked`. This will - make it less likely for users to stumble upon it without any context. +The `cache` name was chosen because this is effectively what the function +produces, a cache function. It is unlikely to be mistaken for a decorator, since +the tense is different, and the import path distinguishes it as a primitive. ### Constant Functions @@ -353,28 +349,11 @@ 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. -## Drawbacks - -- The usage of the term `memoize` may mislead users. It is a _correct_ usage, as - in it meets the definition of memoization: - - > In computing, memoization or memoisation is an optimization technique used primarily to speed up computer programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again. - - The main difference being that most implementations of memoization that users - are familiar with consider the inputs to be the _arguments_ to the function. - Autotracking takes a somewhat novel approach here by saying it memoizes based - on the inputs used _during_ the calculation. - - This terminology difference is teachable, and given this is a low-level API - which is not expected to be used commonly by average users, it makes more - sense than alternatives like `autotrackedFunction` since it describes what the - function becomes - memoized. - ## 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 + of construct for users to use, rather than a low level primitive. - `cache` could return an object with a `value()` function instead of the function itself. This would give a more natural place for setting metadata From 6eb560e4028fb67911d8470559fc5f4380d54b5c Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Mon, 27 Apr 2020 19:20:02 -0700 Subject: [PATCH 05/10] update to non-opinionated design --- text/0615-autotracking-memoization.md | 244 ++++++++++++++------------ 1 file changed, 130 insertions(+), 114 deletions(-) diff --git a/text/0615-autotracking-memoization.md b/text/0615-autotracking-memoization.md index b2d178297d..daa6eb3bd8 100644 --- a/text/0615-autotracking-memoization.md +++ b/text/0615-autotracking-memoization.md @@ -13,7 +13,7 @@ respond to changes in autotracked state ```js import { tracked } from '@glimmer/tracking'; -import { cache } from '@glimmer/tracking/primitives'; +import { createCache, getValue } from '@glimmer/tracking/cache-primitive'; let count = 0; @@ -21,22 +21,26 @@ class Person { @tracked firstName = 'Jen'; @tracked lastName = 'Weber'; - fullName = cache(() => { - count++; + #fullName = createCache(() => { + ++count; return `${this.firstName} ${this.lastName}`; }) + + get fullName() { + return getValue(this.#fullName); + } } let person = new Person(); -console.log(person.fullName()); // Jen Weber +console.log(person.fullName); // Jen Weber console.log(count); // 1; -console.log(person.fullName()); // Jen Weber +console.log(person.fullName); // Jen Weber console.log(count); // 1; person.firstName = 'Jennifer'; -console.log(person.fullName()); // Jennifer Weber +console.log(person.fullName); // Jennifer Weber console.log(count); // 2; ``` @@ -71,30 +75,39 @@ reactive semantics, could help complex libraries and data layers out immensely. ## Detailed design -This RFC proposes two functions to be added to Ember's public API: +This RFC proposes four functions to be added to Ember's public API: ```ts -function cache(fn: (...args: Args) => T): (...args: Args) => T; +interface Cache {} + +function createCache(fn: () => T): Cache; + +function getValue(cache: Cache): T -function isConstCache(fn: () => unknown): boolean; +function isCache(maybeCache: object): boolean; + +function isConst(cache: Cache): boolean; ``` -These functions are exposed as exports of the `@glimmer/tracking/primitives` +These functions are exposed as exports of the `@glimmer/tracking/primitives/cache` module: ```ts import { - cache, - isConstCache -} from '@glimmer/tracking/primitives'; + createCache, + getValue, + isCache, + isConst, +} from '@glimmer/tracking/primitives/cache'; ``` ### Usage -`cache` receives a function, and returns the same function, memoized. -The function will only rerun when the tracked values that have been _consumed_ -while it was running have been _dirtied_. Otherwise, it will return the -previously computed value. +`cache` receives a function, and returns an cache instance for that function. +Users can call `getValue()` on the cache instance 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 { @@ -104,81 +117,65 @@ class State { let state = new State(); let count = 0; -let counter = cache(() => { +let counter = createCache(() => { // consume the state state.value; - return count++; + return ++count; }); -counter(); // 1 -counter(); // 1 +getValue(counter); // 1 +getValue(counter); // 1 state.value = 'foo'; -counter(); // 2 +getValue(counter); // 2 ``` -Memoized functions are wrapped transparently, so they still accept the same -arguments and return the same value as the original function. This gives -`cache` more flexibility in general and helps to clean up common usage -patterns, such as memoizing a method on a class. - -```js -import { tracked } from '@glimmer/tracking'; -import { cache } from '@glimmer/tracking/primitives'; - -class Person { - @tracked firstName = 'Jen'; - @tracked lastName = 'Weber'; - - fullName = cache(() => { - return `${this.firstName} ${this.lastName}`; - }); -} -``` - -The results of memoized functions are _also_ consumed, so they can be nested. +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 = cache(() => { /* ... */ }) +let inner = createCache(() => { /* ... */ }) -let outer = cache(() => { +let outer = createCache(() => { /* ... */ inner(); }); ``` -In this example, the outer function will be invalidated whenever the inner -function invalidates. This can be used to optimize a tracked function. +This can be used to break up different parts of a execution so that only the +pieces that changed are rerun. -Arguments passed to the function are _not_ memoized. `cache` only -memoizes based on autotracking, so it may or may not rerun if the same set of -arguments is passed to the function, or if a different set is passed. +The `isCache` function can be used to determine if a value is a cache, in cases +where it's not possible to know if the value is a cache or not. -#### Function Name +```js +let cache = createCache(() => {}); +let notACache = () => {}; -The `cache` name was chosen because this is effectively what the function -produces, a cache function. It is unlikely to be mistaken for a decorator, since -the tense is different, and the import path distinguishes it as a primitive. +isCache(cache); // true +isCache(notACache); // false +``` -### Constant Functions +### Constant Caches -Memoized functions 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. +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 count = 0; -let counter = cache(() => { - return count++; +let counter = createCache(() => { + return ++count; }); -counter(); // 1 -counter(); // 1 -counter(); // 1 +getValue(counter); // 1 +getValue(counter); // 1 +getValue(counter); // 1 // ... ``` @@ -189,10 +186,10 @@ updating bytecodes if we detect that a memoized function is constant, 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 -`isConstCache` function: +`isConst` function: ```ts -import { cache, isConstCache } from '@glimmer/tracking/primitives'; +import { createCache, getValue, isConst } from '@glimmer/tracking/primitives/cache'; class State { @tracked value; @@ -201,48 +198,43 @@ class State { let state = new State(); let count = 0; -let counter = cache(() => { +let counter = createCache(() => { // consume the state state.value; - return count++; + return ++count; }); -let constCounter = cache(() => { - return count++; +let constCounter = createCache(() => { + return ++count; }); -counter(); -constCounter(); +getValue(counter); +getValue(constCounter); -isConstCache(counter); // false -isConstCache(constCounter); // true +isConst(counter); // false +isConst(constCounter); // true ``` -It is not possible to know whether or not a function is constant before its -first usage, so `isConstCache` will throw an error if the function has never been -called before. +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 = cache(() => { +let constCounter = createCache(() => { return count++; }); -isConstCache(constCounter); // throws an error, `constCounter` has not been used +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 `isConstCache` on the +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. -If called on a normal, non-memoized function, `isConstCache` will always return -`false`. This gives the user some flexibility in how they structure their code, -allowing them to memoize some functions but not others and still optimize them -with `isConstCache`. - ## How we teach this This topic is one that is meant for advanced users and library authors. It @@ -274,7 +266,7 @@ within it have changed. Otherwise, it will return the previous value. ```ts import { tracked } from '@glimmer/tracking'; -import { cache } from '@glimmer/tracking/primitives'; +import { createCache, getValue } from '@glimmer/tracking/primitives/cache'; class State { @tracked value; @@ -283,37 +275,70 @@ class State { let state = new State(); let count = 0; -let counter = cache(() => { +let counter = createCache(() => { // consume the state. Now, `counter` will // only rerun if `state.value` changes. state.value; - return count++; + return ++count; }); -counter(); // 1 +getValue(counter); // 1 // returns the same value because no tracked state has changed -counter(); // 1 +getValue(counter); // 1 state.value = 'foo'; // reruns because a tracked value used in the function has changed, // incermenting the counter -counter(); // 2 +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 count = 0; + +let counter = createCache(() => { + return ++count; +}); + +getValue(counter); // 1 ``` -#### `isConstCache` +#### `isCache` + +Returns whether or not the value is a cache. + +```ts +import { tracked } from '@glimmer/tracking'; +import { createCache, isCache } from '@glimmer/tracking/primitives/cache'; + +let cache = createCache(() => {}); +let notACache = () => {}; + + +isCache(cache); // true +isCache(notACache); // false +``` + +#### `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. `isConstCache` can be used to determine if a memoized +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 { cache, isConstCache } from '@glimmer/tracking/primitives'; +import { createCache, getValue, isConst } from '@glimmer/tracking/primitives/cache'; class State { @tracked value; @@ -322,7 +347,7 @@ class State { let state = new State(); let count = 0; -let counter = cache(() => { +let counter = createCache(() => { // consume the state state.value; @@ -330,21 +355,18 @@ let counter = cache(() => { }); -let constCounter = cache(() => { +let constCounter = createCache(() => { return count++; }); -counter(); -constCounter(); +getValue(counter); +getValue(constCounter); -isConstCache(counter); // false -isConstCache(constCounter); // true +isConst(counter); // false +isConst(constCounter); // true ``` -If called on a function that is _not_ memoized, `isConstCache` will always return -`false`, since that function will always rerun. - -If called on a memoized function that hasn't been run yet, it will throw an +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. @@ -355,15 +377,9 @@ accident. 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. -- `cache` could return an object with a `value()` function instead of - the function itself. This would give a more natural place for setting metadata - such as `isConstCache`, but would sacrifice some of the ergonomics of the API. It - also would require more object creations, and given this is a very commonly - used, low-level API, it would make sense for it to avoid a design that could - be limited in terms of performance in this way. - -- `trackedMemoize` is a somewhat more accurate name that may make more sense to - the average user. It does however collide with the `tracked` import, and will - likely pop up in autocomplete tooling because of this, which would not be - ideal. As a low-level API, this function should generally not be very visible - to average users. +- 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. From c27bea1d09de1c0a02ff57721da648172615f619 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Tue, 28 Apr 2020 10:08:48 -0700 Subject: [PATCH 06/10] Update text/0615-autotracking-memoization.md Co-Authored-By: Scott Ames-Messinger --- text/0615-autotracking-memoization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0615-autotracking-memoization.md b/text/0615-autotracking-memoization.md index daa6eb3bd8..c83263a221 100644 --- a/text/0615-autotracking-memoization.md +++ b/text/0615-autotracking-memoization.md @@ -13,7 +13,7 @@ respond to changes in autotracked state ```js import { tracked } from '@glimmer/tracking'; -import { createCache, getValue } from '@glimmer/tracking/cache-primitive'; +import { createCache, getValue } from '@glimmer/tracking/primitives/cache'; let count = 0; From 9333565d9cf65e61c8a53ba0dee4f889ddc4472d Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Tue, 28 Apr 2020 10:10:28 -0700 Subject: [PATCH 07/10] fix typo --- text/0615-autotracking-memoization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0615-autotracking-memoization.md b/text/0615-autotracking-memoization.md index c83263a221..14613946c6 100644 --- a/text/0615-autotracking-memoization.md +++ b/text/0615-autotracking-memoization.md @@ -103,7 +103,7 @@ import { ### Usage -`cache` receives a function, and returns an cache instance for that function. +`cache` receives a function, and returns a cache instance for that function. Users can call `getValue()` on the cache instance 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_ From 4010a93dd51b59b47709b97e935bf8d817011e24 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 8 May 2020 08:50:41 -0700 Subject: [PATCH 08/10] Apply suggestions from code review Co-authored-by: Robert Jackson --- text/0615-autotracking-memoization.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/text/0615-autotracking-memoization.md b/text/0615-autotracking-memoization.md index 14613946c6..1925d54e26 100644 --- a/text/0615-autotracking-memoization.md +++ b/text/0615-autotracking-memoization.md @@ -9,7 +9,7 @@ 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 +respond to changes in autotracked state. ```js import { tracked } from '@glimmer/tracking'; @@ -56,7 +56,7 @@ 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 -not be possible to build this decorator with public APIs. +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()` @@ -103,7 +103,7 @@ import { ### Usage -`cache` receives a function, and returns a cache instance for that function. +`createCache` receives a function, and returns a cache instance for that function. Users can call `getValue()` on the cache instance 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_ @@ -182,7 +182,7 @@ 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 is constant, because +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 @@ -258,7 +258,7 @@ Some possibilities include: ### API Docs -#### `cache` +#### `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 From feb461345d80a1ffead2c2929a85e1f88a6d6481 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 8 May 2020 08:56:17 -0700 Subject: [PATCH 09/10] updates based on feedback --- text/0615-autotracking-memoization.md | 40 +++++++++++++-------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/text/0615-autotracking-memoization.md b/text/0615-autotracking-memoization.md index 1925d54e26..9e966c3f24 100644 --- a/text/0615-autotracking-memoization.md +++ b/text/0615-autotracking-memoization.md @@ -15,14 +15,14 @@ respond to changes in autotracked state. import { tracked } from '@glimmer/tracking'; import { createCache, getValue } from '@glimmer/tracking/primitives/cache'; -let count = 0; +let computeCount = 0; class Person { @tracked firstName = 'Jen'; @tracked lastName = 'Weber'; #fullName = createCache(() => { - ++count; + ++computeCount; return `${this.firstName} ${this.lastName}`; }) @@ -104,10 +104,10 @@ import { ### Usage `createCache` receives a function, and returns a cache instance for that function. -Users can call `getValue()` on the cache instance 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_. +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 { @@ -115,13 +115,13 @@ class State { } let state = new State(); -let count = 0; +let computeCount = 0; let counter = createCache(() => { // consume the state state.value; - return ++count; + return ++computeCount; }); getValue(counter); // 1 @@ -167,10 +167,10 @@ previously change. If there _were_ no consumed tracked inputs, then they will never recompute. ```ts -let count = 0; +let computeCount = 0; let counter = createCache(() => { - return ++count; + return ++computeCount; }); getValue(counter); // 1 @@ -196,18 +196,18 @@ class State { } let state = new State(); -let count = 0; +let computeCount = 0; let counter = createCache(() => { // consume the state state.value; - return ++count; + return ++computeCount; }); let constCounter = createCache(() => { - return ++count; + return ++computeCount; }); getValue(counter); @@ -273,14 +273,14 @@ class State { } let state = new State(); -let count = 0; +let computeCount = 0; let counter = createCache(() => { // consume the state. Now, `counter` will // only rerun if `state.value` changes. state.value; - return ++count; + return ++computeCount; }); getValue(counter); // 1 @@ -303,10 +303,10 @@ Gets the value of a cache created with `createCache`. import { tracked } from '@glimmer/tracking'; import { createCache, getValue } from '@glimmer/tracking/primitives/cache'; -let count = 0; +let computeCount = 0; let counter = createCache(() => { - return ++count; + return ++computeCount; }); getValue(counter); // 1 @@ -345,18 +345,18 @@ class State { } let state = new State(); -let count = 0; +let computeCount = 0; let counter = createCache(() => { // consume the state state.value; - return count++; + return computeCount++; }); let constCounter = createCache(() => { - return count++; + return computeCount++; }); getValue(counter); From 11833519ead292f4d73853d16ef8ad2e0637b489 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 8 May 2020 12:16:46 -0700 Subject: [PATCH 10/10] Apply suggestions from code review Co-authored-by: Robert Jackson --- text/0615-autotracking-memoization.md | 29 --------------------------- 1 file changed, 29 deletions(-) diff --git a/text/0615-autotracking-memoization.md b/text/0615-autotracking-memoization.md index 9e966c3f24..c0694b4a30 100644 --- a/text/0615-autotracking-memoization.md +++ b/text/0615-autotracking-memoization.md @@ -84,8 +84,6 @@ function createCache(fn: () => T): Cache; function getValue(cache: Cache): T -function isCache(maybeCache: object): boolean; - function isConst(cache: Cache): boolean; ``` @@ -149,17 +147,6 @@ let outer = createCache(() => { This can be used to break up different parts of a execution so that only the pieces that changed are rerun. -The `isCache` function can be used to determine if a value is a cache, in cases -where it's not possible to know if the value is a cache or not. - -```js -let cache = createCache(() => {}); -let notACache = () => {}; - -isCache(cache); // true -isCache(notACache); // false -``` - ### Constant Caches Caches will only recompute if any of the tracked inputs that were consumed @@ -312,22 +299,6 @@ let counter = createCache(() => { getValue(counter); // 1 ``` -#### `isCache` - -Returns whether or not the value is a cache. - -```ts -import { tracked } from '@glimmer/tracking'; -import { createCache, isCache } from '@glimmer/tracking/primitives/cache'; - -let cache = createCache(() => {}); -let notACache = () => {}; - - -isCache(cache); // true -isCache(notACache); // false -``` - #### `isConst` Can be used to check if a memoized function is _constant_. If no tracked state