diff --git a/guides/release/in-depth-topics/reactivity/index.md b/guides/release/in-depth-topics/reactivity/index.md new file mode 100644 index 0000000000..eb6f588b3e --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/index.md @@ -0,0 +1,35 @@ +# The Glimmer Reactivity System + +## Table of Contents + +1. [Tag Composition](./tag-composition.md): The formal composition semantics of Glimmer's tag-based + validation system. +2. [The Fundamental Laws of Reactivity](./laws.md): A definition of Glimmer's reliable and + consistent reactive programming model, and the rules that reactive abstractions must + satisfy in order to safely support this model. +3. [System Phases](./system-phases.md): A description of the phases of the Glimmer execution model: + _action_, _render_, and _idle_, and how the exeuction model supported batched _UI_ updates while + maintaining a _coherent_ data model. +4. [Reactive Abstractions](./reactive-abstractions.md): A description of the implementation of + a number of reactive abstractions, and how they satisfy the laws of reactivity. + +### Pseudocode + +This directory also contains pseudocode for the foundation of a reactive system that satisfies these +requirements, and uses them to demonstrate the implementation of the reactive abstractions. + +- [`tags.ts`](./pseudocode/tags.ts): A simple implementation of the tag-based validation system, + including an interface for a runtime that supports tag consumptions and tracking frames. +- [`primitives.ts`](./pseudocode/primitives.ts): Implementation of: + - `Snapshot`, which captures a value at a specific revision with its tag validator. + - `PrimitiveCell` and `PrimitiveCache`, which implement a primitive root storage and a primitive + cached computation, both of which support law-abiding snapshots. +- [`composition.ts`](./pseudocode/composition.ts): Implementations of the higher-level reactive + constructs described in [Reactive Abstractions](./reactive-abstractions.md) in terms of the + reactive primitives. + +> [!TIP] +> +> While these are significantly simplified versions of the production primitives that ship with +> Ember and Glimmer, they serve as clear illustrations of how to implement reactive abstractions +> that satisfy the reactive laws. \ No newline at end of file diff --git a/guides/release/in-depth-topics/reactivity/laws.md b/guides/release/in-depth-topics/reactivity/laws.md new file mode 100644 index 0000000000..bc0f00ecdb --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/laws.md @@ -0,0 +1,81 @@ +# The Fundamental Laws of Reactivity + +## ♾ The Fundamental Axiom of Reactivity + +> ### "A reactive abstraction must provide both the current value and a means to detect invalidation without recomputation." + +From the perspective of a Glimmer user, this axiom enables writing reactive code using standard +JavaScript functions and getters that automatically reflect the current state of UI inputs. + +**Glimmer users write UI code as straightforward rendering functions**, yet the system behaves _as +if_ these functions re-execute completely whenever any reactive value changes. + +> [!IMPORTANT] +> +> When root state is mutated, all reactive abstractions reflect those changes immediately, even when +> implemented with caching. Glimmer's reactive values are _always coherent_ — changes are never +> batched in ways that would allow inconsistencies between computed values and their underlying root +> state. + +## Definitions + +- **Root Reactive State**: An atomic reactive value that can be updated directly. It is represented + by a single [value tag](./concepts.md#value-tag). You can create a single piece of root state + explicitly using the `cell` API, but containers from `tracked-builtins` and the storage created by + the `@tracked` decorator are also root reactive state. +- **Formula**: A reactive computation that depends on a number of reactive values. A formula's + revision is the most recent revision of any of the members used during the last computation (as a + [combined tag](./concepts.md#combined-tag)). A + formula will _always_ recompute its output if the revision of any of its members is advanced. +- **Snapshot**: A _snapshot_ of a reactive abstraction is its _current value_ at a specific + revision. The snapshot _invalidates_ when the abstraction's tag has a more + recent revision. _A reactive abstraction is said to _invalidate_ when any previous snapshots would + become invalid._ + +## The Fundamental Laws of Reactivity + +In order to satisfy the _Fundamental Axiom of Reactivity_, all reactive abstractions must adhere to these six laws: + +1. **Dependency Tracking**: A reactive abstraction **must** [invalidate](#invalidate) when any + reactive values used in its _last computation_ have changed. _The revision of the tag associated + with the reactive abstraction must advance to match the revision of its most recently + updated member._ + +2. **Value Coherence**: A reactive abstraction **must never** return a cached _value_ from a + revision older than its current revision. _After a root state update, any dependent reactive + abstractions must recompute their value when next snapshotted._ + +3. **Transactional Consistency**: During a single rendering transaction, a reactive abstraction + **must** return the same value and revision for all snapshots taken within that transaction. + +4. **Snapshot Immutability**: The act of snapshotting a reactive abstraction **must not** + advance the reactive timeline. _Recursive snapshotting (akin to functional composition) naturally + involves tag consumption, yet remains consistent with this requirement as immutability applies + recursively to each snapshot operation._ + +5. **Defined Granularity**: A reactive abstraction **must** define a contract specifying its + _invalidation granularity_, and **must not** invalidate more frequently than this contract + permits. When a reactive abstraction allows value mutations, it **must** specify its equivalence + comparison method. When a new value is equivalent to the previous value, the abstraction **must + not** invalidate. + +All reactive abstractions—including built-in mechanisms like `@tracked` and `createCache`, existing +libraries such as `tracked-toolbox` and `tracked-builtins`, and new primitives like `cell`—must +satisfy these six laws to maintain the Fundamental Axiom of Reactivity when these abstractions are +composed together. + +> [!TIP] +> +> In practice, the effectiveness of reactive composition is bounded by the **Defined Granularity** and **Specified Equivalence** of the underlying abstractions. +> +> 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. +> +> The laws do not mandate comparing every value in every _computation_, nor do they require a +> uniform approach to equivalence based solely on reference equality. Each abstraction defines its +> own appropriate granularity and equivalence parameters. +> +> For developers building reactive abstractions, carefully selecting granularity and equivalence +> specifications that align with user mental models is crucial—users will experience the system +> through these decisions, expecting UI updates that accurately reflect meaningful changes in their +> application state. +> diff --git a/guides/release/in-depth-topics/reactivity/pseudocode/composition.ts b/guides/release/in-depth-topics/reactivity/pseudocode/composition.ts new file mode 100644 index 0000000000..c667a4d405 --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/pseudocode/composition.ts @@ -0,0 +1,123 @@ +import { PrimitiveCache, PrimitiveCell, type Status } from './primitives'; +import { runtime, MutableTag, type Tag } from './tags'; + +export class LocalCopy { + #upstream: PrimitiveCache; + #local: PrimitiveCell; + + constructor(compute: () => T) { + this.#upstream = new PrimitiveCache(compute); + this.#local = new PrimitiveCell(); + } + + /** + * Safely return the value of the upstream computation or the local cell, whichever is more + * recent. This satisfies the laws of reactivity transitively through `mostRecent`. + */ + read(): T { + return mostRecent(this.#upstream.snapshot(), this.#local.unsafeSnapshot()).value; + } + + /** + * Safely write a value to the local cell during the "action" phase. + */ + write(value: T): void { + this.#local.write(value); + } +} + +/** + * Safely returns the most recent status from the given statuses. If there are multiple status with + * the same, latest revision, the first such status in the list will be returned. + * + * This satisfies the transactionality law because we consume all tags in all cases, which means + * that: + * + * > The value of the most recent status cannot change after the `MostRecent` was computed in the + * > same rendering transaction, because a change to any of the specified statuses would trigger a + * > backtracking assertion. + * + * The granularity of `mostRecent` is: the call to `mostRecent` will invalidate when the tags of any + * of the statuses passed to it invalidate. This is as granular as possible because a change to any + * of the tags would, by definition, make it the most recent. + */ +function mostRecent, ...Status[]]>(...statuses: S): S[number] { + const [first, ...rest] = statuses; + runtime.consume(first.tag); + + return rest.reduce((latest, status) => { + runtime.consume(latest.tag); + return status.tag.revision > latest.tag.revision ? status : latest; + }, first); +} + +export function tracked( + _value: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext +): ClassAccessorDecoratorResult { + // When the field is initialized, initialize a mutable tag to represent the root storage. + context.addInitializer(function (this: This) { + MutableTag.init(this, context.name); + }); + + return { + get(this: This): V { + // When the field is accessed, consume the tag to track the read, and return the underlying + // value stored in the field. + const tag = MutableTag.get(this, context.name); + tag.consume(); + return context.access.get(this); + }, + + set(this: This, value: V): void { + // When the field is written, update the tag to track the write, and update the underlying + // value stored in the field. + const tag = MutableTag.get(this, context.name); + context.access.set(this, value); + tag.update(); + }, + }; +} + +const COMPUTE = new WeakMap, () => unknown>(); + +declare const FN: unique symbol; +type FN = typeof FN; +type Cache = { + [FN]: () => T; +}; + +export function createCache(fn: () => T): Cache { + const cache = {} as Cache; + let last = undefined as { value: T; tag: Tag; revision: number } | undefined; + + COMPUTE.set(cache, () => { + if (last && last.revision >= last.tag.revision) { + runtime.consume(last.tag); + return last.value; + } + + runtime.begin(); + try { + const result = fn(); + const tag = runtime.commit(); + last = { value: result, tag, revision: runtime.current() }; + runtime.consume(tag); + return result; + } catch { + last = undefined; + } + }); + + return cache; +} + +export function getCache(cache: Cache): T { + const fn = COMPUTE.get(cache); + + if (!fn) { + throw new Error('You must only call `getCache` with the return value of `createCache`'); + } + + return fn() as T; +} diff --git a/guides/release/in-depth-topics/reactivity/pseudocode/primitives.ts b/guides/release/in-depth-topics/reactivity/pseudocode/primitives.ts new file mode 100644 index 0000000000..05c8856ec2 --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/pseudocode/primitives.ts @@ -0,0 +1,105 @@ +import { type Tag, MutableTag, runtime } from './tags'; + +export class PrimitiveCell { + readonly #tag: MutableTag = MutableTag.init(this, 'value'); + #value: T; + + /** + * Unsafely read the value of the cell. This is unsafe because it exposes the raw value of the tag + * and the last value of the cell, but relies on the caller to ensure that the tag is consumed if + * the abstraction needs to invalidate when the cell changes. + * + * Callers of `unsafeSnapshot` must satisfy the transactionality law by consuming the tag whenever a + * change to the value would result in a change to the computed value of the abstraction. + */ + unsafeSnapshot(): Snapshot { + return Snapshot.of({ value: this.#value, tag: this.#tag }); + } + + write(value: T): void { + this.#tag.update(); + this.#value = value; + } +} +export type Status = { value: T; tag: Tag }; +type Last = { value: T; tag: Tag; revision: number }; + +export class Snapshot { + static of(status: Status): Snapshot { + return new Snapshot({ value: status.value, tag: status.tag }); + } + readonly #value: T; + readonly #tag: Tag; + readonly #revision: number; + + private constructor({ value, tag }: Status) { + this.#value = value; + this.#tag = tag; + this.#revision = tag.revision; + } + + get tag(): Tag { + return this.#tag; + } + + get value(): T { + return this.#value; + } +} + +export class PrimitiveCache { + readonly #compute: () => T; + #last: Last; + + constructor(compute: () => T) { + this.#compute = compute; + + // A `PrimitiveCache` must always be initialized with a value. If all of the primitives used + // inside of a `PrimitiveCache` are compliant with the Fundamental Laws of Reactivity, then + // initializing a cache will never change the revision counter. + this.read(); + } + + /** + * Unsafely read the status of the cache. This is unsafe because it exposes the raw value of the + * tag and the last value of the cache, but relies on the caller to ensure that the tag is + * consumed if the abstraction needs to invalidate when the cache changes. + * + * Callers of `unsafeSnapshot` must satisfy the transactionality law by consuming the tag whenever a + * change to the value would result in a change to the computed value of the abstraction. + */ + snapshot(): Snapshot { + return Snapshot.of(this.#last); + } + + /** + * Safely read the value of the cache. This satisfies the transactionality law because: + * + * 1. If the cache is valid, then it will return the last value of the cache. This is guaranteed + * to be the same value for all reads in the same rendering transaction because any mutations + * to any _members_ of the last tag will trigger a backtracking assertion. + * 2. If the cache is invalid, then the previous value of the cache is thrown away and the + * computation is run again. Any subsequent reads from the cache will return the same value + * because of (1). + */ + read(): T { + if (this.#last && this.#last.revision >= this.#last.tag.revision) { + runtime.consume(this.#last.tag); + return this.#last.value; + } + + runtime.begin(); + try { + const result = this.#compute(); + const tag = runtime.commit(); + this.#last = { value: result, tag, revision: runtime.current() }; + runtime.consume(tag); + return result; + } catch (e) { + // This is possible, but not currently modelled at all. The approach used by the error + // recovery branch that was not merged is: tags are permitted to capture errors, and + // value abstractions expose those errors in their safe read() abstractions. + throw e; + } + } +} diff --git a/guides/release/in-depth-topics/reactivity/pseudocode/tags.ts b/guides/release/in-depth-topics/reactivity/pseudocode/tags.ts new file mode 100644 index 0000000000..a6a2300522 --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/pseudocode/tags.ts @@ -0,0 +1,81 @@ +const TAGS = new WeakMap>(); + +export interface Tag { + get revision(): number; +} + +interface Runtime { + /** + * Consumes a tag in the current tracking frame, if one is active. + */ + consume(tag: Tag): void; + + /** + * Returns the timeline's current revision. + */ + current(): number; + + /** + * Advances the revision counter, returning the new revision. + */ + advance(): number; + + /** + * Begins a new tracking frame. All `consume` operations that happen after this will be + * associated with the new frame. + */ + begin(): void; + + /** + * Ends the current tracking frame, returning a tag that contains all of the members that were + * consumed in the duration of the frame. If a previous frame exists, it will become the current + * frame, and it will consume the returned tag. + */ + commit(): Tag; +} + +export declare const runtime: Runtime; + +export class MutableTag implements Tag { + static init(obj: object, key: PropertyKey): MutableTag { + let tags = TAGS.get(obj); + if (!tags) { + tags = {}; + TAGS.set(obj, tags); + } + + const tag = new MutableTag(); + tags[key] = tag; + return tag; + } + + static get(obj: object, key: PropertyKey): MutableTag { + const tag = TAGS.get(obj)?.[key]; + assert(tag, `No tag found for object ${obj}@${String(key)}`); + return tag; + } + + #revision = runtime.current(); + + get revision(): number { + return this.#revision; + } + + consume(this: MutableTag): void { + runtime.consume(this); + } + + update(this: MutableTag): void { + this.#revision = runtime.advance(); + } +} + +/** + * Asserts an invariant. This function represents code that would be removed in user-facing code, + * because the mechanics of the implementation should enforce the invariant. + */ +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} diff --git a/guides/release/in-depth-topics/reactivity/reactive-abstractions.md b/guides/release/in-depth-topics/reactivity/reactive-abstractions.md new file mode 100644 index 0000000000..02bf423837 --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/reactive-abstractions.md @@ -0,0 +1,195 @@ +### `@tracked` + +The `@tracked` accessor creates a single reactive value that that is accessed and mutated through a +JavaScript property. It satisfies the reactivity laws: + +1. **Dependency Tracking**: This requirement is satisfied for root state as long as reads + of the root state consume the same tag that is updated when the root state is changed. It is + trivial to verify that the implementation of `@tracked` satisfies these requirements. + +2. **Value Coherence**: This requirement is also satisfied for root state as long as writes to the + root storage always write to the same JavaScript storage location that is accessed when the root + state is accessed. It is trivial to verify that the implementation of `@tracked` satisfies this + requirement. + +3. **Transactional Consistency**: This requirement is trivially satisfied for root state by Ember's + "backtracking rerender assertion." This assertion ensures that once a reactive value has been + _read_ during a rendering transaction, it cannot be updated again during the same transaction. + +4. **Snapshot Immutability**: In this case, the property is snapshotted when the property is read + and when the tag is consumed by the current tracking frame. It is trivial to verify that the + `get` operation does not advance the timeline. + +5. **Defined Granularity**: The `@tracked` accessor is specified to invalidate whenever the property + is set, regardless of previous or subsequent value. The new value is _never_ considered + equivalent to the previous value. + +
+Pseudo-Implementation of @tracked + +```ts +export function tracked(_value, context) { + context.addInitializer(function () { + ValueTag.init(this, context.name); + }); + + return { + get() { + const tag = ValueTag.get(this, context.name); + tag.consume(); + return context.access.get(this); + }, + + set(value) { + const tag = ValueTag.get(this, context.name); + context.access.set(this, value); + tag.update(); + }, + }; +} +``` + +
+ +### `Cell` + +The new `cell` API satisfies the reactivity laws: + +1. **Dependency Tracking**: This requirement is satisfied for root state as long as reads + of the root state consume the same tag that is updated when the root state is changed. It is + trivial to verify that the implementation of `cell` satisfies these requirements. + +2. **Value Coherence**: This requirement is also satisfied for root state as long as writes to the + root storage always write to the same JavaScript storage location that is accessed when the root + state is accessed. It is trivial to verify that the implementation of `cell` satisfies this + requirement. + +3. **Transactional Consistency**: This requirement is trivially satisfied for root state by Ember's + "backtracking rerender assertion." This assertion ensures that once a reactive value has been + _read_ during a rendering transaction, it cannot be updated again during the same transaction. + +4. **Snapshot Immutability**: In this case, the property is snapshotted when the property is read + and when the tag is consumed by the current tracking frame. It is trivial to verify that the + `get` operation does not advance the timeline. + +5. **Defined Granularity**: When the cell is set, the new value is compared to the previous value for + equivalence using the specified `equals` function. When the new value is equivalent to the + previous value, the cell's tag will _not_ invalidate. + +
+Pseudo-Implementation of the cell constructor + +```ts +export function cell(value, { equals = Object.is } = {}) { + const tag = ValueTag.init(this, 'value'); + + return { + get() { + tag.consume(); + return value; + }, + + set(newValue) { + if (!equals(value, newValue)) { + value = newValue; + tag.update(); + } + }, + }; +} +``` + +
+ +### The `createCache` Primitive API + +The `createCache` primitive API satisfies the reactivity laws: + +1. **Dependency Tracking**: The cache's computation uses `begin()` and `commit()` to automatically + track the reactive values used in the computation. Since the tag returned by `commit` produces + the maximum revision of its members, the cache will invalidate whenever any of the reactive values + used in the computation have changed. + +2. **Value Coherence**: This requirement is satisfied by the cache's implementation, which only + returns a previously cached value if its tag is still valid. When the tag is invalidated, the + cache recomputes its value before returning it, ensuring it never returns a stale value. + +3. **Transactional Consistency**: This requirement is satisfied by consuming the combined tag + during every read, regardless of whether the cache was valid or invalid. Since Ember's backtracking + rerender assertion fires whenever a tag that was previously consumed is updated during the same + rendering transaction, this requirement is enforced. + +4. **Snapshot Immutability**: In this case, snapshotting occurs when `getCache` is called. The + implementation only consumes tags during this operation and doesn't update any tags, ensuring + that reading a cache doesn't advance the timeline. This property holds recursively for the + entire computation, as each reactive value accessed during execution must also + satisfy the same immutability requirement. + +5. **Defined Granularity**: The granularity of the `createCache` API is defined transitively - + the cache invalidates whenever any of its dependencies invalidate, according to their own + granularity rules. The cache itself does not add any additional equivalence checks. + +
+Pseudo-Implementation of createCache + +```ts +const COMPUTE = new WeakMap(); + +export function createCache(fn) { + const cache = {}; + let last = undefined; + + COMPUTE.set(cache, () => { + if (last && last.revision >= last.tag.revision) { + runtime.consume(last.tag); + return last.value; + } + + runtime.begin(); + try { + const result = fn(); + const tag = runtime.commit(); + runtime.consume(tag); + last = { value: result, tag, revision: runtime.current() }; + return result; + } catch { + last = undefined; + } + }); + + return cache; +} + +export function getCache(cache) { + const fn = COMPUTE.get(cache); + + if (!fn) { + throw new Error('You must only call `getCache` with the return value of `createCache`'); + } + + return fn(); +} +``` + +
+ +### The `LocalCopy` Primitive + +> [!NOTE] +> +> This section will be written next. The crux of the matter is that `LocalCopy` satisfies the +> reactivity laws because: +> +> 1. Snapshotting a `LocalCopy` deterministically returns the value that corresponds to the latest +> the upstream computation or the local cell, whichever is more recent. +> 2. Since the backtracking rerender assertion prevents any tag from being updated once it's +> consumed, it is impossible for the choice of upstream computation or local cell to change in +> the same rendering transaction. +> 3. What's weird about `LocalCopy` is that its value is determined in part by the _revision_ of the +> members of the composition, whereas most compositions are determined entirely by the _values_ +> of the members. +> 4. By being explicit about reactivity semantics and the reactivity laws, we can see that +> `LocalCopy` is a safe abstraction despite having a dependency on the revision of the members. + +An implementation of `LocalCopy` exists in [composition.ts](./pseudocode/composition.ts) with +comments that explain how the implementation satisfies the reactivity laws. \ No newline at end of file diff --git a/guides/release/in-depth-topics/reactivity/system-phases.md b/guides/release/in-depth-topics/reactivity/system-phases.md new file mode 100644 index 0000000000..345c8bdad9 --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/system-phases.md @@ -0,0 +1,118 @@ +# Reactive Values + +To allow developers to reason about reactive composition, developers typically interact with +[_tags_](./concepts.md#tags) through higher-level abstractions that represent _reactive values_. + +While reactive tags describe the formal _composition_ semantics of the Glimmer system, "Reactive +Values" describe the rules that higher-level abstractions must follow in order to allow developers +to safely reason about reactive composition. + +## System Phases and the Fundamental Laws + +Glimmer's reactivity system combines three key elements that work together to provide a coherent reactive programming model: + +1. **A Tag-Based Validation System**: As described in [tag-composition.md](./tag-composition.md), Glimmer uses tags to track and validate dependencies without requiring value recomputation. + +2. **The Fundamental Laws of Reactivity**: The [laws](./laws.md) define the contracts that all reactive abstractions must adhere to, ensuring predictable behavior when abstractions are composed. + +3. **System Phases**: The system operates in distinct phases that separate mutation from validation, enabling efficient tracking while preventing inconsistencies. + +These elements combine to create a system where developers can write straightforward JavaScript code while the framework maintains reactivity behind the scenes. The phases of the system, in particular, provide the framework for implementing the laws in practice. + +## System Phases: A Visual Overview + + +```mermaid +--- +config: + theme: base + themeVariables: + primaryColor: "#b9cccc" + secondaryColor: "#ffffff" + tertiaryColor: "#ff0000" + background: "#f9ffff" + lineColor: "#005f5f" + mainBkg: "#9fe9e9" + textColor: "#005f5f" +--- +stateDiagram + direction LR + state ReactiveLoop { + direction LR + Render --> Idle + Idle --> Action + Action --> Render + Render + Idle + Action + } + [*] --> Setup:App Boot + Setup --> Render:Initial Render + ReactiveLoop --> [*]:App cleanup + ReactiveLoop:Reactive Loop + Setup:Setup state + classDef transition fill:red +``` + + +- [Action Phase](#action-phase) +- [Render Phase](#render-phase) +- [Idle Phase](#idle-phase) + +### Action Phase + +The action phase allows app code to update reactive state in response to a system event (such as a +promise resolution) or a user event (such as an input event or button click). + +During this phase, app code is permitted to freely read and write reactive values as if they were +normal JavaScript values. This prepares the reactive state for the next phase, the render phase. + +- Root state can be updated freely, and each update advances the timeline. +- Reactive values can be accessed freely in order to decide how to update the root state. +- Reactive state is always fully coherent: reading from a reactive computation after updating one if + its dependencies will always return a value that is consistent with the current values of its + dependencies. + +### Render Phase + +The render phase updates the UI to reflect any changes to the reactive state. + +This phase is **transactional**, which means that reactive state is _observably frozen_ for the +duration of the render phase. Once a reactive value is accessed during a render transaction, any +subsequent accesses will produce the same value at the same revision. + +To satisfy the requirement that reactive state is _observably frozen_ during a single render phase: + +- Ember's _backtracking rerender assertion_ throws a development-mode exception if a tag that was + consumed during the render transaction is updated again during the transaction. _While this is not + enforced in production, this is a performance optimization, and the system assumes that this + prohibition is upheld._ +- Safe reactive abstractions **must** obey the **Snapshot Immutability** law, which forbids them + from updating any tags during the snapshotting operation. + +### Idle Phase + +The idle phase represents a quiescent state where the UI is fully rendered and consistent with the current reactive state. During this phase: + +- The system is waiting for the next event that will trigger state changes +- No tracking frames are active +- No updates to the reactive timeline occur + +The idle phase ends when an event occurs (user interaction, network response, timer, etc.) and the +event handler updates one or more reactive values (via the `[Update]` operation on a tag). This +transition the system into the **action phase**. + +The system also **schedules the render phase** to run asynchronously after the current JavaScript job +completes (consistent with JavaScript's run-to-completion semantics). + +### Batched Updates with a Coherent Data Model + +As a consequence of this design, all reactive updates that occur within the same JavaScript execution context are naturally batched together and processed in a single upcoming render phase. + +It's important to understand that while updates to the _rendered output_ (UI) are batched, the reactive values _inside_ the system maintain full coherence at all times: + +- There is no delay between updating root state and seeing that update reflected in a computation that depends on it +- When a computation is accessed after its dependencies have changed, it will immediately recompute based on the current values +- This internal coherence ensures that JavaScript code always sees a consistent reactive state, even though UI updates are batched + +This automatic batching ensures optimal performance by preventing cascading rerenders while allowing developers to work with reactive values as if they were normal JavaScript values. From the developer's perspective, reactive computations behave just like normal functions, giving them a familiar programming model with the added benefit of automatic UI updates. diff --git a/guides/release/in-depth-topics/reactivity/tag-composition.md b/guides/release/in-depth-topics/reactivity/tag-composition.md new file mode 100644 index 0000000000..db6b52c55b --- /dev/null +++ b/guides/release/in-depth-topics/reactivity/tag-composition.md @@ -0,0 +1,47 @@ +# Tag Composition + +Glimmer's reactivity system is founded on a minimal algebraic primitive called _Tag_. Tags operate on a _monotonic revision timeline_ and compose within _tracking frames_. + +Tags intentionally exist as a separate layer from the [reactive values](./reactive-values.md) they represent, creating a clean separation between validation algebra and value semantics. + +> [!NOTE] +> +> This separation distinguishes Glimmer from most reactive systems: Glimmer can validate reactive computations without recomputing values or performing equality comparisons. While this approach doesn't provide reference-equality-based cutoffs for invalidation propagation, it enables reliable fine-grained validation and controlled root-state invalidation through equivalence rules. At scale, this architecture yields significant benefits in predictability and performance. + +## Core Algebraic Primitives + +- **Revision Timeline**: A monotonically increasing sequence where each increment represents an atomic change. The timeline advances in a discrete and monotonic manner, with each new revision strictly greater than all previous revisions. + +- **Tag**: A stateful object that represents a timestamp (revision) on the timeline when its + associated value or computation last changed. _Tags can be retained and later used to determine if + a previously snapshotted value is still valid, without recomputing that value._ + + All tags support the `[Consume]` operation, which records the tag in the current tracking frame when accessed. + +- **Value Tag**: The base tag type that tracks when a single value changes. + +- **Mutable Tag**: A tag that can be explicitly updated with: + - `[Update]`: Advances the timeline and records the new revision as the tag's current revision. + - `[Freeze]`: Marks a tag as immutable, preventing its accesses from being recorded in tracking frames. + +- **Combined Tag**: A tag that represents the join (maximum) of multiple tag revisions. This join operation maintains the algebraic property that if any constituent tag invalidates, the combined tag also invalidates. + +- **Tracking Frame**: A collector that accumulates tags consumed during a computation. The frame has + two operations: + - `[Begin]`: Creates a bounded context for tag collection. + - `[Commit]`: Closes the collection scope and produces a combined tag from the collected tags. + +- **Tracking Stack**: A nested structure of tracking frames representing the current computation hierarchy. Enables compositional reactive tracking across function boundaries. + +## Revision Timeline + +The revision timeline forms the foundation of Glimmer's validation algebra. It consists of: + +- **Initial Revision**: Timeline begins at revision `1`. +- **Constant Revision**: Special revision `0` indicates values that never change. Tags with this revision are not tracked. +- **Timeline Advancement**: Each atomic update advances the global timeline exactly once. +- **Revision Comparison**: Two tags can be compared by examining their respective revisions. + +> [!NOTE] +> +> Revision `0` (constant revision) receives special treatment in the algebra. Tags with revision `0` do not participate in tracking and a frame containing only constant tags is itself considered constant.