Skip to content

Instantly share code, notes, and snippets.

@pzuraq
Last active December 13, 2023 22:57
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save pzuraq/79bf862e0f8cd9521b79c4b6eccdc4f9 to your computer and use it in GitHub Desktop.
Save pzuraq/79bf862e0f8cd9521b79c4b6eccdc4f9 to your computer and use it in GitHub Desktop.
Autotracking Simplified
// The global revision clock. Every time state changes, the clock increments.
let $REVISION = 0;
// The current dependency tracker. Whenever we compute a cache, we create a Set
// to track any dependencies that are used while computing. If no cache is
// computing, then the tracker is null.
let CURRENT_TRACKER = null;
// Storage represents a root value in the system - the actual state of our app.
class Storage {
revision = $REVISION;
#value;
constructor(initialValue) {
this.#value = initialValue;
}
// Whenever a storage value is read, it'll add itself to the current tracker if
// one exists, entangling its state with that cache.
get value() {
CURRENT_TRACKER?.add(this);
return this.#value;
}
// Whenever a storage value is updated, we bump the global revision clock,
// assign the revision for this storage to the new value, _and_ we schedule a
// rerender. This is important, and it's what makes autotracking _pull_
// based. We don't actively tell the caches which depend on the storage that
// anything has happened. Instead, we recompute the caches when needed.
set value(newValue) {
if (this.value === newValue) return;
this.#value = newValue;
this.revision = ++$REVISION;
scheduleRerender();
}
}
// Caches represent derived state in the system. They are ultimately functions
// that are memoized based on what state they use to produce their output,
// meaning they will only rerun IFF a storage value that could affect the output
// has changed. Otherwise, they'll return the cached value.
class Cache {
#cachedValue;
#cachedRevision = -1;
#deps = [];
constructor(fn) {
this.fn = fn;
}
get value() {
// When getting the value for a Cache, first we check all the dependencies of
// the cache to see what their current revision is. If the current revision is
// greater than the cached revision, then something has changed.
if (this.revision > this.#cachedRevision) {
let { fn } = this;
// We create a new dependency tracker for this cache. As the cache runs
// its function, any Storage or Cache instances which are used while
// computing will be added to this tracker. In the end, it will be the
// full list of dependencies that this Cache depends on.
let currentTracker = new Set();
let prevTracker = CURRENT_TRACKER;
CURRENT_TRACKER = currentTracker;
try {
this.#cachedValue = fn();
} finally {
CURRENT_TRACKER = prevTracker;
this.#deps = Array.from(currentTracker);
// Set the cached revision. This is the current clock count of all the
// dependencies. If any dependency changes, this number will be less
// than the new revision.
this.#cachedRevision = this.revision;
}
}
// If there is a current tracker, it means another Cache is computing and
// using this one, so we add this one to the tracker.
CURRENT_TRACKER?.add(this);
// Always return the cached value.
return this.#cachedValue;
}
get revision() {
// The current revision is the max of all the dependencies' revisions.
return Math.max(...this.#deps.map(d => d.revision), 0);
}
}
//////////
function scheduleRerender() {
// This would normally schedule a rerender for the next tick. This way,
// whenever a Storage is updated, we don't immediately incur any cost. If many
// Storage values are updated in a single action, it's effectively free.
}
// Test
const root1 = new Storage(1);
const root2 = new Storage(2);
const computed1 = new Cache(() => {
console.log('computed1 ran!');
return root1.value + root2.value;
});
const computed2 = new Cache(() => {
console.log('computed2 ran!')
return computed1.value + root2.value;
});
console.log(computed1.value); // 3, computed1 ran!
console.log(computed1.value); // 3
console.log(computed2.value); // 5, computed2 ran!
console.log(computed2.value); // 5
root1.value = 3;
console.log(computed2.value); // 7, computed1 ran!, computed2 ran!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment