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 01/22] 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 02/22] 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 03/22] 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 04/22] 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 From 3aee4a253c57e141f09fd9741f20ca40066995cd Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:53:37 -0500 Subject: [PATCH 05/22] Update text/1071-cell.md --- text/1071-cell.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/1071-cell.md b/text/1071-cell.md index 271cad85a5..b01d222b0e 100644 --- a/text/1071-cell.md +++ b/text/1071-cell.md @@ -89,7 +89,7 @@ interface Cell extends Reactive { /** * The constructor takes an optional initial value and optional equals function. */ - constructor(initialValue?: Value, equals?: (a: Value, b: Value) => boolean): {} + constructor(initialValue: Value, equals?: (a: Value, b: Value) => boolean): {} /** * Function short-hand of updating the current value From 0d7b5e10970c207fbed4896fc833e59d2853c965 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:04:59 -0500 Subject: [PATCH 06/22] Update from todos --- text/1071-cell.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/text/1071-cell.md b/text/1071-cell.md index b01d222b0e..63730fb964 100644 --- a/text/1071-cell.md +++ b/text/1071-cell.md @@ -84,7 +84,7 @@ interface Cell extends Reactive { /** * Utility to create a Cell without the caller using the `new` keyword. */ - static create(initialValue: T, equals?: (a: T, b: T) => boolean): Cell; + static create(initialValue: T, equals?: (a: T, b: T) => boolean, description?: string): Cell; /** * The constructor takes an optional initial value and optional equals function. @@ -113,25 +113,34 @@ interface Cell extends Reactive { Behaviorally, the `Cell` behaves almost the same as this function: ```js class CellPolyfill { - @tracked current; #isFrozen = false; + #value; - constructor(initialValue) { - this.current = initialValue; + constructor(initialValue, equals, description) { + this.#value = initialValue; + // ... + } + + get current() { + // + consume + return this.#value; } read() { - return this.current; + // + consume + return this.#value; } set(value) { assert(`Cannot set a frozen Cell`, !this.#isFrozen); - this.current = value; + // + dirty + this.#value = value; } update(updater) { assert(`Cannot update a frozen Cell`, !this.#isFrozen); - this.set(updater(this.read())); + // #value is not tracked + this.set(updater(this.#value)); } freeze() { @@ -227,7 +236,7 @@ function tracked(target, key, { initializer }) { let cell = cells.get(obj); if (cell === undefined) { - cell = Cell.create(initializer.call(this), () => false); + cell = Cell.create(initializer.call(this), null, `tracked:${key}`); cells.set(this, cell); } @@ -264,7 +273,7 @@ export function tracked(target, context) { }, init(value) { - return Cell.create(value); + return Cell.create(value, null, `tracked:${key}`); }, }; } From 99e40db520dc77ddc926a7261005a634db5a0465 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:31:46 -0500 Subject: [PATCH 07/22] Apply performance feedback around class instantiation, reducing allocations, and ensuring that there is only one in-memory copy of the class by making all arguments required --- text/1071-cell.md | 56 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/text/1071-cell.md b/text/1071-cell.md index 63730fb964..3c1797a93c 100644 --- a/text/1071-cell.md +++ b/text/1071-cell.md @@ -80,16 +80,34 @@ interface ReadOnlyReactive extends Reactive { read(): Value; } -interface Cell extends Reactive { - /** - * Utility to create a Cell without the caller using the `new` keyword. - */ - static create(initialValue: T, equals?: (a: T, b: T) => boolean, description?: string): Cell; +/** +* Utility to create a Cell without the caller using the `new` keyword. +* exists as a separate function so that in memory, there is only one copy of the Cell class, +* with a single constructor. +*/ +function cell( + initialValue: Value, + options?: { equals: (a: Value, b: Value) => boolean, description?: string } = {} +) { + return new Cell( + initialValue, + { + equals: options?.equals ?? Object.is, + description: options?.description + } + ); +} +interface Cell extends Reactive { /** * The constructor takes an optional initial value and optional equals function. */ - constructor(initialValue: Value, equals?: (a: Value, b: Value) => boolean): {} + constructor( + initialValue: Value, + options: { + equals: (a: Value, b: Value) => boolean; + description: string + }): {} /** * Function short-hand of updating the current value @@ -112,12 +130,18 @@ interface Cell extends Reactive { Behaviorally, the `Cell` behaves almost the same as this function: ```js +function cell(initial, { equals, description ) = {}) { + return new CellPolyfill(initial, { equals, description }); +} + class CellPolyfill { #isFrozen = false; #value; - constructor(initialValue, equals, description) { + constructor(initialValue, options) { this.#value = initialValue; + this.#equals = options.equals; + this.#description = options.description; // ... } @@ -157,7 +181,7 @@ This default value allows the `Cell` to be the backing implementation if `@track For example, with this Cell and equality function: ```gjs -const value = Cell.create(0, (a, b) => a === b); +const value = cell(0, { equals (a, b) => a === b }); const selfAssign = () => value.current = value.current; @@ -178,12 +202,12 @@ This differs from `@tracked`, as the contents of `output` would always re-render Incrementing a count with local state. ```gjs -import { Cell } from '@glimmer/tracking'; +import { cell } from '@glimmer/tracking'; -const increment = (cell) => cell.current++; +const increment = (c) => c.current++;