From e41fbbd8d15da35af9cdc796e1f3b23ae46a157c Mon Sep 17 00:00:00 2001 From: "@NullVoxPopuli's reduced-access machine account for AI usage" Date: Fri, 31 Jul 2026 07:52:45 -0400 Subject: [PATCH 1/3] Overload cached for non-class use (#19) * Add RFC: Overload cached for non-class use Derived-state companion to RFC 1071 (overloaded tracked). Re-uses the Reactive/ReadOnlyReactive interfaces defined there; cached(fn, options) returns a read-only CachedValue. * Fill in proposal PR URL Co-Authored-By: Claude Fable 5 * Do not conflate derived state with cached state Derived state is just plain functions and needs no API; cached() is opt-in memoization of a derivation. Reword Summary, Motivation, usage headings, and How We Teach accordingly. Co-Authored-By: Claude Fable 5 * Address review: guides link, drop terms paragraph, RFC 615 to appendix - Motivation references the in-flight reactivity guides (ember-learn/guides-source#2219) instead of claiming docs are sparse - remove the derived-vs-cached clarification paragraph (the guides cover it; not an ambiguation) - move the RFC 615 relationship to the Appendix, without any de-emphasize/deprecate intent Co-Authored-By: Claude Fable 5 * Address review: drop equals, drop 'memoization' wording - remove the equals option from the proposed API entirely (calling fn is the expensive part; re-running it to discard the result is silly); noted as deferred in the Appendix - say caching, not memoization, throughout - reword the 'born a ReadOnlyReactive' sentence - drop the implementation-would-be-lower aside Co-Authored-By: Claude Fable 5 * Apply suggestion from @NullVoxPopuli * Address review: get on ReadOnlyReactive, trim examples and mentions - no new CachedValue interface; add get to ReadOnlyReactive instead (an RFC 1071 oversight) and return ReadOnlyReactive from cached() - apply suggested guides sentence (drop 'over the years') - Starbeam only in prior art; resources are a different concept - remove the contrived nested-let template example Co-Authored-By: Claude Fable 5 --------- Co-authored-by: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Co-authored-by: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> --- .../0000-overload-cached-for-non-class-use.md | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 text/0000-overload-cached-for-non-class-use.md diff --git a/text/0000-overload-cached-for-non-class-use.md b/text/0000-overload-cached-for-non-class-use.md new file mode 100644 index 0000000000..ec491c1722 --- /dev/null +++ b/text/0000-overload-cached-for-non-class-use.md @@ -0,0 +1,349 @@ +--- +stage: accepted +start-date: 2026-07-30T00:00:00.000Z +release-date: # In format YYYY-MM-DDT00:00:00.000Z +release-versions: +teams: # delete teams that aren't relevant + - framework + - learning + - typescript +prs: + accepted: https://github.com/NullVoxPopuli/rfcs/pull/19 +project-link: +suite: +--- + + + +# Overload `cached` to work outside of classes + +## Summary + +This RFC introduces an overload to the existing `cached` function, allowing it to be used outside of classes. + +This is the caching companion to [RFC#1071](https://github.com/emberjs/rfcs/blob/master/text/1071-overload-tracked-for-non-class-use.md), which overloaded `tracked` for use outside of classes, and this RFC re-uses the interfaces defined there (adding `get` to `ReadOnlyReactive`, which was an oversight). + +## Motivation + +Our guides are gaining in-depth reactivity documentation ([ember-learn/guides-source#2219](https://github.com/ember-learn/guides-source/pull/2219)), and it's been useful to talk about reactive primitives as things _outside_ of classes, and compose/wrap them in to refactoring boundaries (classes, components, etc). + +[RFC#1071](https://github.com/emberjs/rfcs/blob/master/text/1071-overload-tracked-for-non-class-use.md) gave us `tracked()` for _root state_ outside of classes, but there is no ergonomic equivalent for caching a computation -- today, caching outside of a class requires either a class with a `@cached` getter, or dropping down to the caching primitives from [RFC#615](https://github.com/emberjs/rfcs/blob/master/text/0615-autotracking-memoization.md). + +Enabling `cached` to be used outside of a class makes it a good tool for demos[^demos] for caching expensive computations in function-based APIs, such as _helpers_, _modifiers_, or _resources_ (or even in module space)[^apps]. They also provide a benefit in testing as well, since tests tend to want to assert that expensive computations do not re-run unnecessarily. + +`cached`-as-non-decorator was prototyped in [Starbeam](https://starbeamjs.com/guides/fundamentals/functions.html) (as `CachedFormula`). + +[^apps]: Apps typically should not have reactive state in module space, becaues it doesn't get automatically reset between tests, since we don't reload modules between each tests (partly for perf reasons). Cached state in module space is safer than root state (it recomputes when its inputs reset), but the same caution applies to what it reads. + +[^demos]: demos _must_ over simplify to bring attention to a specific concept. Too much syntax getting in the way easily distracts from what is trying to be demoed. This has benefits for actual app development as well though, as we're, by focusing on concise demo-ability, gradually removing the amount of typing needed to create features. + +## Detailed design + +Developers will continue to use: + +```ts +import { cached } from '@glimmer/tracking'; +``` + +however, when called with a function (not used as a decorator), a different return value will be available: +- a `ReadOnlyReactive` + +> [!IMPORTANT] +> This particular return value gives us the abilitiy in the future guides talking about reactivity a way to describe what the `@cached` decorator is doing (since decorators are not in every ecosystem), we can describe it as a syntactic sugar on top of a `ReadOnlyReactive`. + +### Types + +This RFC re-uses the interfaces defined in [RFC#1071](https://github.com/emberjs/rfcs/blob/master/text/1071-overload-tracked-for-non-class-use.md): + +~~~ts +interface Reactive { + /** + * The underlying value + * + * Allows easy usage of reactive values in templates. + */ + value: Value; +} + +// Useful internal concept for optimizations +interface ReadOnlyReactive extends Reactive { + /** + * The underlying value. + * Cannot be set. + */ + readonly value: Value; + + /** + * Function short-hand of reading the value + */ + get: () => Value; +} +~~~ + +`get` was an oversight in RFC#1071's `ReadOnlyReactive` -- this RFC adds it to the interface (rather than introducing a new interface). `TrackedValue` already has `get`, so it already conforms. + +This RFC adds: + +~~~ts +/** +* Utility to create a cached computation. +*/ +function cached( + fn: () => Value, + options?: { + description?: string + } = {} +): ReadOnlyReactive; +~~~ + +Unlike RFC#1071's `TrackedValue`, there is no `set`, `update`, or `freeze` -- the returned value has no storage of its own; its value comes entirely from the tracked state its function reads, so it is a `ReadOnlyReactive` from the start. + +Behaviorally, `cached()` behaves almost the same as this function: +```js +import { createCache, getValue } from '@glimmer/tracking/primitives/cache'; + +function cached(fn, { description } = {}) { + return new CachedValuePolyfill(fn, { description }); +} + +class CachedValuePolyfill { + #cache; + + constructor(fn, options) { + this.#cache = createCache(fn, options.description); + } + + get value() { + // reading entangles with the tracked state `fn` reads + return getValue(this.#cache); + } + + get() { + return this.value; + } +} +``` + +The function passed to `cached` is only re-invoked when tracked state it previously read has changed -- the same caching behavior as the `@cached` decorator from [RFC#566](https://github.com/emberjs/rfcs/blob/master/text/0566-memo-decorator.md). + +### Usage + +Caching a computation over module state. +This is already common in demos. + +```gjs +import { tracked, cached } from '@glimmer/tracking'; + +const count = tracked(0); +const doubled = cached(() => count.value * 2); +const increment = () => count.value++; + + +``` + +Using private mutable properties providing public, cached, read-only access: + +```gjs +export class MyAPI { + #state = tracked(0); + + #expensive = cached(() => veryExpensiveFunction(this.#state.value)); + + get expensive() { + return this.#expensive.value; + } + + doTheThing() { + this.#state.value = secretFunctionFromSomewhere(); + } +} +``` + +### Re-implementing `@cached` + +> [!NOTE] +> This is a conceptual exercise, and for performance reasons it won't be implemented this way + +For most current ember projects, using the TC39 Stage 1 implementation of decorators: + +```js +import { cached as glimmerCached } from '@glimmer/tracking'; + +function cached(target, key, { get }) { + let caches = new WeakMap(); + + function getCache(obj) { + let cache = caches.get(obj); + + if (cache === undefined) { + cache = glimmerCached(() => get.call(obj), { description: `cached:${key}` }); + caches.set(obj, cache); + } + + return cache; + }; + + return { + get() { + return getCache(this).value; + }, + }; +} +``` + +
Using spec / standards-decorators + +```js +import { cached as glimmerCached } from '@glimmer/tracking'; + +export function cached(target, context) { + let caches = new WeakMap(); + + return function (this: object) { + let cache = caches.get(this); + + if (cache === undefined) { + cache = glimmerCached(() => target.call(this), { description: `cached:${String(context.name)}` }); + caches.set(this, cache); + } + + return cache.value; + }; +} +``` + +
+ +## How we teach this + +The `cached` function is a low-level tool, for folks that want specific behavior and for most real applications, folks should continue to use classes, with `@cached` getters, as the combination of classes with decorators provide unparalleled ergonomics in state management. + +However, developers may think of `@cached` (or decorators in general) as magic -- we can utilize `cached()` as a storytelling tool to demystify how `@cached` works -- since `cached()` will be public API, we can easily explain how `cached()` is used to _create the `@cached` decorator_ (without discussing the real private APIs that we _don't_ want folks using (such as those exported from `@glimmer/validator`). + +We can even use the example over-simplified implementation of `@cached` from the _Detailed Design_ section above. + +Together with `tracked()` from RFC#1071, this completes the story for teaching reactivity without classes. As with the `@cached` decorator, overuse is discouraged: most computations are cheap and should stay plain functions. + +### When to use `value` + +Allows for easy use in templates as well as in getters: + +```gjs +import { tracked, cached } from '@glimmer/tracking'; + +const count = tracked(0); +const doubled = cached(() => count.value * 2); +const increment = () => count.value++; + + +``` + +### When to use `get()` + +Allows passing the read as a function, e.g. to utilities that accept a thunk: + +```gjs +import { tracked, cached } from '@glimmer/tracking'; + +const count = tracked(0); +const doubled = cached(() => count.value * 2); + +const logLater = (read) => setTimeout(() => console.log(read())); + + +``` + +## Drawbacks + +- same API does multiple things based on usage, but developers should be used to this somewhat as overloading is nothing new -- TS will also be agreeable with the overloads -- and RFC#1071 has already established this pattern for `tracked` +- potential confusion between `@cached` (decorates a getter) and `cached(fn)` (wraps a function) -- though the mental model is the same: "cache this computation against the tracked state it reads" + +## Alternatives + +- completetly new API, such as `memo` or `formula` +- continue pointing folks at `createCache` / `getValue` from `@glimmer/tracking/primitives/cache` (2 imports, and a `primitives` path that signals "not for app developers") + +## Unresolved questions + +- none yet + +## Appendix + +### Relationship to the primitives from RFC#615 + +The primitives from [RFC#615](https://github.com/emberjs/rfcs/blob/master/text/0615-autotracking-memoization.md) (`createCache` / `getValue`) provide the same caching capability, and the implementation of `cached()` sits on the same machinery. `cached()` packages that capability in the already-public `cached` import, without requiring 2 imports from a `primitives` path. The primitives remain as-is, and stay useful for library authors. + +### Deferred: an `equals` option + +An earlier draft proposed an `equals` option for retaining the previous value's identity when a re-computation produced an equivalent result. Since calling the function at all is the expensive part, re-running it only to discard the result has unclear benefit -- so it is not part of this RFC. It could be added later without changing the API proposed here. + +### Naming: value + +Consistent with RFC#1071's `TrackedValue`. Value is generic enough, and is a generally understood concept without nuance. + +### Naming: `get` and `read` + +The same reasoning as RFC#1071 applies: + +- `get` implies that you are always going to do something with what is given to you +- `read` somewhat implies that you want to see the state of the cached value, but is ambigous about if you want to do anything with that information + +`get`, in particular, (while ~ unfortunately ~, matches legacy naming in our history), matches existing JS concepts from Map, WeakMap, other other concepts. + +### Why no `set`, `update`, or `freeze` + +The value returned from `cached()` has no storage of its own -- its value is entirely a function of the tracked state its function reads. Writing to it is meaningless, and it is already permanently "frozen" from the consumer's point of view. This is also why `cached()` returns a `ReadOnlyReactive` rather than a `Reactive`. + +### Extension + +If folks wanted, they could make their own cached value with previous or historical values. This could be useful for extremely expensive operations that depend on previous computations. +To do this, folks would need to implement their own class: +```js +class CachedValueWithHistory { + #previous; + #current; + + constructor(fn) { + this.#current = cached(() => { + this.#previous = untrack(() => this.#current?.value); + return fn(this.#previous); + }); + } + + get value() { + return this.#current.value; + } + + get previous() { + return this.#previous; + } + + // ... +} +``` From 2e9e7718d9860e562671c6478a2f4bd89634c386 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:55:06 -0400 Subject: [PATCH 2/3] Update PR link for RFC 1218 --- ...n-class-use.md => 1218-overload-cached-for-non-class-use.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename text/{0000-overload-cached-for-non-class-use.md => 1218-overload-cached-for-non-class-use.md} (99%) diff --git a/text/0000-overload-cached-for-non-class-use.md b/text/1218-overload-cached-for-non-class-use.md similarity index 99% rename from text/0000-overload-cached-for-non-class-use.md rename to text/1218-overload-cached-for-non-class-use.md index ec491c1722..5bac0d262d 100644 --- a/text/0000-overload-cached-for-non-class-use.md +++ b/text/1218-overload-cached-for-non-class-use.md @@ -8,7 +8,7 @@ teams: # delete teams that aren't relevant - learning - typescript prs: - accepted: https://github.com/NullVoxPopuli/rfcs/pull/19 + accepted: https://github.com/emberjs/rfcs/pull/1218/ project-link: suite: --- From 1f99b3a673a71014356e0c1743b1eb64cddc1ad0 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:58:14 -0400 Subject: [PATCH 3/3] Apply suggestion from @NullVoxPopuli --- text/1218-overload-cached-for-non-class-use.md | 1 + 1 file changed, 1 insertion(+) diff --git a/text/1218-overload-cached-for-non-class-use.md b/text/1218-overload-cached-for-non-class-use.md index 5bac0d262d..b1089791a0 100644 --- a/text/1218-overload-cached-for-non-class-use.md +++ b/text/1218-overload-cached-for-non-class-use.md @@ -331,6 +331,7 @@ class CachedValueWithHistory { constructor(fn) { this.#current = cached(() => { + // BUG: previous is not reactive this.#previous = untrack(() => this.#current?.value); return fn(this.#previous); });