Skip to content

[WIP] Begin moving towards invokable wire format #1690

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a6089ce
Begin moving towards invokable wire format
wycats Jan 22, 2025
3ac9228
Fixed all remaining semantic bugs
wycats Feb 22, 2025
44d5f1c
Refactored syntax validation tracking
wycats Feb 23, 2025
b37d152
Validation context done
wycats Feb 24, 2025
d229ff6
Validation context restructure
wycats Feb 25, 2025
98ea0e7
Start making things pass
wycats Feb 25, 2025
6427a1d
Clean up errors
wycats Feb 26, 2025
ae82fe3
More error message improvements
wycats Feb 26, 2025
77f78f3
snippet creator
wycats Feb 27, 2025
70985d4
Initial syntax-based DSL for test errors
wycats Feb 27, 2025
2ec0d5c
fix tests
wycats Feb 28, 2025
4ef4bc2
More tests pass
wycats Feb 28, 2025
b46473d
Forgiving parser
wycats Mar 1, 2025
6ec87b6
Fix more error messages
wycats Mar 4, 2025
f251793
More test fixes
wycats Mar 5, 2025
77386cb
Fixes named block errors
wycats Mar 5, 2025
90e5795
Fix semantic breakage
wycats Mar 6, 2025
939fbe7
Improved error messages and added validation util
wycats Mar 7, 2025
9ff7376
Refactor box drawing
wycats Mar 8, 2025
63e3037
More tests passing
wycats Mar 8, 2025
72157ea
Checkpoint
wycats Mar 9, 2025
7bf22e4
More fixed tests
wycats Mar 9, 2025
6bd9795
Updating named block tests
wycats Mar 11, 2025
798b96a
Most tests pass
wycats Mar 12, 2025
1fdfb64
Support multiple errors in tests
wycats Mar 13, 2025
07ddb66
Almost all tests pass
wycats Mar 14, 2025
7ed5ebc
Tests pass
wycats Mar 15, 2025
31a9b59
Add some reactivity concepts
wycats Mar 18, 2025
8f162b1
Reactivity concepts
wycats Mar 19, 2025
ccb6165
alint:fix
NullVoxPopuli Mar 25, 2025
f13db5f
Fix some types in wire-format
NullVoxPopuli Apr 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .prototools
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
node = "lts"
pnpm = "latest-10"
node = "22.14.0"
pnpm = "10.6.0"
24 changes: 12 additions & 12 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"[javascript][typescript]": {
"editor.codeActionsOnSave": {
"source.fixAll": "always",
"source.fixAll.eslint": "always",
"source.formatDocument": "always"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
Expand All @@ -30,30 +30,23 @@
"eslint.enable": true,
"eslint.lintTask.enable": true,
"eslint.onIgnoredFiles": "warn",
"eslint.options": {
"overrideConfigFile": "./eslint.config.js"
},
"eslint.problems.shortenToSingleLine": true,
"eslint.runtime": "node",
"eslint.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "json", "jsonc"],
"eslint.workingDirectories": [
{
"pattern": "."
"mode": "auto"
}
],
"explorer.excludeGitIgnore": true,
"files.exclude": {
"**/.DS_Store": true,
"**/.git": true,
"**/dist": true,
"ts-dist": true,
"**/node_modules": true,
"tracerbench-results": true
},
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true
},
"inline-bookmarks.expert.custom.styles": {
"active": {
"dark": {
Expand Down Expand Up @@ -118,7 +111,7 @@
"@bandaid(?!\\()"
]
},
"inline-bookmarks.view.showVisibleFilesOnly": true,
"inline-bookmarks.view.showVisibleFilesOnly": false,
"javascript.preferences.importModuleSpecifier": "project-relative",
"javascript.updateImportsOnFileMove.enabled": "always",
"rewrap.autoWrap.enabled": true,
Expand All @@ -137,5 +130,12 @@
"typescript.preferences.useAliasesForRenames": false,
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.reportStyleChecksAsWarnings": false
"typescript.reportStyleChecksAsWarnings": false,
"stylelint.packageManager": "pnpm",
"npm.packageManager": "pnpm",
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
"typescript.inlayHints.parameterNames.enabled": "literals",
"typescript.inlayHints.parameterTypes.enabled": true,
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
"typescript.inlayHints.variableTypes.enabled": true
}
2 changes: 1 addition & 1 deletion bin/opcodes.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"AppendSafeHTML",
"AppendDocumentFragment",
"AppendNode",
"AppendText",
"AppendValue",
"OpenElement",
"OpenDynamicElement",
"PushRemoteElement",
Expand Down
35 changes: 35 additions & 0 deletions guides/reactivity/index.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions guides/reactivity/laws.md
Original file line number Diff line number Diff line change
@@ -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 <a id="invalidate"></a> _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 <u>must</u> 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.
>
123 changes: 123 additions & 0 deletions guides/reactivity/pseudocode/composition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { PrimitiveCache, PrimitiveCell, type Status } from './primitives';
import { runtime, MutableTag, type Tag } from './tags';

export class LocalCopy<T> {
#upstream: PrimitiveCache<T>;
#local: PrimitiveCell<T>;

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<S extends [Status<unknown>, ...Status<unknown>[]]>(...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<V, This extends object>(
_value: ClassAccessorDecoratorTarget<This, V>,
context: ClassAccessorDecoratorContext<This, V>
): ClassAccessorDecoratorResult<This, V> {
// 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<Cache<unknown>, () => unknown>();

declare const FN: unique symbol;
type FN = typeof FN;
type Cache<T> = {
[FN]: () => T;
};

export function createCache<T>(fn: () => T): Cache<T> {
const cache = {} as Cache<T>;
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<T>(cache: Cache<T>): 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;
}
Loading
Loading