Skip to content

Commit 48b6e5e

Browse files
committed
Add reactivity docs from #1690
1 parent d862748 commit 48b6e5e

File tree

8 files changed

+785
-0
lines changed

8 files changed

+785
-0
lines changed

guides/reactivity/index.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# The Glimmer Reactivity System
2+
3+
## Table of Contents
4+
5+
1. [Tag Composition](./tag-composition.md): The formal composition semantics of Glimmer's tag-based
6+
validation system.
7+
2. [The Fundamental Laws of Reactivity](./laws.md): A definition of Glimmer's reliable and
8+
consistent reactive programming model, and the rules that reactive abstractions must
9+
satisfy in order to safely support this model.
10+
3. [System Phases](./system-phases.md): A description of the phases of the Glimmer execution model:
11+
_action_, _render_, and _idle_, and how the exeuction model supported batched _UI_ updates while
12+
maintaining a _coherent_ data model.
13+
4. [Reactive Abstractions](./reactive-abstractions.md): A description of the implementation of
14+
a number of reactive abstractions, and how they satisfy the laws of reactivity.
15+
16+
### Pseudocode
17+
18+
This directory also contains pseudocode for the foundation of a reactive system that satisfies these
19+
requirements, and uses them to demonstrate the implementation of the reactive abstractions.
20+
21+
- [`tags.ts`](./pseudocode/tags.ts): A simple implementation of the tag-based validation system,
22+
including an interface for a runtime that supports tag consumptions and tracking frames.
23+
- [`primitives.ts`](./pseudocode/primitives.ts): Implementation of:
24+
- `Snapshot`, which captures a value at a specific revision with its tag validator.
25+
- `PrimitiveCell` and `PrimitiveCache`, which implement a primitive root storage and a primitive
26+
cached computation, both of which support law-abiding snapshots.
27+
- [`composition.ts`](./pseudocode/composition.ts): Implementations of the higher-level reactive
28+
constructs described in [Reactive Abstractions](./reactive-abstractions.md) in terms of the
29+
reactive primitives.
30+
31+
> [!TIP]
32+
>
33+
> While these are significantly simplified versions of the production primitives that ship with
34+
> Ember and Glimmer, they serve as clear illustrations of how to implement reactive abstractions
35+
> that satisfy the reactive laws.

guides/reactivity/laws.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# The Fundamental Laws of Reactivity
2+
3+
## ♾ The Fundamental Axiom of Reactivity
4+
5+
> ### "A reactive abstraction must provide both the current value and a means to detect invalidation without recomputation."
6+
7+
From the perspective of a Glimmer user, this axiom enables writing reactive code using standard
8+
JavaScript functions and getters that automatically reflect the current state of UI inputs.
9+
10+
**Glimmer users write UI code as straightforward rendering functions**, yet the system behaves _as
11+
if_ these functions re-execute completely whenever any reactive value changes.
12+
13+
> [!IMPORTANT]
14+
>
15+
> When root state is mutated, all reactive abstractions reflect those changes immediately, even when
16+
> implemented with caching. Glimmer's reactive values are _always coherent_ — changes are never
17+
> batched in ways that would allow inconsistencies between computed values and their underlying root
18+
> state.
19+
20+
## Definitions
21+
22+
- **Root Reactive State**: An atomic reactive value that can be updated directly. It is represented
23+
by a single [value tag](./concepts.md#value-tag). You can create a single piece of root state
24+
explicitly using the `cell` API, but containers from `tracked-builtins` and the storage created by
25+
the `@tracked` decorator are also root reactive state.
26+
- **Formula**: A reactive computation that depends on a number of reactive values. A formula's
27+
revision is the most recent revision of any of the members used during the last computation (as a
28+
[combined tag](./concepts.md#combined-tag)). A
29+
formula will _always_ recompute its output if the revision of any of its members is advanced.
30+
- **Snapshot**: A _snapshot_ of a reactive abstraction is its _current value_ at a specific
31+
revision. The snapshot <a id="invalidate"></a> _invalidates_ when the abstraction's tag has a more
32+
recent revision. _A reactive abstraction is said to _invalidate_ when any previous snapshots would
33+
become invalid._
34+
35+
## The Fundamental Laws of Reactivity
36+
37+
In order to satisfy the _Fundamental Axiom of Reactivity_, all reactive abstractions must adhere to these six laws:
38+
39+
1. **Dependency Tracking**: A reactive abstraction **must** [invalidate](#invalidate) when any
40+
reactive values used in its _last computation_ have changed. _The revision of the tag associated
41+
with the reactive abstraction <u>must</u> advance to match the revision of its most recently
42+
updated member._
43+
44+
2. **Value Coherence**: A reactive abstraction **must never** return a cached _value_ from a
45+
revision older than its current revision. _After a root state update, any dependent reactive
46+
abstractions must recompute their value when next snapshotted._
47+
48+
3. **Transactional Consistency**: During a single rendering transaction, a reactive abstraction
49+
**must** return the same value and revision for all snapshots taken within that transaction.
50+
51+
4. **Snapshot Immutability**: The act of snapshotting a reactive abstraction **must not**
52+
advance the reactive timeline. _Recursive snapshotting (akin to functional composition) naturally
53+
involves tag consumption, yet remains consistent with this requirement as immutability applies
54+
recursively to each snapshot operation._
55+
56+
5. **Defined Granularity**: A reactive abstraction **must** define a contract specifying its
57+
_invalidation granularity_, and **must not** invalidate more frequently than this contract
58+
permits. When a reactive abstraction allows value mutations, it **must** specify its equivalence
59+
comparison method. When a new value is equivalent to the previous value, the abstraction **must
60+
not** invalidate.
61+
62+
All reactive abstractions—including built-in mechanisms like `@tracked` and `createCache`, existing
63+
libraries such as `tracked-toolbox` and `tracked-builtins`, and new primitives like `cell`—must
64+
satisfy these six laws to maintain the Fundamental Axiom of Reactivity when these abstractions are
65+
composed together.
66+
67+
> [!TIP]
68+
>
69+
> In practice, the effectiveness of reactive composition is bounded by the **Defined Granularity** and **Specified Equivalence** of the underlying abstractions.
70+
>
71+
> For instance, if a [`cell`](#cell) implementation defines granularity at the level of JSON serialization equality, then all higher-level abstractions built upon it will inherit this same granularity constraint.
72+
>
73+
> The laws do not mandate comparing every value in every _computation_, nor do they require a
74+
> uniform approach to equivalence based solely on reference equality. Each abstraction defines its
75+
> own appropriate granularity and equivalence parameters.
76+
>
77+
> For developers building reactive abstractions, carefully selecting granularity and equivalence
78+
> specifications that align with user mental models is crucial—users will experience the system
79+
> through these decisions, expecting UI updates that accurately reflect meaningful changes in their
80+
> application state.
81+
>
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { PrimitiveCache, PrimitiveCell, type Status } from './primitives';
2+
import { runtime, MutableTag, type Tag } from './tags';
3+
4+
export class LocalCopy<T> {
5+
#upstream: PrimitiveCache<T>;
6+
#local: PrimitiveCell<T>;
7+
8+
constructor(compute: () => T) {
9+
this.#upstream = new PrimitiveCache(compute);
10+
this.#local = new PrimitiveCell();
11+
}
12+
13+
/**
14+
* Safely return the value of the upstream computation or the local cell, whichever is more
15+
* recent. This satisfies the laws of reactivity transitively through `mostRecent`.
16+
*/
17+
read(): T {
18+
return mostRecent(this.#upstream.snapshot(), this.#local.unsafeSnapshot()).value;
19+
}
20+
21+
/**
22+
* Safely write a value to the local cell during the "action" phase.
23+
*/
24+
write(value: T): void {
25+
this.#local.write(value);
26+
}
27+
}
28+
29+
/**
30+
* Safely returns the most recent status from the given statuses. If there are multiple status with
31+
* the same, latest revision, the first such status in the list will be returned.
32+
*
33+
* This satisfies the transactionality law because we consume all tags in all cases, which means
34+
* that:
35+
*
36+
* > The value of the most recent status cannot change after the `MostRecent` was computed in the
37+
* > same rendering transaction, because a change to any of the specified statuses would trigger a
38+
* > backtracking assertion.
39+
*
40+
* The granularity of `mostRecent` is: the call to `mostRecent` will invalidate when the tags of any
41+
* of the statuses passed to it invalidate. This is as granular as possible because a change to any
42+
* of the tags would, by definition, make it the most recent.
43+
*/
44+
function mostRecent<S extends [Status<unknown>, ...Status<unknown>[]]>(...statuses: S): S[number] {
45+
const [first, ...rest] = statuses;
46+
runtime.consume(first.tag);
47+
48+
return rest.reduce((latest, status) => {
49+
runtime.consume(latest.tag);
50+
return status.tag.revision > latest.tag.revision ? status : latest;
51+
}, first);
52+
}
53+
54+
export function tracked<V, This extends object>(
55+
_value: ClassAccessorDecoratorTarget<This, V>,
56+
context: ClassAccessorDecoratorContext<This, V>
57+
): ClassAccessorDecoratorResult<This, V> {
58+
// When the field is initialized, initialize a mutable tag to represent the root storage.
59+
context.addInitializer(function (this: This) {
60+
MutableTag.init(this, context.name);
61+
});
62+
63+
return {
64+
get(this: This): V {
65+
// When the field is accessed, consume the tag to track the read, and return the underlying
66+
// value stored in the field.
67+
const tag = MutableTag.get(this, context.name);
68+
tag.consume();
69+
return context.access.get(this);
70+
},
71+
72+
set(this: This, value: V): void {
73+
// When the field is written, update the tag to track the write, and update the underlying
74+
// value stored in the field.
75+
const tag = MutableTag.get(this, context.name);
76+
context.access.set(this, value);
77+
tag.update();
78+
},
79+
};
80+
}
81+
82+
const COMPUTE = new WeakMap<Cache<unknown>, () => unknown>();
83+
84+
declare const FN: unique symbol;
85+
type FN = typeof FN;
86+
type Cache<T> = {
87+
[FN]: () => T;
88+
};
89+
90+
export function createCache<T>(fn: () => T): Cache<T> {
91+
const cache = {} as Cache<T>;
92+
let last = undefined as { value: T; tag: Tag; revision: number } | undefined;
93+
94+
COMPUTE.set(cache, () => {
95+
if (last && last.revision >= last.tag.revision) {
96+
runtime.consume(last.tag);
97+
return last.value;
98+
}
99+
100+
runtime.begin();
101+
try {
102+
const result = fn();
103+
const tag = runtime.commit();
104+
last = { value: result, tag, revision: runtime.current() };
105+
runtime.consume(tag);
106+
return result;
107+
} catch {
108+
last = undefined;
109+
}
110+
});
111+
112+
return cache;
113+
}
114+
115+
export function getCache<T>(cache: Cache<T>): T {
116+
const fn = COMPUTE.get(cache);
117+
118+
if (!fn) {
119+
throw new Error('You must only call `getCache` with the return value of `createCache`');
120+
}
121+
122+
return fn() as T;
123+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { type Tag, MutableTag, runtime } from './tags';
2+
3+
export class PrimitiveCell<T> {
4+
readonly #tag: MutableTag = MutableTag.init(this, 'value');
5+
#value: T;
6+
7+
/**
8+
* Unsafely read the value of the cell. This is unsafe because it exposes the raw value of the tag
9+
* and the last value of the cell, but relies on the caller to ensure that the tag is consumed if
10+
* the abstraction needs to invalidate when the cell changes.
11+
*
12+
* Callers of `unsafeSnapshot` must satisfy the transactionality law by consuming the tag whenever a
13+
* change to the value would result in a change to the computed value of the abstraction.
14+
*/
15+
unsafeSnapshot(): Snapshot<T> {
16+
return Snapshot.of({ value: this.#value, tag: this.#tag });
17+
}
18+
19+
write(value: T): void {
20+
this.#tag.update();
21+
this.#value = value;
22+
}
23+
}
24+
export type Status<T> = { value: T; tag: Tag };
25+
type Last<T> = { value: T; tag: Tag; revision: number };
26+
27+
export class Snapshot<T> {
28+
static of<T>(status: Status<T>): Snapshot<T> {
29+
return new Snapshot({ value: status.value, tag: status.tag });
30+
}
31+
readonly #value: T;
32+
readonly #tag: Tag;
33+
readonly #revision: number;
34+
35+
private constructor({ value, tag }: Status<T>) {
36+
this.#value = value;
37+
this.#tag = tag;
38+
this.#revision = tag.revision;
39+
}
40+
41+
get tag(): Tag {
42+
return this.#tag;
43+
}
44+
45+
get value(): T {
46+
return this.#value;
47+
}
48+
}
49+
50+
export class PrimitiveCache<T> {
51+
readonly #compute: () => T;
52+
#last: Last<T>;
53+
54+
constructor(compute: () => T) {
55+
this.#compute = compute;
56+
57+
// A `PrimitiveCache` must always be initialized with a value. If all of the primitives used
58+
// inside of a `PrimitiveCache` are compliant with the Fundamental Laws of Reactivity, then
59+
// initializing a cache will never change the revision counter.
60+
this.read();
61+
}
62+
63+
/**
64+
* Unsafely read the status of the cache. This is unsafe because it exposes the raw value of the
65+
* tag and the last value of the cache, but relies on the caller to ensure that the tag is
66+
* consumed if the abstraction needs to invalidate when the cache changes.
67+
*
68+
* Callers of `unsafeSnapshot` must satisfy the transactionality law by consuming the tag whenever a
69+
* change to the value would result in a change to the computed value of the abstraction.
70+
*/
71+
snapshot(): Snapshot<T> {
72+
return Snapshot.of(this.#last);
73+
}
74+
75+
/**
76+
* Safely read the value of the cache. This satisfies the transactionality law because:
77+
*
78+
* 1. If the cache is valid, then it will return the last value of the cache. This is guaranteed
79+
* to be the same value for all reads in the same rendering transaction because any mutations
80+
* to any _members_ of the last tag will trigger a backtracking assertion.
81+
* 2. If the cache is invalid, then the previous value of the cache is thrown away and the
82+
* computation is run again. Any subsequent reads from the cache will return the same value
83+
* because of (1).
84+
*/
85+
read(): T {
86+
if (this.#last && this.#last.revision >= this.#last.tag.revision) {
87+
runtime.consume(this.#last.tag);
88+
return this.#last.value;
89+
}
90+
91+
runtime.begin();
92+
try {
93+
const result = this.#compute();
94+
const tag = runtime.commit();
95+
this.#last = { value: result, tag, revision: runtime.current() };
96+
runtime.consume(tag);
97+
return result;
98+
} catch (e) {
99+
// This is possible, but not currently modelled at all. The approach used by the error
100+
// recovery branch that was not merged is: tags are permitted to capture errors, and
101+
// value abstractions expose those errors in their safe read() abstractions.
102+
throw e;
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)