Last active
December 13, 2023 22:57
-
-
Save pzuraq/79bf862e0f8cd9521b79c4b6eccdc4f9 to your computer and use it in GitHub Desktop.
Autotracking Simplified
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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