Skip to content

Commit 91d5f62

Browse files
Merge pull request #1752 from glimmerjs/update-docs
Add reactivity docs from #1690
2 parents d862748 + 7c8fa20 commit 91d5f62

File tree

9 files changed

+889
-0
lines changed

9 files changed

+889
-0
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Autotracked Rendering
2+
3+
An explanation of the depths of the reactivity system we've been using and refining since Ember Octane (ember-source 3.13+).
4+
5+
### Walkthrough: setting a value
6+
7+
Given:
8+
```gjs
9+
import { tracked } from '@glimmer/tracking';
10+
11+
class ModuleState {
12+
@tracked count = 0;
13+
14+
increment() {
15+
this.count++;
16+
}
17+
}
18+
19+
const state = new ModuleState();
20+
21+
<template>
22+
<output>{{ state.count }}</output>
23+
<button {{on "click" state.increment}}>Increment</button>
24+
</template>
25+
```
26+
27+
And we
28+
1. observe a render,
29+
2. and then click the button,
30+
- and then observe the output count update.
31+
32+
How does it work?
33+
34+
There are a few systems at play for autotracking:
35+
- [tags][^vm-tags]
36+
- [global context][^ember-global-context]
37+
- the environment / delegate
38+
- some [glue code][^ember-renderer] that [configures][^ember-renderer-revalidate] the [timing specifics][^ember-renderer-render-transaction] of when to [render updates][^ember-renderer-render-roots]
39+
- the actual [call to the VM to render][^ember-root-state-render]
40+
41+
42+
[^vm-tags]: https://github.com/glimmerjs/glimmer-vm/blob/d86274816a21c61fbc82059006fe7687ca17dc7e/packages/%40glimmer/validator/lib/validators.ts#L1
43+
[^ember-global-context]: https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/glimmer/lib/environment.ts#L21
44+
[^ember-renderer]: https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/glimmer/lib/renderer.ts#L613C1-L614C1
45+
[^ember-renderer-revalidate]: https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/glimmer/lib/renderer.ts#L626
46+
[^ember-renderer-render-transaction]: https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/glimmer/lib/renderer.ts#L573
47+
[^ember-renderer-render-roots]: https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/glimmer/lib/renderer.ts#L524
48+
[^ember-root-state-render]: https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/glimmer/lib/renderer.ts#L156
49+
50+
#### 1. leading up to observing a render
51+
52+
- **render**
53+
- call `renderMain()` from glimmer-vm
54+
- this creates a [VM instance and a TemplateIterator](https://github.com/glimmerjs/glimmer-vm/blob/d86274816a21c61fbc82059006fe7687ca17dc7e/packages/%40glimmer/runtime/lib/render.ts#L59)
55+
- tell the [renderer to render](https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/glimmer/lib/renderer.ts#L165-L168)
56+
1. [executes the VM](https://github.com/glimmerjs/glimmer-vm/blob/d86274816a21c61fbc82059006fe7687ca17dc7e/packages/%40glimmer/runtime/lib/render.ts#L32)
57+
2. iterates over blocks / defers to [_execute](https://github.com/glimmerjs/glimmer-vm/blob/d86274816a21c61fbc82059006fe7687ca17dc7e/packages/%40glimmer/runtime/lib/vm/append.ts#L728)
58+
3. [evaluate opcodes](https://github.com/glimmerjs/glimmer-vm/blob/d86274816a21c61fbc82059006fe7687ca17dc7e/packages/%40glimmer/runtime/lib/vm/append.ts#L770)
59+
4. this brings us to the [low-level VM](https://github.com/glimmerjs/glimmer-vm/blob/main/packages/%40glimmer/runtime/lib/vm/low-level.ts#L167)
60+
5. the low-level VM is the actual VirtualMachine which inteprets all our opcodes -- it iterates until there are no more opcodes
61+
62+
- **read: count**
63+
- access `count`, which `@tracked`'s getter [defers to `trackedData`](https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/metal/lib/tracked.ts#L155C28-L155C39)
64+
- the [`trackedData`](https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/metal/lib/tracked.ts#L5) is in `@glimmer/validator` instead of using tags _directly_.
65+
- `trackedData` calls `consumeTag` when [the value is access](https://github.com/glimmerjs/glimmer-vm/blob/main/packages/%40glimmer/validator/lib/tracked-data.ts#L15)
66+
- `consumeTag` adds the tag to the [`CURRENT_TRACKER`](https://github.com/glimmerjs/glimmer-vm/blob/main/packages/%40glimmer/validator/lib/tracking.ts#L116)
67+
- this is so that when any `{{ }}` regions of a template "detect" a dirty tag, they can individually re-render
68+
69+
70+
- [valueForRef](https://github.com/glimmerjs/glimmer-vm/blob/main/packages/%40glimmer/reference/lib/reference.ts#L155)
71+
- called by _many_ opcode handlers in the VM, in this case: [this APPEND_OPCODE](https://github.com/glimmerjs/glimmer-vm/blob/main/packages/%40glimmer/runtime/lib/compiled/opcodes/content.ts#L88)
72+
- [track](https://github.com/glimmerjs/glimmer-vm/blob/main/packages/%40glimmer/validator/lib/tracking.ts#L232)
73+
- calls [beginTrackFrame](https://github.com/glimmerjs/glimmer-vm/blob/d86274816a21c61fbc82059006fe7687ca17dc7e/packages/%40glimmer/validator/lib/tracking.ts#L58) and the corresponding `endTrackFrame()`
74+
75+
- **render a button with modifier**
76+
- for demonstration purposes, this phase is skipped in this explanation, as this document is more about auto-tracking, and less so about how elements and event listeners get wired up
77+
78+
#### 2. click the button
79+
80+
- `increment()`
81+
- **read: count**
82+
- reading is part of `variable++` behavior
83+
- **set: count**
84+
- we dirty the tag [via `@tracked`'s setter](https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/metal/lib/tracked.ts#L171)
85+
86+
- `scheduleRevalidate()` is called by `dirtyTag()`, which then defers to ember to call these things and interacts with the scheduler (we go back to step 1):
87+
- **env.begin**
88+
- **env.rerender**
89+
- **read: count**
90+
- **env.commit**
91+
92+
the output, `count` is rendered as `1`
93+
94+
95+
### A minimal renderer
96+
97+
[JSBin, here](https://jsbin.com/mobupuh/edit?html,output)
98+
99+
> [!CAUTION]
100+
> This is heavy in boilerplate, and mostly private API. This 300 line *minimal* example, should be considered our todo list, as having all this required to render a tiny component is _too much_.
101+
102+

guides/reactivity/index.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
5. [Autotracked Rendering](./autotracked-rendering.md): An overview of the
16+
details of how rendering and autotracking interplay.
17+
18+
### Pseudocode
19+
20+
This directory also contains pseudocode for the foundation of a reactive system that satisfies these
21+
requirements, and uses them to demonstrate the implementation of the reactive abstractions.
22+
23+
- [`tags.ts`](./pseudocode/tags.ts): A simple implementation of the tag-based validation system,
24+
including an interface for a runtime that supports tag consumptions and tracking frames.
25+
- [`primitives.ts`](./pseudocode/primitives.ts): Implementation of:
26+
- `Snapshot`, which captures a value at a specific revision with its tag validator.
27+
- `PrimitiveCell` and `PrimitiveCache`, which implement a primitive root storage and a primitive
28+
cached computation, both of which support law-abiding snapshots.
29+
- [`composition.ts`](./pseudocode/composition.ts): Implementations of the higher-level reactive
30+
constructs described in [Reactive Abstractions](./reactive-abstractions.md) in terms of the
31+
reactive primitives.
32+
33+
> [!TIP]
34+
>
35+
> While these are significantly simplified versions of the production primitives that ship with
36+
> Ember and Glimmer, they serve as clear illustrations of how to implement reactive abstractions
37+
> 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+
}

0 commit comments

Comments
 (0)