From 4eb777637a730425b6a34bdd9e0b511849f07721 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 19 Jan 2025 12:38:52 -0500 Subject: [PATCH 1/4] The Cell --- text/0000-cell.md | 167 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 text/0000-cell.md diff --git a/text/0000-cell.md b/text/0000-cell.md new file mode 100644 index 0000000000..fc0569854c --- /dev/null +++ b/text/0000-cell.md @@ -0,0 +1,167 @@ +--- +stage: accepted +start-date: 2025-01-19T00:00:00.000Z +release-date: # In format YYYY-MM-DDT00:00:00.000Z +release-versions: +teams: # delete teams that aren't relevant + - cli + - data + - framework + - learning + - steering + - typescript +prs: + accepted: # Fill this in with the URL for the Proposal RFC PR +project-link: +suite: +--- + + + +<-- Replace "RFC title" with the title of your RFC --> +# Introduce `Cell` + +## Summary + +This RFC introduces a new tracking primitive, which represents a single value, the `Cell`. + +## Motivation + +The `Cell` is part of the "spreadsheet analogy" when talking about reactivity -- it represents a single tracked value, and can be created without the use of a class, making it a primate candidate for demos[^demos] and for creating reactive values in function-based APIs, such as _helpers_, _modifiers_, or _resources_. They also provide a benefit in testing as well, since tests tend to want to work with some state, the `Cell` is wholly encapsulated, and can be quickly created with 0 ceremony. + +This is not too dissimilar to the [Tracked Storage Primitive in RFC#669](https://github.com/emberjs/rfcs/blob/master/text/0669-tracked-storage-primitive.md). The `Cell` provides more ergonomic benefits as it doesn't require 3 imports to use. + +The `Cell` was prototyped in [Starbeam](https://starbeamjs.com/guides/fundamentals/cells.html) and has been available for folks to try out in ember via [ember-resources](https://github.com/NullVoxPopuli/ember-resources/tree/main/docs/docs). + +[^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 + + +### Types + +Some interfaces to share with future low-level reactive primitives: + +```ts + +interface Reactive { + /** + * The underlying value + */ + current: Value; + /** + * Returns the underlying value + */ + read(): Value; +} + +interface ReadOnlyReactive extends Reactive { + /** + * The underlying value. + * Cannot be set. + */ + readonly current: Value; + + /** + * Returns the underlying value + */ + read(): Value; +} + +interface Cell extends Reactive { + /** + * Utility to create a Cell without the caller using the `new` keyword. + */ + static create(initialValue: T): Cell; + + /** + * Function short-hand of updating the current value + * of the Cell + */ + set: (value: Value) => boolean; + /** + * Function short-hand for using the current value to + * update the state of the Cell + */ + update: (fn: (value: Value) => Value) => void; + + /** + * Prevents further updates, making the Cell + * behave as a ReadOnlyReactive + */ + freeze: () => void; +} +``` + + +### Usage + +Incrementing a count with local state. + +```gjs +import { Cell } from '@glimmer/tracking'; + +const increment = (cell) => cell.current++; + + +``` + +Incrementing a count with module state. +This is already common in demos. + +```gjs +import { Cell } from '@glimmer/tracking'; + +const count = Cell.create(0); +const increment => count.current++; + + +``` + + +### Re-implementing `@tracked` + + +## How we teach this + +The `Cell` is a primitive, and for most real applications, folks should continue to use classes, with `@tracked`, as the combination of classes with decorators provide unparalleled ergonomics in state management. + +However, developers may think of `@tracked` (or decorators in general) as magic -- we can utilize `Cell` as a storytelling tool to demystify how `@tracked` works -- since `Cell` will be public API, we can easily explain how `Cell` is used to _create the `@tracked` decorator_. + +We can even use the example over-simplified implementation of `@tracked` from the _Detailed Design_ section above. + + +## Drawbacks + +- another API + +## Alternatives + +- don't do it, we have tracked storage (tho, it is not implemented at the time of writing this RFC) + +## Unresolved questions + +- none yet + From c0b20b68f370d6baf787cb1e479610c71ccf2d50 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 19 Jan 2025 13:37:12 -0500 Subject: [PATCH 2/4] Update meta --- text/{0000-cell.md => 1071-cell.md} | 73 ++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) rename text/{0000-cell.md => 1071-cell.md} (79%) diff --git a/text/0000-cell.md b/text/1071-cell.md similarity index 79% rename from text/0000-cell.md rename to text/1071-cell.md index fc0569854c..f67434c7f7 100644 --- a/text/0000-cell.md +++ b/text/1071-cell.md @@ -11,7 +11,7 @@ teams: # delete teams that aren't relevant - steering - typescript prs: - accepted: # Fill this in with the URL for the Proposal RFC PR + accepted: https://github.com/emberjs/rfcs/pull/1071 project-link: suite: --- @@ -140,9 +140,80 @@ const increment => count.current++; ``` +Using private mutable properties providing public read-only access: + +```gjs +export class MyAPI { + #state = Cell.create(0); + + get myValue() { + return this.#state; + } + + doTheThing() { + this.#state = secretFunctionFromSomewhere(); + } +} +``` + ### Re-implementing `@tracked` +For most current ember projects, using the TC39 Stage 1 implementation of decorators: + +```js +function tracked(target, key, { initializer }) { + let cells = new WeakMap(); + + function getCell(obj) { + let cell = cells.get(obj); + + if (cell === undefined) { + cell = Cell.create(initializer.call(this), () => false); + cells.set(this, cell); + } + + return cell; + }; + + return { + get() { + return getCell(this).read(); + }, + + set(value) { + getCell(this).set(value); + }, + }; +} +``` + +
Using spec / standards-decorators + +```js +import { Cell } from '@glimmer/tracking'; + +export function tracked(target, context) { + const { get } = target; + + return { + get() { + return get.call(this).read(); + }, + + set(value) { + get.call(this).set(value); + }, + + init(value) { + return Cell.create(value); + }, + }; +} +``` + +
+ ## How we teach this From 7b9e4b753cca51bae963c7f49816d7de7edb652b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 19 Jan 2025 13:54:54 -0500 Subject: [PATCH 3/4] The Cell --- text/1071-cell.md | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/text/1071-cell.md b/text/1071-cell.md index f67434c7f7..86f29375cd 100644 --- a/text/1071-cell.md +++ b/text/1071-cell.md @@ -84,7 +84,12 @@ interface Cell extends Reactive { /** * Utility to create a Cell without the caller using the `new` keyword. */ - static create(initialValue: T): Cell; + static create(initialValue: T, equals?: (a: T, b: T) => boolean): Cell; + + /** + * The constructor takes an optional initial value and optional equals function. + */ + constructor(initialValue?: Value, equals?: (a: Value, b: Value) => boolean): {} /** * Function short-hand of updating the current value @@ -105,6 +110,41 @@ interface Cell extends Reactive { } ``` +Behaviorally, the `Cell` behaves almost the same as this function: +```js +class CellPolyfill { + @tracked current; + #isFrozen = false; + + constructor(initialValue) { + this.current = initialValue; + } + + read() { + return this.current; + } + + set(value) { + assert(`Cannot set a frozen Cell`, !this.#isFrozen); + this.current = value; + } + + update(updater) { + assert(`Cannot update a frozen Cell`, !this.#isFrozen); + this.set(updater(this.read())); + } + + freeze() { + this.#isFrozen = true; + } +} +``` + +The key difference is that with a primitive, we expose a new way for developers to decide when their value becomes dirty. +The above example, and the default value, would use the "always dirty" behavior of `() => false`. + +This default value allows the `Cell` to be the backing implementation if `@tracked`, as `@tracked` values do not have equalty checking to decide when to become dirty. + ### Usage @@ -230,7 +270,7 @@ We can even use the example over-simplified implementation of `@tracked` from th ## Alternatives -- don't do it, we have tracked storage (tho, it is not implemented at the time of writing this RFC) +- Have the cell's equality function check value-equality of primitives, rather than _always_ dirty. This may mean that folks apps could subtly break if we changed the `@tracked` implementation. But we could maybe provide a different tracked implementation from a different import, if we want to pursue this equality checking without breaking folks existing apps. ## Unresolved questions From 21cef4c605f07fbef6cc6e171a948d8bb07711f5 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 19 Jan 2025 13:57:25 -0500 Subject: [PATCH 4/4] The Cell --- text/1071-cell.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/text/1071-cell.md b/text/1071-cell.md index 86f29375cd..271cad85a5 100644 --- a/text/1071-cell.md +++ b/text/1071-cell.md @@ -145,6 +145,24 @@ The above example, and the default value, would use the "always dirty" behavior This default value allows the `Cell` to be the backing implementation if `@tracked`, as `@tracked` values do not have equalty checking to decide when to become dirty. +For example, with this Cell and equality function: + +```gjs +const value = Cell.create(0, (a, b) => a === b); + +const selfAssign = () => value.current = value.current; + + +``` + +The contents of the `output` element would never re-render due to the value never changing. + +This differs from `@tracked`, as the contents of `output` would always re-render. + ### Usage