Skip to content

Commit ee383b0

Browse files
authored
Merge pull request #605 from reduxjs/feature/5.0-experimental-memoizers
2 parents 64564d8 + 41959cd commit ee383b0

18 files changed

+2302
-906
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"format": "prettier --write \"{src,test}/**/*.{js,ts}\" \"docs/**/*.md\"",
3535
"lint": "eslint src test",
3636
"prepack": "yarn build",
37-
"test": "vitest run",
37+
"test": "node --expose-gc ./node_modules/vitest/dist/cli-wrapper.js run",
3838
"test:cov": "vitest run --coverage",
3939
"test:typescript": "tsc --noEmit -p typescript_test/tsconfig.json"
4040
},
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createNode, updateNode } from './proxy'
2+
import { Node } from './tracking'
3+
4+
import { createCache } from './autotracking'
5+
import {
6+
createCacheKeyComparator,
7+
defaultEqualityCheck
8+
} from '@internal/defaultMemoize'
9+
10+
export function autotrackMemoize<F extends (...args: any[]) => any>(func: F) {
11+
// we reference arguments instead of spreading them for performance reasons
12+
13+
const node: Node<Record<string, unknown>> = createNode(
14+
[] as unknown as Record<string, unknown>
15+
)
16+
17+
let lastArgs: IArguments | null = null
18+
19+
const shallowEqual = createCacheKeyComparator(defaultEqualityCheck)
20+
21+
const cache = createCache(() => {
22+
const res = func.apply(null, node.proxy as unknown as any[])
23+
return res
24+
})
25+
26+
function memoized() {
27+
if (!shallowEqual(lastArgs, arguments)) {
28+
updateNode(node, arguments as unknown as Record<string, unknown>)
29+
lastArgs = arguments
30+
}
31+
return cache.value
32+
}
33+
34+
memoized.clearCache = () => cache.clear()
35+
36+
return memoized as F & { clearCache: () => void }
37+
}

src/autotrackMemoize/autotracking.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Original autotracking implementation source:
2+
// - https://gist.github.com/pzuraq/79bf862e0f8cd9521b79c4b6eccdc4f9
3+
// Additional references:
4+
// - https://www.pzuraq.com/blog/how-autotracking-works
5+
// - https://v5.chriskrycho.com/journal/autotracking-elegant-dx-via-cutting-edge-cs/
6+
import { assert } from './utils'
7+
8+
// The global revision clock. Every time state changes, the clock increments.
9+
export let $REVISION = 0
10+
11+
// The current dependency tracker. Whenever we compute a cache, we create a Set
12+
// to track any dependencies that are used while computing. If no cache is
13+
// computing, then the tracker is null.
14+
let CURRENT_TRACKER: Set<Cell<any> | TrackingCache> | null = null
15+
16+
type EqualityFn = (a: any, b: any) => boolean
17+
18+
// Storage represents a root value in the system - the actual state of our app.
19+
export class Cell<T> {
20+
revision = $REVISION
21+
22+
_value: T
23+
_lastValue: T
24+
_isEqual: EqualityFn = tripleEq
25+
26+
constructor(initialValue: T, isEqual: EqualityFn = tripleEq) {
27+
this._value = this._lastValue = initialValue
28+
this._isEqual = isEqual
29+
}
30+
31+
// Whenever a storage value is read, it'll add itself to the current tracker if
32+
// one exists, entangling its state with that cache.
33+
get value() {
34+
CURRENT_TRACKER?.add(this)
35+
36+
return this._value
37+
}
38+
39+
// Whenever a storage value is updated, we bump the global revision clock,
40+
// assign the revision for this storage to the new value, _and_ we schedule a
41+
// rerender. This is important, and it's what makes autotracking _pull_
42+
// based. We don't actively tell the caches which depend on the storage that
43+
// anything has happened. Instead, we recompute the caches when needed.
44+
set value(newValue) {
45+
if (this.value === newValue) return
46+
47+
this._value = newValue
48+
this.revision = ++$REVISION
49+
}
50+
}
51+
52+
function tripleEq(a: unknown, b: unknown) {
53+
return a === b
54+
}
55+
56+
// Caches represent derived state in the system. They are ultimately functions
57+
// that are memoized based on what state they use to produce their output,
58+
// meaning they will only rerun IFF a storage value that could affect the output
59+
// has changed. Otherwise, they'll return the cached value.
60+
export class TrackingCache {
61+
_cachedValue: any
62+
_cachedRevision = -1
63+
_deps: any[] = []
64+
hits = 0
65+
66+
fn: () => any
67+
68+
constructor(fn: () => any) {
69+
this.fn = fn
70+
}
71+
72+
clear() {
73+
this._cachedValue = undefined
74+
this._cachedRevision = -1
75+
this._deps = []
76+
this.hits = 0
77+
}
78+
79+
get value() {
80+
// When getting the value for a Cache, first we check all the dependencies of
81+
// the cache to see what their current revision is. If the current revision is
82+
// greater than the cached revision, then something has changed.
83+
if (this.revision > this._cachedRevision) {
84+
const { fn } = this
85+
86+
// We create a new dependency tracker for this cache. As the cache runs
87+
// its function, any Storage or Cache instances which are used while
88+
// computing will be added to this tracker. In the end, it will be the
89+
// full list of dependencies that this Cache depends on.
90+
const currentTracker = new Set<Cell<any>>()
91+
const prevTracker = CURRENT_TRACKER
92+
93+
CURRENT_TRACKER = currentTracker
94+
95+
// try {
96+
this._cachedValue = fn()
97+
// } finally {
98+
CURRENT_TRACKER = prevTracker
99+
this.hits++
100+
this._deps = Array.from(currentTracker)
101+
102+
// Set the cached revision. This is the current clock count of all the
103+
// dependencies. If any dependency changes, this number will be less
104+
// than the new revision.
105+
this._cachedRevision = this.revision
106+
// }
107+
}
108+
109+
// If there is a current tracker, it means another Cache is computing and
110+
// using this one, so we add this one to the tracker.
111+
CURRENT_TRACKER?.add(this)
112+
113+
// Always return the cached value.
114+
return this._cachedValue
115+
}
116+
117+
get revision() {
118+
// The current revision is the max of all the dependencies' revisions.
119+
return Math.max(...this._deps.map(d => d.revision), 0)
120+
}
121+
}
122+
123+
export function getValue<T>(cell: Cell<T>): T {
124+
if (!(cell instanceof Cell)) {
125+
console.warn('Not a valid cell! ', cell)
126+
}
127+
128+
return cell.value
129+
}
130+
131+
type CellValue<T extends Cell<unknown>> = T extends Cell<infer U> ? U : never
132+
133+
export function setValue<T extends Cell<unknown>>(
134+
storage: T,
135+
value: CellValue<T>
136+
): void {
137+
assert(
138+
storage instanceof Cell,
139+
'setValue must be passed a tracked store created with `createStorage`.'
140+
)
141+
142+
storage.value = storage._lastValue = value
143+
}
144+
145+
export function createCell<T = unknown>(
146+
initialValue: T,
147+
isEqual: EqualityFn = tripleEq
148+
): Cell<T> {
149+
return new Cell(initialValue, isEqual)
150+
}
151+
152+
export function createCache<T = unknown>(fn: () => T): TrackingCache {
153+
assert(
154+
typeof fn === 'function',
155+
'the first parameter to `createCache` must be a function'
156+
)
157+
158+
return new TrackingCache(fn)
159+
}

0 commit comments

Comments
 (0)