From 31f98b7617ff62166ab54f8f0f80d529d417dc30 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 9 Apr 2023 21:59:15 -0400 Subject: [PATCH 1/9] Always use `defaultMemoize` on extracted args --- src/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index a231decc9..5f6655382 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,7 +118,15 @@ export function createSelectorCreator< ) // If a selector is called with the exact same arguments we don't need to traverse our dependencies again. - const selector = memoize(function dependenciesChecker() { + // TODO This was changed to `memoize` in 4.0.0 ( #297 ), but I changed it back. + // The original intent was to allow customizing things like skortchmark's + // selector debugging setup. + // But, there's multiple issues: + // - We don't pass in `memoizeOptions` + // Arguments change all the time, but input values change less often. + // Most of the time shallow equality _is_ what we really want here. + // TODO Rethink this change, or find a way to expose more options? + const selector = defaultMemoize(function dependenciesChecker() { const params = [] const length = dependencies.length From 97e84882563421397211db96d438a7597bdb9393 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 11 Apr 2023 23:11:09 -0400 Subject: [PATCH 2/9] Add experimental new memoizers: autotrack and weakmap --- src/autotrackMemoize/autotrackMemoize.ts | 56 +++ src/autotrackMemoize/autotracking.ts | 167 ++++++++ src/autotrackMemoize/proxy.ts | 376 ++++++++++++++++++ src/autotrackMemoize/tracking.ts | 62 +++ src/autotrackMemoize/utils.ts | 19 + src/index.ts | 5 +- src/weakMapMemoize.ts | 187 +++++++++ test/test_selector.ts | 467 ++++++++++++++++++++++- 8 files changed, 1334 insertions(+), 5 deletions(-) create mode 100644 src/autotrackMemoize/autotrackMemoize.ts create mode 100644 src/autotrackMemoize/autotracking.ts create mode 100644 src/autotrackMemoize/proxy.ts create mode 100644 src/autotrackMemoize/tracking.ts create mode 100644 src/autotrackMemoize/utils.ts create mode 100644 src/weakMapMemoize.ts diff --git a/src/autotrackMemoize/autotrackMemoize.ts b/src/autotrackMemoize/autotrackMemoize.ts new file mode 100644 index 000000000..c7cae55cf --- /dev/null +++ b/src/autotrackMemoize/autotrackMemoize.ts @@ -0,0 +1,56 @@ +import { createNode, updateNode } from './proxy' +import { Node } from './tracking' + +import { createCache } from './autotracking' +import { + createCacheKeyComparator, + defaultEqualityCheck +} from '@internal/defaultMemoize' + +export function autotrackMemoize any>(func: F) { + // we reference arguments instead of spreading them for performance reasons + + // console.log('Creating autotrack memoizer node') + const node: Node> = createNode( + [] as unknown as Record + ) + + let lastArgs: IArguments | null = null + + const shallowEqual = createCacheKeyComparator(defaultEqualityCheck) + + // console.log('Creating cache') + const cache = createCache(() => { + // console.log('Executing cache: ', node.value) + const res = func.apply(null, node.proxy as unknown as any[]) + // console.log('Res: ', res) + return res + }) + + // console.log('Creating memoized function') + function memoized() { + // console.log('Memoized running') + if (!shallowEqual(lastArgs, arguments)) { + // console.log( + // 'Args are different: lastArgs =', + // lastArgs, + // 'newArgs =', + // arguments + // ) + updateNode(node, arguments as unknown as Record) + lastArgs = arguments + } else { + // console.log('Same args: ', lastArgs, arguments) + } + // const start = performance.now() + // console.log('Calling memoized: ', arguments) + + // const end = performance.now() + // console.log('Memoized execution time: ', end - start) + return cache.value + } + + memoized.clearCache = () => cache.clear() + + return memoized as F & { clearCache: () => void } +} diff --git a/src/autotrackMemoize/autotracking.ts b/src/autotrackMemoize/autotracking.ts new file mode 100644 index 000000000..5d19b8185 --- /dev/null +++ b/src/autotrackMemoize/autotracking.ts @@ -0,0 +1,167 @@ +import { assert } from './utils' + +// The global revision clock. Every time state changes, the clock increments. +export 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: Set | TrackingCache> | null = null + +type EqualityFn = (a: any, b: any) => boolean + +// Storage represents a root value in the system - the actual state of our app. +export class Cell { + revision = $REVISION + + _value: T + _lastValue: T + _isEqual: EqualityFn = tripleEq + + constructor(initialValue: T, isEqual: EqualityFn = tripleEq) { + // console.log('Constructing cell: ', initialValue) + this._value = this._lastValue = initialValue + this._isEqual = isEqual + } + + // 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() { + // console.log('Getting cell value: ', this._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) { + // console.log('Setting value: ', this.value, newValue) + // if (this.value === newValue) return + + this._value = newValue + this.revision = ++$REVISION + // scheduleRerender() + } +} + +function tripleEq(a: unknown, b: unknown) { + return a === b +} + +// 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. +export class TrackingCache { + _cachedValue: any + _cachedRevision = -1 + _deps: any[] = [] + hits = 0 + + fn: () => any + + constructor(fn: () => any) { + this.fn = fn + } + + clear() { + this._cachedValue = undefined + this._cachedRevision = -1 + this._deps = [] + this.hits = 0 + } + + 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) { + const { 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. + const currentTracker = new Set>() + const prevTracker = CURRENT_TRACKER + + CURRENT_TRACKER = currentTracker + + // try { + this._cachedValue = fn() + // } finally { + CURRENT_TRACKER = prevTracker + this.hits++ + 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) + } +} + +export function getValue(cell: Cell): T { + if (!(cell instanceof Cell)) { + console.warn('Not a valid cell! ', cell) + } + + return cell.value +} + +type CellValue> = T extends Cell ? U : never + +export function setValue>( + storage: T, + value: CellValue +): void { + assert( + storage instanceof Cell, + 'setValue must be passed a tracked store created with `createStorage`.' + ) + + // console.log('setValue: ', storage, value) + + // console.log('Setting value: ', storage.value, value) + // storage.value = value + + const { _isEqual: isEqual, _lastValue: lastValue } = storage + + // if (!isEqual(value, lastValue)) { + storage.value = storage._lastValue = value + // } +} + +export function createCell( + initialValue: T, + isEqual: EqualityFn = tripleEq +): Cell { + return new Cell(initialValue, isEqual) +} + +export function createCache(fn: () => T): TrackingCache { + assert( + typeof fn === 'function', + 'the first parameter to `createCache` must be a function' + ) + + return new TrackingCache(fn) +} diff --git a/src/autotrackMemoize/proxy.ts b/src/autotrackMemoize/proxy.ts new file mode 100644 index 000000000..c4aed98f3 --- /dev/null +++ b/src/autotrackMemoize/proxy.ts @@ -0,0 +1,376 @@ +// import { DEBUG } from '@glimmer/env' + +// import { consumeTag, createTag, dirtyTag, Tag } from '@glimmer/validator' +// import { consumeTag, createTag, dirtyTag, Tag } from '../tracked-storage' +import { formatMs, logLater } from './utils' +import { + consumeCollection, + dirtyCollection, + Node, + Tag, + consumeTag, + dirtyTag, + createTag +} from './tracking' + +export const REDUX_PROXY_LABEL = Symbol() + +let nextId = 0 + +const proto = Object.getPrototypeOf({}) + +class ObjectTreeNode> implements Node { + proxy: T = new Proxy(this, objectProxyHandler) as unknown as T + tag = createTag() + tags = {} as Record + children = {} as Record + collectionTag = null + id = nextId++ + + constructor(public value: T) { + // console.log('Object node: ', this.value) + this.value = value + this.tag.value = value + } +} + +const objectProxyHandler = { + get(node: Node, key: string | symbol): unknown { + // if (DEBUG && key === REDUX_PROXY_LABEL) { + // // logLater('Bailing out of getter: ', key) + // return true + // } + // let res : unknown; + + const keyString = key.toString() + // if (keyString === 'constructor') { + // console.log('Constructor: ', node) + // } + const start = performance.now() + + function calculateResult() { + // try { + const { value } = node + + // console.time('Reflect.get: ' + keyString) + const childValue = Reflect.get(value, key) + // console.timeEnd('Reflect.get: ' + keyString) + + if (typeof key === 'symbol') { + return childValue + } + + if (key in proto) { + return childValue + } + + if (typeof childValue === 'object' && childValue !== null) { + // logLater('Getting child node: ', key, childValue) + let childNode = node.children[key] + + if (childNode === undefined) { + // console.time('Creating child node') + + // console.log('Creating node: ', key, childValue) + childNode = node.children[key] = createNode(childValue) + // console.timeEnd('Creating child node') + } + + if (childNode.tag) { + // logLater('Consuming tag: ', childNode) + // console.time('Consuming tag A: ' + keyString) + // console.log('Consuming tag: ', keyString) + consumeTag(childNode.tag) + // console.timeEnd('Consuming tag A: ' + keyString) + } + + return childNode.proxy + } else { + let tag = node.tags[key] + if (key === 'constructor') { + // console.log('Constructor tag: ', tag) + } + + if (tag === undefined) { + // console.time('Creating tag: ' + key) + // console.log('Creating tag: ', key) + tag = node.tags[key] = createTag() + // console.timeEnd('Creating tag: ' + key) + // console.time('Assigning tag value: ' + keyString) + tag.value = childValue + // console.timeEnd('Assigning tag value: ' + keyString) + } + + // console.time('Consuming tag B: ' + keyString) + // console.log('Consuming tag: ', keyString, tag) + consumeTag(tag) + + // console.timeEnd('Consuming tag B: ' + keyString) + + return childValue + } + } + const res = calculateResult() + + const end = performance.now() + // logLater(`Proxy get trap: ${keyString}: ${formatMs(end - start)}`) + return res + }, + + ownKeys(node: Node): ArrayLike { + consumeCollection(node) + return Reflect.ownKeys(node.value) + }, + + getOwnPropertyDescriptor( + node: Node, + prop: string | symbol + ): PropertyDescriptor | undefined { + console.log('getOwnPropertyDescriptor', prop) + return Reflect.getOwnPropertyDescriptor(node.value, prop) + }, + + has(node: Node, prop: string | symbol): boolean { + return Reflect.has(node.value, prop) + } +} + +class ArrayTreeNode> implements Node { + proxy: T = new Proxy([this], arrayProxyHandler) as unknown as T + tag = createTag() + tags = {} + children = {} + collectionTag = null + id = nextId++ + + constructor(public value: T) { + // console.log('Array node: ', value) + this.value = value + this.tag.value = value + } +} + +const arrayProxyHandler = { + get([node]: [Node], key: string | symbol): unknown { + if (key === 'length') { + consumeCollection(node) + } + + return objectProxyHandler.get(node, key) + }, + + ownKeys([node]: [Node]): ArrayLike { + return objectProxyHandler.ownKeys(node) + }, + + getOwnPropertyDescriptor( + [node]: [Node], + prop: string | symbol + ): PropertyDescriptor | undefined { + return objectProxyHandler.getOwnPropertyDescriptor(node, prop) + }, + + has([node]: [Node], prop: string | symbol): boolean { + return objectProxyHandler.has(node, prop) + } +} + +export function createNode | Record>( + value: T +): Node { + if (Array.isArray(value)) { + return new ArrayTreeNode(value) + } + + return new ObjectTreeNode(value) as Node +} + +const keysMap = new WeakMap< + Array | Record, + Set +>() + +export function updateNode | Record>( + node: Node, + newValue: T +): void { + // console.log('UpdateNode: ', newValue) + const { value, tags, children } = node + + node.value = newValue + + const start = performance.now() + + // console.time('updateNode: array check: ' + node.id) + if ( + Array.isArray(value) && + Array.isArray(newValue) && + value.length !== newValue.length + ) { + dirtyCollection(node) + } else { + if (value !== newValue) { + let oldKeysSize = 0 + let newKeysSize = 0 + let anyKeysAdded = false + + // console.log('Key check: ', value, newValue) + + for (const _key in value) { + oldKeysSize++ + } + + for (const key in newValue) { + newKeysSize++ + if (!(key in value)) { + anyKeysAdded = true + break + } + } + + // let oldKeys = keysMap.get(value) + // if (!oldKeys) { + // oldKeys = new Set() + // for (let key in value) { + // oldKeys.add(key) + // } + // keysMap.set(value, oldKeys) + // } + // oldKeyIteration = performance.now() + // let newKeys = keysMap.get(newValue) + // if (!newKeys) { + // newKeys = new Set() + // for (let key in newValue) { + // newKeys.add(key) + // } + // keysMap.set(newValue, newKeys) + // } + // newKeyIteration = performance.now() + // // const oldKeys = Object.keys(value) + // // const newKeys = Object.keys(newValue) + // const isDifferent = + // oldKeys.size !== newKeys.size || anyKeysDifferent(oldKeys, newKeys) + + const isDifferent = anyKeysAdded || oldKeysSize !== newKeysSize + + if ( + isDifferent + // [...oldKeys].some((k) => !newKeys!.has(k)) + ) { + // console.log('Dirtying collection: ', node) + dirtyCollection(node) + } + } + // console.time('Checking object keys') + // let oldKeys = keysMap.get(value) + // if (!oldKeys) { + // oldKeys = new Set() + // for (const key in value) { + // oldKeys.add(key) + // } + // keysMap.set(value, oldKeys) + // } + // let newKeys = keysMap.get(value) + // if (!newKeys) { + // newKeys = new Set() + // for (const key in newValue) { + // newKeys.add(key) + // } + // keysMap.set(newValue, newKeys) + // } + // // const oldKeys = Object.keys(value) + // // const newKeys = Object.keys(newValue) + + // if ( + // oldKeys.size !== newKeys.size || + // [...oldKeys].some(k => !newKeys!.has(k)) + // ) { + // dirtyCollection(node) + // } + // console.timeEnd('Checking object keys') + } + + const arrayDone = performance.now() + + // console.timeEnd('updateNode: array check: ' + node.id) + + // console.time('updateNode: tags check: ' + node.id) + + // console.log('Tags: ', tags) + for (const key in tags) { + // logLater('Tag key: ', key) + const childValue = (value as Record)[key] + const newChildValue = (newValue as Record)[key] + + if (childValue !== newChildValue) { + // console.log('Dirtying tag: ', { key, childValue, newChildValue }) + dirtyCollection(node) + dirtyTag(tags[key], newChildValue) + } + + if (typeof newChildValue === 'object' && newChildValue !== null) { + delete tags[key] + } + } + + const tagsDone = performance.now() + + // console.timeEnd('updateNode: tags check: ' + node.id) + + // console.time('updateNode: keys check: ' + node.id) + + for (const key in children) { + // logLater('Child key: ', key) + const childNode = children[key] + const newChildValue = (newValue as Record)[key] + + const childValue = childNode.value + + if (childValue === newChildValue) { + // logLater('Skipping child node: ', key, childValue, newChildValue) + continue + } else if ( + typeof newChildValue === 'object' && + newChildValue !== null // && + // Object.getPrototypeOf(newChildValue) === Object.getPrototypeOf(childValue) + ) { + // logLater('Updating child node: ', key, childValue, newChildValue) + // console.time('Nested updateNode: ' + key) + updateNode(childNode, newChildValue as Record) + // console.timeEnd('Nested updateNode: ' + key) + } else { + deleteNode(childNode) + delete children[key] + } + } + + const keysDone = performance.now() + + // logLater( + // 'updateNode: ', + // { + // total: formatMs(keysDone - start), + // array: formatMs(arrayDone - start), + // tags: formatMs(tagsDone - arrayDone), + // keys: formatMs(keysDone - tagsDone) + // }, + // node.value + // ) + + // console.timeEnd('updateNode: keys check: ' + node.id) +} + +function deleteNode(node: Node): void { + if (node.tag) { + dirtyTag(node.tag, null) + } + dirtyCollection(node) + for (const key in node.tags) { + dirtyTag(node.tags[key], null) + } + for (const key in node.children) { + deleteNode(node.children[key]) + } + // Object.values(node.tags).map(dirtyTag) + // Object.values(node.children).map(deleteNode) +} diff --git a/src/autotrackMemoize/tracking.ts b/src/autotrackMemoize/tracking.ts new file mode 100644 index 000000000..1d12ab817 --- /dev/null +++ b/src/autotrackMemoize/tracking.ts @@ -0,0 +1,62 @@ +// import { +// createStorage, +// getValue as consumeTag, +// setValue, +// } from '../tracked-storage' + +import { + createCell as createStorage, + getValue as consumeTag, + setValue, + Cell +} from './autotracking' + +// import { consumeTag, createTag, dirtyTag, Tag } from '@glimmer/validator' + +// export { consumeTag, createTag, dirtyTag, Tag } from '@glimmer/validator' + +export type Tag = Cell + +const neverEq = (a: any, b: any): boolean => false + +export function createTag(): Tag { + return createStorage(null, neverEq) +} +export { consumeTag } +export function dirtyTag(tag: Tag, value: any): void { + setValue(tag, value) +} + +//////////// + +export interface Node< + T extends Array | Record = + | Array + | Record +> { + collectionTag: Tag | null + tag: Tag | null + tags: Record + children: Record + proxy: T + value: T + id: number +} + +export const consumeCollection = (node: Node): void => { + let tag = node.collectionTag + + if (tag === null) { + tag = node.collectionTag = createTag() + } + + consumeTag(tag) +} + +export const dirtyCollection = (node: Node): void => { + const tag = node.collectionTag + + if (tag !== null) { + dirtyTag(tag, null) + } +} diff --git a/src/autotrackMemoize/utils.ts b/src/autotrackMemoize/utils.ts new file mode 100644 index 000000000..bdcd1c6db --- /dev/null +++ b/src/autotrackMemoize/utils.ts @@ -0,0 +1,19 @@ +export function assert( + condition: any, + msg = 'Assertion failed!' +): asserts condition { + if (!condition) { + console.error(msg) + throw new Error(msg) + } +} + +export function formatMs(n: number) { + return n.toFixed(4) + 'ms' +} + +export const loggedValues: any[] = [] + +export const logLater: typeof console.log = (...args: any[]) => { + loggedValues.push([new Date(), ...args]) +} diff --git a/src/index.ts b/src/index.ts index 5f6655382..66711e0f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,9 @@ import { DefaultMemoizeOptions } from './defaultMemoize' +export { autotrackMemoize } from './autotrackMemoize/autotrackMemoize' +export { weakMapMemoize } from './weakMapMemoize' + export { defaultMemoize, defaultEqualityCheck } export type { DefaultMemoizeOptions } @@ -200,7 +203,7 @@ export interface CreateSelectorFunction< ): OutputSelector< Selectors, Result, - ((...args: SelectorResultArray) => Result), + (...args: SelectorResultArray) => Result, GetParamsFromSelectors, Keys > & diff --git a/src/weakMapMemoize.ts b/src/weakMapMemoize.ts new file mode 100644 index 000000000..af71bc98a --- /dev/null +++ b/src/weakMapMemoize.ts @@ -0,0 +1,187 @@ +const UNTERMINATED = 0 +const TERMINATED = 1 +const ERRORED = 2 + +type UnterminatedCacheNode = { + s: 0 + v: void + o: null | WeakMap> + p: null | Map> +} + +type TerminatedCacheNode = { + s: 1 + v: T + o: null | WeakMap> + p: null | Map> +} + +type ErroredCacheNode = { + s: 2 + v: any + o: null | WeakMap> + p: null | Map> +} + +type CacheNode = + | TerminatedCacheNode + | UnterminatedCacheNode + | ErroredCacheNode + +function createCacheRoot(): WeakMap> { + return new WeakMap() +} + +function createCacheNode(): CacheNode { + return { + s: UNTERMINATED, // status, represents whether the cached computation returned a value or threw an error + v: undefined, // value, either the cached result or an error, depending on s + o: null, // object cache, a WeakMap where non-primitive arguments are stored + p: null // primitive cache, a regular Map where primitive arguments are stored. + } +} + +export function weakMapMemoize any>(func: F) { + // we reference arguments instead of spreading them for performance reasons + + let fnNode = createCacheNode() + + // console.log('Creating memoized function') + function memoized() { + let cacheNode = fnNode + + for (let i = 0, l = arguments.length; i < l; i++) { + const arg = arguments[i] + if ( + typeof arg === 'function' || + (typeof arg === 'object' && arg !== null) + ) { + // Objects go into a WeakMap + let objectCache = cacheNode.o + if (objectCache === null) { + cacheNode.o = objectCache = new WeakMap() + } + const objectNode = objectCache.get(arg) + if (objectNode === undefined) { + cacheNode = createCacheNode() + objectCache.set(arg, cacheNode) + } else { + cacheNode = objectNode + } + } else { + // Primitives go into a regular Map + let primitiveCache = cacheNode.p + if (primitiveCache === null) { + cacheNode.p = primitiveCache = new Map() + } + const primitiveNode = primitiveCache.get(arg) + if (primitiveNode === undefined) { + cacheNode = createCacheNode() + primitiveCache.set(arg, cacheNode) + } else { + cacheNode = primitiveNode + } + } + } + if (cacheNode.s === TERMINATED) { + return cacheNode.v + } + if (cacheNode.s === ERRORED) { + throw cacheNode.v + } + //try { + const result = func.apply(null, arguments as unknown as any[]) + const terminatedNode = cacheNode as unknown as TerminatedCacheNode + terminatedNode.s = TERMINATED + terminatedNode.v = result + return result + // } catch (error) { + // // We store the first error that's thrown and rethrow it. + // const erroredNode = cacheNode as unknown as ErroredCacheNode + // erroredNode.s = ERRORED + // erroredNode.v = error + // throw error + // } + } + + memoized.clearCache = () => { + fnNode = createCacheNode() + } + + return memoized as F & { clearCache: () => void } +} + +/* +function cache, T>(fn: (...A) => T): (...A) => T { + return function() { + const dispatcher = ReactCurrentCache.current; + if (!dispatcher) { + // If there is no dispatcher, then we treat this as not being cached. + // $FlowFixMe: We don't want to use rest arguments since we transpile the code. + return fn.apply(null, arguments); + } + const fnMap = dispatcher.getCacheForType(createCacheRoot); + const fnNode = fnMap.get(fn); + let cacheNode: CacheNode; + if (fnNode === undefined) { + cacheNode = createCacheNode(); + fnMap.set(fn, cacheNode); + } else { + cacheNode = fnNode; + } + for (let i = 0, l = arguments.length; i < l; i++) { + const arg = arguments[i]; + if ( + typeof arg === 'function' || + (typeof arg === 'object' && arg !== null) + ) { + // Objects go into a WeakMap + let objectCache = cacheNode.o; + if (objectCache === null) { + cacheNode.o = objectCache = new WeakMap(); + } + const objectNode = objectCache.get(arg); + if (objectNode === undefined) { + cacheNode = createCacheNode(); + objectCache.set(arg, cacheNode); + } else { + cacheNode = objectNode; + } + } else { + // Primitives go into a regular Map + let primitiveCache = cacheNode.p; + if (primitiveCache === null) { + cacheNode.p = primitiveCache = new Map(); + } + const primitiveNode = primitiveCache.get(arg); + if (primitiveNode === undefined) { + cacheNode = createCacheNode(); + primitiveCache.set(arg, cacheNode); + } else { + cacheNode = primitiveNode; + } + } + } + if (cacheNode.s === TERMINATED) { + return cacheNode.v; + } + if (cacheNode.s === ERRORED) { + throw cacheNode.v; + } + try { + // $FlowFixMe: We don't want to use rest arguments since we transpile the code. + const result = fn.apply(null, arguments); + const terminatedNode: TerminatedCacheNode = (cacheNode: any); + terminatedNode.s = TERMINATED; + terminatedNode.v = result; + return result; + } catch (error) { + // We store the first error that's thrown and rethrow it. + const erroredNode: ErroredCacheNode = (cacheNode: any); + erroredNode.s = ERRORED; + erroredNode.v = error; + throw error; + } + }; +} +*/ diff --git a/test/test_selector.ts b/test/test_selector.ts index ff9d2fad8..59d3e8c6e 100644 --- a/test/test_selector.ts +++ b/test/test_selector.ts @@ -4,10 +4,13 @@ import { createSelector, createSelectorCreator, defaultMemoize, - createStructuredSelector + createStructuredSelector, + autotrackMemoize, + weakMapMemoize } from 'reselect' import lodashMemoize from 'lodash/memoize' import { vi } from 'vitest' +import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit' // Construct 1E6 states for perf test outside of the perf test so as to not change the execute time of the test function const numOfStates = 1000000 @@ -110,16 +113,17 @@ describe('Basic selector behavior', () => { ) const state1 = { a: 1, b: 2 } - const start = new Date() + const start = performance.now() for (let i = 0; i < 1000000; i++) { selector(state1) } - const totalTime = new Date().getTime() - start.getTime() + const totalTime = performance.now() - start expect(selector(state1)).toBe(3) expect(selector.recomputations()).toBe(1) // Expected a million calls to a selector with the same arguments to take less than 1 second expect(totalTime).toBeLessThan(1000) + console.log('Total basic time: ', totalTime) }) test('basic selector cache hit performance for state changes but shallowly equal selector args', () => { @@ -464,7 +468,6 @@ describe('defaultMemoize', () => { This test was useful when we had a cache size of 1 previously, and always saved `lastArgs`. But, with the new implementation, this doesn't make sense any more. - // the third call does not fall through because `defaultMemoize` passes `anotherObject` as // both the `newVal` and `oldVal` params. This allows `shallowEqual` to be much more performant // than if it had passed `someObject` as `oldVal`, even though `someObject` and `anotherObject` @@ -899,3 +902,459 @@ describe('createSelector exposed utils', () => { expect(selector.lastResult()).toBe(3) }) }) + +describe('Basic selector behavior with autotrack', () => { + const createSelector = createSelectorCreator(autotrackMemoize) + + test('basic selector', () => { + // console.log('Selector test') + const selector = createSelector( + (state: StateA) => state.a, + a => a + ) + const firstState = { a: 1 } + const firstStateNewPointer = { a: 1 } + const secondState = { a: 2 } + + expect(selector(firstState)).toBe(1) + expect(selector(firstState)).toBe(1) + expect(selector.recomputations()).toBe(1) + expect(selector(firstStateNewPointer)).toBe(1) + expect(selector.recomputations()).toBe(1) + expect(selector(secondState)).toBe(2) + expect(selector.recomputations()).toBe(2) + }) + + test("don't pass extra parameters to inputSelector when only called with the state", () => { + const selector = createSelector( + (...params: any[]) => params.length, + a => a + ) + expect(selector({})).toBe(1) + }) + + test('basic selector multiple keys', () => { + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + const state1 = { a: 1, b: 2 } + expect(selector(state1)).toBe(3) + expect(selector(state1)).toBe(3) + expect(selector.recomputations()).toBe(1) + const state2 = { a: 3, b: 2 } + expect(selector(state2)).toBe(5) + expect(selector(state2)).toBe(5) + expect(selector.recomputations()).toBe(2) + }) + + test('basic selector invalid input selector', () => { + expect(() => + createSelector( + // @ts-ignore + (state: StateAB) => state.a, + function input2(state: StateAB) { + return state.b + }, + 'not a function', + (a: any, b: any) => a + b + ) + ).toThrow( + 'createSelector expects all input-selectors to be functions, but received the following types: [function unnamed(), function input2(), string]' + ) + + expect(() => + // @ts-ignore + createSelector((state: StateAB) => state.a, 'not a function') + ).toThrow( + 'createSelector expects an output function after the inputs, but received: [string]' + ) + }) + + test('basic selector cache hit performance', () => { + if (process.env.COVERAGE) { + return // don't run performance tests for coverage + } + + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + const state1 = { a: 1, b: 2 } + + const start = performance.now() + for (let i = 0; i < 1000000; i++) { + selector(state1) + } + const totalTime = performance.now() - start + console.log('Total time', totalTime) + + expect(selector(state1)).toBe(3) + expect(selector.recomputations()).toBe(1) + // Expected a million calls to a selector with the same arguments to take less than 1 second + expect(totalTime).toBeLessThan(1000) + }) + + test('basic selector cache hit performance for state changes but shallowly equal selector args', () => { + if (process.env.COVERAGE) { + return // don't run performance tests for coverage + } + + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + + const start = performance.now() + for (let i = 0; i < 1000000; i++) { + selector(states[i]) + } + const totalTime = performance.now() - start + + console.log('Total time', totalTime) + + expect(selector(states[0])).toBe(3) + expect(selector.recomputations()).toBe(1) + + // Expected a million calls to a selector with the same arguments to take less than 1 second + expect(totalTime).toBeLessThan(1000) + }) + + test('memoized composite arguments', () => { + const selector = createSelector( + (state: StateSub) => state.sub, + sub => sub.a + ) + const state1 = { sub: { a: 1 } } + expect(selector(state1)).toEqual(1) + expect(selector(state1)).toEqual(1) + expect(selector.recomputations()).toBe(1) + const state2 = { sub: { a: 2 } } + expect(selector(state2)).toEqual(2) + expect(selector.recomputations()).toBe(2) + }) + + test('first argument can be an array', () => { + const selector = createSelector( + [state => state.a, state => state.b], + (a, b) => { + return a + b + } + ) + expect(selector({ a: 1, b: 2 })).toBe(3) + expect(selector({ a: 1, b: 2 })).toBe(3) + expect(selector.recomputations()).toBe(1) + expect(selector({ a: 3, b: 2 })).toBe(5) + expect(selector.recomputations()).toBe(2) + }) + + test('can accept props', () => { + let called = 0 + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (state: StateAB, props: { c: number }) => props.c, + (a, b, c) => { + called++ + return a + b + c + } + ) + expect(selector({ a: 1, b: 2 }, { c: 100 })).toBe(103) + }) + + test('recomputes result after exception', () => { + let called = 0 + const selector = createSelector( + (state: StateA) => state.a, + () => { + called++ + throw Error('test error') + } + ) + expect(() => selector({ a: 1 })).toThrow('test error') + expect(() => selector({ a: 1 })).toThrow('test error') + expect(called).toBe(2) + }) + + test('memoizes previous result before exception', () => { + let called = 0 + const selector = createSelector( + (state: StateA) => state.a, + a => { + called++ + if (a > 1) throw Error('test error') + return a + } + ) + const state1 = { a: 1 } + const state2 = { a: 2 } + expect(selector(state1)).toBe(1) + expect(() => selector(state2)).toThrow('test error') + expect(selector(state1)).toBe(1) + expect(called).toBe(2) + }) +}) + +describe('More perf comparisons', () => { + const csDefault = createSelectorCreator(defaultMemoize) + const csAutotrack = createSelectorCreator(autotrackMemoize) + + interface Todo { + id: number + name: string + completed: boolean + } + + type TodosState = Todo[] + + const counterSlice = createSlice({ + name: 'counters', + initialState: { + deeply: { + nested: { + really: { + deeply: { + nested: { + c1: { value: 0 } + } + } + } + } + }, + + c2: { value: 0 } + }, + reducers: { + increment1(state) { + // state.c1.value++ + state.deeply.nested.really.deeply.nested.c1.value++ + }, + increment2(state) { + state.c2.value++ + } + } + }) + + const todosSlice = createSlice({ + name: 'todos', + initialState: [ + { id: 0, name: 'a', completed: false }, + { id: 1, name: 'b', completed: false }, + { id: 2, name: 'c', completed: false } + ] as TodosState, + reducers: { + toggleCompleted(state, action: PayloadAction) { + const todo = state.find(todo => todo.id === action.payload) + if (todo) { + todo.completed = !todo.completed + } + }, + setName(state) { + state[1].name = 'd' + } + } + }) + + const store = configureStore({ + reducer: { + counter: counterSlice.reducer, + todos: todosSlice.reducer + }, + middleware: gDM => + gDM({ + serializableCheck: false, + immutableCheck: false + }) + }) + + type RootState = ReturnType + + const states: RootState[] = [] + + for (let i = 0; i < 1000; i++) { + states.push(store.getState()) + store.dispatch(counterSlice.actions.increment1()) + states.push(store.getState()) + store.dispatch(counterSlice.actions.increment2()) + states.push(store.getState()) + store.dispatch(todosSlice.actions.toggleCompleted(1)) + states.push(store.getState()) + store.dispatch(todosSlice.actions.setName()) + states.push(store.getState()) + } + + it('More detailed perf comparison', () => { + const cdCounters1 = csDefault( + (state: RootState) => + state.counter.deeply.nested.really.deeply.nested.c1.value, + (state: RootState) => state.counter.c2.value, + (c1, c2) => { + return c1 + c2 + } + ) + + const cdCounters2 = csDefault( + (state: RootState) => state.counter.deeply.nested.really.deeply.nested.c1, + (state: RootState) => state.counter.c2, + (c1, c2) => { + return c1.value + c2.value + } + ) + + const cdTodoIds = csDefault( + (state: RootState) => state.todos, + todos => { + return todos.map(todo => todo.id) + } + ) + + const cdTodoIdsAndNames = csDefault( + (state: RootState) => state.todos, + todos => { + return todos.map(todo => ({ id: todo.id, name: todo.name })) + } + ) + + const cdCompletedTodos = csDefault( + (state: RootState) => state.todos, + todos => { + const completed = todos.filter(todo => todo.completed) + return completed.length + } + ) + + const cdCompletedTodos2 = csDefault( + (state: RootState) => state.todos, + todos => { + const completed = todos.filter(todo => todo.completed) + return completed.length + } + ) + + const caCounters1 = csDefault( + (state: RootState) => + state.counter.deeply.nested.really.deeply.nested.c1.value, + (state: RootState) => state.counter.c2.value, + (c1, c2) => { + return c1 + c2 + } + ) + + const caCounters2 = csAutotrack( + (state: RootState) => state.counter.deeply.nested.really.deeply.nested.c1, + (state: RootState) => state.counter.c2, + (c1, c2) => { + // console.log('inside caCounters2: ', { c1, c2 }) + return c1.value + c2.value + } + ) + + const caTodoIds = csAutotrack( + (state: RootState) => state.todos, + todos => { + return todos.map(todo => todo.id) + } + ) + + const caTodoIdsAndNames = csAutotrack( + (state: RootState) => state.todos, + todos => { + return todos.map(todo => ({ id: todo.id, name: todo.name })) + } + ) + + const caCompletedTodos = csAutotrack( + (state: RootState) => state.todos, + todos => { + const completed = todos.filter(todo => todo.completed) + return completed.length + } + ) + + const caCompletedTodos2 = csAutotrack( + (state: RootState) => state.todos, + todos => { + const completed = todos.filter(todo => todo.completed) + return completed.length + } + ) + + const defaultStart = performance.now() + for (const state of states) { + cdCounters1(state) + cdCounters2(state) + // console.log('csCounters2', cdCounters2(state)) + cdTodoIds(state) + cdTodoIdsAndNames(state) + cdCompletedTodos(state) + cdCompletedTodos2(state) + } + const defaultEnd = performance.now() + + const autotrackStart = performance.now() + for (const state of states) { + caCounters1(state) + caCounters2(state) + // console.log('State.counter: ', state.counter) + // console.log('caCounters2', caCounters2(state)) + caTodoIds(state) + caTodoIdsAndNames(state) + caCompletedTodos(state) + caCompletedTodos2(state) + } + const autotrackEnd = performance.now() + + const allSelectors = { + cdCounters1, + cdCounters2, + cdTodoIds, + cdTodoIdsAndNames, + cdCompletedTodos, + cdCompletedTodos2, + caCounters1, + caCounters2, + caTodoIds, + caTodoIdsAndNames, + caCompletedTodos, + caCompletedTodos2 + } + + console.log('\nTotal recomputations:') + Object.entries(allSelectors).forEach(([name, selector]) => { + console.log(name, selector.recomputations()) + }) + + console.log('Total elapsed times: ', { + defaultElapsed: defaultEnd - defaultStart, + autotrackElapsed: autotrackEnd - autotrackStart + }) + }) + + it.skip('weakMapMemoizer recalcs', () => { + const state1 = store.getState() + + store.dispatch(counterSlice.actions.increment1()) + const state2 = store.getState() + + const csWeakmap = createSelectorCreator(weakMapMemoize) + + const cwCounters2 = csWeakmap( + (state: RootState) => state.counter.deeply.nested.really.deeply.nested.c1, + (state: RootState) => state.counter.c2, + (c1, c2) => { + // console.log('inside caCounters2: ', { c1, c2 }) + return c1.value + c2.value + } + ) + + for (let i = 0; i < 10; i++) { + cwCounters2(state1) + cwCounters2(state2) + } + + console.log('cwCounters2.recomputations()', cwCounters2.recomputations()) + }) +}) From ff08b3b937ff8766e77ca59afac0fdac5a77615f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 4 May 2023 12:09:06 -0700 Subject: [PATCH 3/9] WIP Got garbage-collecting selector results working --- package.json | 2 +- test/test_selector.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c7b854fc..99e893980 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "format": "prettier --write \"{src,test}/**/*.{js,ts}\" \"docs/**/*.md\"", "lint": "eslint src test", "prepack": "yarn build", - "test": "vitest run", + "test": "node --expose-gc ./node_modules/vitest/dist/cli-wrapper.js run", "test:cov": "vitest run --coverage", "test:typescript": "tsc --noEmit -p typescript_test/tsconfig.json" }, diff --git a/test/test_selector.ts b/test/test_selector.ts index 59d3e8c6e..95e43be60 100644 --- a/test/test_selector.ts +++ b/test/test_selector.ts @@ -1357,4 +1357,87 @@ describe('More perf comparisons', () => { console.log('cwCounters2.recomputations()', cwCounters2.recomputations()) }) + + test.only('Does something?', async () => { + const fn = vi.fn() + + let resolve: () => void + const promise = new Promise(r => (resolve = r)) + + const registry = new FinalizationRegistry(heldValue => { + resolve() + console.log('Garbage-collected value for ID: ', heldValue) + fn(heldValue) + }) + + const createSelectorWeakmap = createSelectorCreator(weakMapMemoize) + + const store = configureStore({ + reducer: { + counter: counterSlice.reducer, + todos: todosSlice.reducer + }, + middleware: gDM => + gDM({ + serializableCheck: false, + immutableCheck: false + }) + }) + + const reduxStates: RootState[] = [] + + const NUM_ITEMS = 10 + + for (let i = 0; i < NUM_ITEMS; i++) { + store.dispatch(todosSlice.actions.toggleCompleted(1)) + const state = store.getState() + reduxStates.push(state) + registry.register(state, i) + } + + const cdTodoIdsAndNames = createSelectorWeakmap( + (state: RootState) => state.todos, + todos => { + // console.log('Recalculating todo IDs') + return todos.map(todo => ({ id: todo.id, name: todo.name })) + } + ) + + for (const state of reduxStates) { + cdTodoIdsAndNames(state) + } + + expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS) + + for (const state of reduxStates) { + cdTodoIdsAndNames(state) + } + + expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS) + + console.log('clearCache: ', cdTodoIdsAndNames.clearCache) + cdTodoIdsAndNames.memoizedResultFunc.clearCache() + + cdTodoIdsAndNames(reduxStates[0]) + + expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS + 1) + + cdTodoIdsAndNames(reduxStates[1]) + + expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS + 2) + + console.log('Before nulling out') + // @ts-ignore + reduxStates[0] = null + console.log('After nulling out') + if (global.gc) { + global.gc() + } + console.log('After GC') + + await promise + expect(fn).toHaveBeenCalledWith(0) + + // garbage-collected for ID: 3 + }) }) From 695c36d769b384976e5dd546cce5c9d60e84e6b8 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 9 May 2023 22:54:17 -0400 Subject: [PATCH 4/9] Rename test file in prep for splitting up --- test/{test_selector.ts => reselect.spec.ts} | 0 vitest.config.ts | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename test/{test_selector.ts => reselect.spec.ts} (100%) diff --git a/test/test_selector.ts b/test/reselect.spec.ts similarity index 100% rename from test/test_selector.ts rename to test/reselect.spec.ts diff --git a/vitest.config.ts b/vitest.config.ts index 186ad3ae3..0ee99ef30 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,9 +3,9 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, - include: ['./test/test_selector.ts'], + include: ['./test/**/*.(spec|test).[jt]s?(x)'], alias: { - 'reselect': './src/index.ts', // @remap-prod-remove-line + reselect: './src/index.ts', // @remap-prod-remove-line // this mapping is disabled as we want `dist` imports in the tests only to be used for "type-only" imports which don't play a role for jest '@internal/': './src/' From ff75ac2e91625c8fa32d4407d15a817516d9fe5b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 9 May 2023 23:07:57 -0400 Subject: [PATCH 5/9] Split up unit test files --- test/autotrackMemoize.spec.ts | 216 +++++ test/createStructuredSelector.spec.ts | 62 ++ test/defaultMemoize.spec.ts | 417 ++++++++++ test/perfComparisons.spec.ts | 347 ++++++++ test/reselect.spec.ts | 1058 ------------------------- test/selectorUtils.spec.ts | 54 ++ test/testTypes.ts | 14 + 7 files changed, 1110 insertions(+), 1058 deletions(-) create mode 100644 test/autotrackMemoize.spec.ts create mode 100644 test/createStructuredSelector.spec.ts create mode 100644 test/defaultMemoize.spec.ts create mode 100644 test/perfComparisons.spec.ts create mode 100644 test/selectorUtils.spec.ts create mode 100644 test/testTypes.ts diff --git a/test/autotrackMemoize.spec.ts b/test/autotrackMemoize.spec.ts new file mode 100644 index 000000000..be6db56fe --- /dev/null +++ b/test/autotrackMemoize.spec.ts @@ -0,0 +1,216 @@ +import { createSelectorCreator, autotrackMemoize } from 'reselect' + +// Construct 1E6 states for perf test outside of the perf test so as to not change the execute time of the test function +const numOfStates = 1000000 +interface StateA { + a: number +} + +interface StateAB { + a: number + b: number +} + +interface StateSub { + sub: { + a: number + } +} + +const states: StateAB[] = [] + +for (let i = 0; i < numOfStates; i++) { + states.push({ a: 1, b: 2 }) +} + +describe('Basic selector behavior with autotrack', () => { + const createSelector = createSelectorCreator(autotrackMemoize) + + test('basic selector', () => { + // console.log('Selector test') + const selector = createSelector( + (state: StateA) => state.a, + a => a + ) + const firstState = { a: 1 } + const firstStateNewPointer = { a: 1 } + const secondState = { a: 2 } + + expect(selector(firstState)).toBe(1) + expect(selector(firstState)).toBe(1) + expect(selector.recomputations()).toBe(1) + expect(selector(firstStateNewPointer)).toBe(1) + expect(selector.recomputations()).toBe(1) + expect(selector(secondState)).toBe(2) + expect(selector.recomputations()).toBe(2) + }) + + test("don't pass extra parameters to inputSelector when only called with the state", () => { + const selector = createSelector( + (...params: any[]) => params.length, + a => a + ) + expect(selector({})).toBe(1) + }) + + test('basic selector multiple keys', () => { + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + const state1 = { a: 1, b: 2 } + expect(selector(state1)).toBe(3) + expect(selector(state1)).toBe(3) + expect(selector.recomputations()).toBe(1) + const state2 = { a: 3, b: 2 } + expect(selector(state2)).toBe(5) + expect(selector(state2)).toBe(5) + expect(selector.recomputations()).toBe(2) + }) + + test('basic selector invalid input selector', () => { + expect(() => + createSelector( + // @ts-ignore + (state: StateAB) => state.a, + function input2(state: StateAB) { + return state.b + }, + 'not a function', + (a: any, b: any) => a + b + ) + ).toThrow( + 'createSelector expects all input-selectors to be functions, but received the following types: [function unnamed(), function input2(), string]' + ) + + expect(() => + // @ts-ignore + createSelector((state: StateAB) => state.a, 'not a function') + ).toThrow( + 'createSelector expects an output function after the inputs, but received: [string]' + ) + }) + + test('basic selector cache hit performance', () => { + if (process.env.COVERAGE) { + return // don't run performance tests for coverage + } + + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + const state1 = { a: 1, b: 2 } + + const start = performance.now() + for (let i = 0; i < 1000000; i++) { + selector(state1) + } + const totalTime = performance.now() - start + + expect(selector(state1)).toBe(3) + expect(selector.recomputations()).toBe(1) + // Expected a million calls to a selector with the same arguments to take less than 1 second + expect(totalTime).toBeLessThan(1000) + }) + + test('basic selector cache hit performance for state changes but shallowly equal selector args', () => { + if (process.env.COVERAGE) { + return // don't run performance tests for coverage + } + + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + + const start = performance.now() + for (let i = 0; i < 1000000; i++) { + selector(states[i]) + } + const totalTime = performance.now() - start + + expect(selector(states[0])).toBe(3) + expect(selector.recomputations()).toBe(1) + + // Expected a million calls to a selector with the same arguments to take less than 1 second + expect(totalTime).toBeLessThan(1000) + }) + + test('memoized composite arguments', () => { + const selector = createSelector( + (state: StateSub) => state.sub, + sub => sub.a + ) + const state1 = { sub: { a: 1 } } + expect(selector(state1)).toEqual(1) + expect(selector(state1)).toEqual(1) + expect(selector.recomputations()).toBe(1) + const state2 = { sub: { a: 2 } } + expect(selector(state2)).toEqual(2) + expect(selector.recomputations()).toBe(2) + }) + + test('first argument can be an array', () => { + const selector = createSelector( + [state => state.a, state => state.b], + (a, b) => { + return a + b + } + ) + expect(selector({ a: 1, b: 2 })).toBe(3) + expect(selector({ a: 1, b: 2 })).toBe(3) + expect(selector.recomputations()).toBe(1) + expect(selector({ a: 3, b: 2 })).toBe(5) + expect(selector.recomputations()).toBe(2) + }) + + test('can accept props', () => { + let called = 0 + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (state: StateAB, props: { c: number }) => props.c, + (a, b, c) => { + called++ + return a + b + c + } + ) + expect(selector({ a: 1, b: 2 }, { c: 100 })).toBe(103) + }) + + test('recomputes result after exception', () => { + let called = 0 + const selector = createSelector( + (state: StateA) => state.a, + () => { + called++ + throw Error('test error') + } + ) + expect(() => selector({ a: 1 })).toThrow('test error') + expect(() => selector({ a: 1 })).toThrow('test error') + expect(called).toBe(2) + }) + + test('memoizes previous result before exception', () => { + let called = 0 + const selector = createSelector( + (state: StateA) => state.a, + a => { + called++ + if (a > 1) throw Error('test error') + return a + } + ) + const state1 = { a: 1 } + const state2 = { a: 2 } + expect(selector(state1)).toBe(1) + expect(() => selector(state2)).toThrow('test error') + expect(selector(state1)).toBe(1) + expect(called).toBe(2) + }) +}) diff --git a/test/createStructuredSelector.spec.ts b/test/createStructuredSelector.spec.ts new file mode 100644 index 000000000..34e177ad3 --- /dev/null +++ b/test/createStructuredSelector.spec.ts @@ -0,0 +1,62 @@ +import { + createSelectorCreator, + defaultMemoize, + createStructuredSelector +} from 'reselect' + +interface StateAB { + a: number + b: number +} + +describe('createStructureSelector', () => { + test('structured selector', () => { + const selector = createStructuredSelector({ + x: (state: StateAB) => state.a, + y: (state: StateAB) => state.b + }) + const firstResult = selector({ a: 1, b: 2 }) + expect(firstResult).toEqual({ x: 1, y: 2 }) + expect(selector({ a: 1, b: 2 })).toBe(firstResult) + const secondResult = selector({ a: 2, b: 2 }) + expect(secondResult).toEqual({ x: 2, y: 2 }) + expect(selector({ a: 2, b: 2 })).toBe(secondResult) + }) + + test('structured selector with invalid arguments', () => { + expect(() => + // @ts-expect-error + createStructuredSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b + ) + ).toThrow(/expects first argument to be an object.*function/) + expect(() => + createStructuredSelector({ + a: state => state.b, + // @ts-expect-error + c: 'd' + }) + ).toThrow( + 'createSelector expects all input-selectors to be functions, but received the following types: [function a(), string]' + ) + }) + + test('structured selector with custom selector creator', () => { + const customSelectorCreator = createSelectorCreator( + defaultMemoize, + (a, b) => a === b + ) + const selector = createStructuredSelector( + { + x: (state: StateAB) => state.a, + y: (state: StateAB) => state.b + }, + customSelectorCreator + ) + const firstResult = selector({ a: 1, b: 2 }) + expect(firstResult).toEqual({ x: 1, y: 2 }) + expect(selector({ a: 1, b: 2 })).toBe(firstResult) + expect(selector({ a: 2, b: 2 })).toEqual({ x: 2, y: 2 }) + }) +}) diff --git a/test/defaultMemoize.spec.ts b/test/defaultMemoize.spec.ts new file mode 100644 index 000000000..94e98d97e --- /dev/null +++ b/test/defaultMemoize.spec.ts @@ -0,0 +1,417 @@ +// TODO: Add test for React Redux connect function + +import { createSelector, defaultMemoize } from 'reselect' +import { vi } from 'vitest' + +describe('defaultMemoize', () => { + test('Basic memoization', () => { + let called = 0 + const memoized = defaultMemoize(state => { + called++ + return state.a + }) + + const o1 = { a: 1 } + const o2 = { a: 2 } + expect(memoized(o1)).toBe(1) + expect(memoized(o1)).toBe(1) + expect(called).toBe(1) + expect(memoized(o2)).toBe(2) + expect(called).toBe(2) + }) + + test('Memoizes with multiple arguments', () => { + const memoized = defaultMemoize((...args) => + args.reduce((sum, value) => sum + value, 0) + ) + expect(memoized(1, 2)).toBe(3) + expect(memoized(1)).toBe(1) + }) + + test('Memoizes with equalityCheck override', () => { + // a rather absurd equals operation we can verify in tests + let called = 0 + const valueEquals = (a: any, b: any) => typeof a === typeof b + const memoized = defaultMemoize(a => { + called++ + return a + }, valueEquals) + expect(memoized(1)).toBe(1) + expect(memoized(2)).toBe(1) // yes, really true + expect(called).toBe(1) + expect(memoized('A')).toBe('A') + expect(called).toBe(2) + }) + + test('Passes correct objects to equalityCheck', () => { + let fallthroughs = 0 + function shallowEqual(newVal: any, oldVal: any) { + if (newVal === oldVal) return true + + fallthroughs += 1 // code below is expensive and should be bypassed when possible + + let countA = 0 + let countB = 0 + for (const key in newVal) { + if ( + Object.hasOwnProperty.call(newVal, key) && + newVal[key] !== oldVal[key] + ) + return false + countA++ + } + for (const key in oldVal) { + if (Object.hasOwnProperty.call(oldVal, key)) countB++ + } + return countA === countB + } + + const someObject = { foo: 'bar' } + const anotherObject = { foo: 'bar' } + const memoized = defaultMemoize(a => a, shallowEqual) + + // the first call to `memoized` doesn't hit because `defaultMemoize.lastArgs` is uninitialized + // and so `equalityCheck` is never called + memoized(someObject) + // first call does not shallow compare + expect(fallthroughs).toBe(0) + + // the next call, with a different object reference, does fall through + memoized(anotherObject) + + // call with different object does shallow compare + expect(fallthroughs).toBe(1) + + /* + This test was useful when we had a cache size of 1 previously, and always saved `lastArgs`. + But, with the new implementation, this doesn't make sense any more. + + // the third call does not fall through because `defaultMemoize` passes `anotherObject` as + // both the `newVal` and `oldVal` params. This allows `shallowEqual` to be much more performant + // than if it had passed `someObject` as `oldVal`, even though `someObject` and `anotherObject` + // are shallowly equal + memoized(anotherObject) + // call with same object as previous call does not shallow compare + expect(fallthroughs).toBe(1) + + */ + }) + + test('Accepts a max size greater than 1 with LRU cache behavior', () => { + let funcCalls = 0 + + const memoizer = defaultMemoize( + (state: any) => { + funcCalls++ + return state + }, + { + maxSize: 3 + } + ) + + // Initial call + memoizer('a') // ['a'] + expect(funcCalls).toBe(1) + + // In cache - memoized + memoizer('a') // ['a'] + expect(funcCalls).toBe(1) + + // Added + memoizer('b') // ['b', 'a'] + expect(funcCalls).toBe(2) + + // Added + memoizer('c') // ['c', 'b', 'a'] + expect(funcCalls).toBe(3) + + // Added, removes 'a' + memoizer('d') // ['d', 'c', 'b'] + expect(funcCalls).toBe(4) + + // No longer in cache, re-added, removes 'b' + memoizer('a') // ['a', 'd', 'c'] + expect(funcCalls).toBe(5) + + // In cache, moved to front + memoizer('c') // ['c', 'a', 'd'] + expect(funcCalls).toBe(5) + + // Added, removes 'd' + memoizer('e') // ['e', 'c', 'a'] + expect(funcCalls).toBe(6) + + // No longer in cache, re-added, removes 'a' + memoizer('d') // ['d', 'e', 'c'] + expect(funcCalls).toBe(7) + }) + + test('Allows reusing an existing result if they are equivalent', () => { + interface Todo { + id: number + name: string + } + + const todos1: Todo[] = [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' } + ] + const todos2 = todos1.slice() + todos2[2] = { id: 3, name: 'd' } + + function is(x: unknown, y: unknown) { + if (x === y) { + return x !== 0 || y !== 0 || 1 / x === 1 / y + } else { + return x !== x && y !== y + } + } + + function shallowEqual(objA: any, objB: any) { + if (is(objA, objB)) return true + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false + } + + const keysA = Object.keys(objA) + const keysB = Object.keys(objB) + + if (keysA.length !== keysB.length) return false + + for (let i = 0; i < keysA.length; i++) { + if ( + !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || + !is(objA[keysA[i]], objB[keysA[i]]) + ) { + return false + } + } + + return true + } + + for (const maxSize of [1, 3]) { + let funcCalls = 0 + + const memoizer = defaultMemoize( + (state: Todo[]) => { + funcCalls++ + return state.map(todo => todo.id) + }, + { + maxSize, + resultEqualityCheck: shallowEqual + } + ) + + const ids1 = memoizer(todos1) + expect(funcCalls).toBe(1) + + const ids2 = memoizer(todos1) + expect(funcCalls).toBe(1) + expect(ids2).toBe(ids1) + + const ids3 = memoizer(todos2) + expect(funcCalls).toBe(2) + expect(ids3).toBe(ids1) + } + }) + + test('updates the cache key even if resultEqualityCheck is a hit', () => { + const selector = vi.fn(x => x) + const equalityCheck = vi.fn((a, b) => a === b) + const resultEqualityCheck = vi.fn((a, b) => typeof a === typeof b) + + const memoizedFn = defaultMemoize(selector, { + maxSize: 1, + resultEqualityCheck, + equalityCheck + }) + + // initialize the cache + memoizedFn('cache this result') + expect(selector).toBeCalledTimes(1) + + // resultEqualityCheck hit (with a different cache key) + const result = memoizedFn('arg1') + expect(equalityCheck).toHaveLastReturnedWith(false) + expect(resultEqualityCheck).toHaveLastReturnedWith(true) + expect(result).toBe('cache this result') + expect(selector).toBeCalledTimes(2) + + // cache key should now be updated + const result2 = memoizedFn('arg1') + expect(result2).toBe('cache this result') + expect(equalityCheck).toHaveLastReturnedWith(true) + expect(selector).toBeCalledTimes(2) + }) + + // Issue #527 + test('Allows caching a value of `undefined`', () => { + const state = { + foo: { baz: 'baz' }, + bar: 'qux' + } + + const fooChangeSpy = vi.fn() + + const fooChangeHandler = createSelector( + (state: any) => state.foo, + fooChangeSpy + ) + + fooChangeHandler(state) + expect(fooChangeSpy.mock.calls.length).toEqual(1) + + // no change + fooChangeHandler(state) + // this would fail + expect(fooChangeSpy.mock.calls.length).toEqual(1) + + const state2 = { a: 1 } + let count = 0 + + const selector = createSelector([(state: any) => state.a], () => { + count++ + return undefined + }) + + selector(state) + expect(count).toBe(1) + selector(state) + expect(count).toBe(1) + }) + + test('Accepts an options object as an arg', () => { + let memoizer1Calls = 0 + + const acceptsEqualityCheckAsOption = defaultMemoize((a: any) => a, { + equalityCheck: (a, b) => { + memoizer1Calls++ + return a === b + } + }) + + acceptsEqualityCheckAsOption(42) + acceptsEqualityCheckAsOption(43) + + expect(memoizer1Calls).toBeGreaterThan(0) + + let called = 0 + const fallsBackToDefaultEqualityIfNoArgGiven = defaultMemoize( + state => { + called++ + return state.a + }, + { + // no args + } + ) + + const o1 = { a: 1 } + const o2 = { a: 2 } + expect(fallsBackToDefaultEqualityIfNoArgGiven(o1)).toBe(1) + expect(fallsBackToDefaultEqualityIfNoArgGiven(o1)).toBe(1) + expect(called).toBe(1) + expect(fallsBackToDefaultEqualityIfNoArgGiven(o2)).toBe(2) + expect(called).toBe(2) + }) + + test('Exposes a clearCache method on the memoized function', () => { + let funcCalls = 0 + + // Cache size of 1 + const memoizer = defaultMemoize( + (state: any) => { + funcCalls++ + return state + }, + { + maxSize: 1 + } + ) + + // Initial call + memoizer('a') // ['a'] + expect(funcCalls).toBe(1) + + // In cache - memoized + memoizer('a') // ['a'] + expect(funcCalls).toBe(1) + + memoizer.clearCache() + + // Cache was cleared + memoizer('a') + expect(funcCalls).toBe(2) + + funcCalls = 0 + + // Test out maxSize of 3 + exposure via createSelector + const selector = createSelector( + (state: string) => state, + state => { + funcCalls++ + return state + }, + { + memoizeOptions: { maxSize: 3 } + } + ) + + // Initial call + selector('a') // ['a'] + expect(funcCalls).toBe(1) + + // In cache - memoized + selector('a') // ['a'] + expect(funcCalls).toBe(1) + + // Added + selector('b') // ['b', 'a'] + expect(funcCalls).toBe(2) + + // Added + selector('c') // ['c', 'b', 'a'] + expect(funcCalls).toBe(3) + + // Already in cache + selector('c') // ['c', 'b', 'a'] + expect(funcCalls).toBe(3) + + selector.memoizedResultFunc.clearCache() + + // Added + selector('a') // ['a'] + expect(funcCalls).toBe(4) + + // Already in cache + selector('a') // ['a'] + expect(funcCalls).toBe(4) + + // make sure clearCache is passed to the selector correctly + selector.clearCache() + + // Cache was cleared + // Note: the outer arguments wrapper function still has 'a' in its own size-1 cache, so passing + // 'a' here would _not_ recalculate + selector('b') // ['b'] + expect(funcCalls).toBe(5) + + try { + //@ts-expect-error issue 591 + selector.resultFunc.clearCache() + fail('should have thrown for issue 591') + } catch (err) { + //expected catch + } + }) +}) diff --git a/test/perfComparisons.spec.ts b/test/perfComparisons.spec.ts new file mode 100644 index 000000000..4e50cb276 --- /dev/null +++ b/test/perfComparisons.spec.ts @@ -0,0 +1,347 @@ +import { + createSelectorCreator, + defaultMemoize, + autotrackMemoize, + weakMapMemoize +} from 'reselect' +import { vi } from 'vitest' +import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit' + +describe('More perf comparisons', () => { + const csDefault = createSelectorCreator(defaultMemoize) + const csAutotrack = createSelectorCreator(autotrackMemoize) + + interface Todo { + id: number + name: string + completed: boolean + } + + type TodosState = Todo[] + + const counterSlice = createSlice({ + name: 'counters', + initialState: { + deeply: { + nested: { + really: { + deeply: { + nested: { + c1: { value: 0 } + } + } + } + } + }, + + c2: { value: 0 } + }, + reducers: { + increment1(state) { + // state.c1.value++ + state.deeply.nested.really.deeply.nested.c1.value++ + }, + increment2(state) { + state.c2.value++ + } + } + }) + + const todosSlice = createSlice({ + name: 'todos', + initialState: [ + { id: 0, name: 'a', completed: false }, + { id: 1, name: 'b', completed: false }, + { id: 2, name: 'c', completed: false } + ] as TodosState, + reducers: { + toggleCompleted(state, action: PayloadAction) { + const todo = state.find(todo => todo.id === action.payload) + if (todo) { + todo.completed = !todo.completed + } + }, + setName(state) { + state[1].name = 'd' + } + } + }) + + const store = configureStore({ + reducer: { + counter: counterSlice.reducer, + todos: todosSlice.reducer + }, + middleware: gDM => + gDM({ + serializableCheck: false, + immutableCheck: false + }) + }) + + type RootState = ReturnType + + const states: RootState[] = [] + + for (let i = 0; i < 10000; i++) { + states.push(store.getState()) + store.dispatch(counterSlice.actions.increment1()) + states.push(store.getState()) + store.dispatch(counterSlice.actions.increment2()) + states.push(store.getState()) + store.dispatch(todosSlice.actions.toggleCompleted(1)) + states.push(store.getState()) + store.dispatch(todosSlice.actions.setName()) + states.push(store.getState()) + } + + it('More detailed perf comparison', () => { + const cdCounters1 = csDefault( + (state: RootState) => + state.counter.deeply.nested.really.deeply.nested.c1.value, + (state: RootState) => state.counter.c2.value, + (c1, c2) => { + return c1 + c2 + } + ) + + const cdCounters2 = csDefault( + (state: RootState) => state.counter.deeply.nested.really.deeply.nested.c1, + (state: RootState) => state.counter.c2, + (c1, c2) => { + return c1.value + c2.value + } + ) + + const cdTodoIds = csDefault( + (state: RootState) => state.todos, + todos => { + return todos.map(todo => todo.id) + } + ) + + const cdTodoIdsAndNames = csDefault( + (state: RootState) => state.todos, + todos => { + return todos.map(todo => ({ id: todo.id, name: todo.name })) + } + ) + + const cdCompletedTodos = csDefault( + (state: RootState) => state.todos, + todos => { + const completed = todos.filter(todo => todo.completed) + return completed.length + } + ) + + const cdCompletedTodos2 = csDefault( + (state: RootState) => state.todos, + todos => { + const completed = todos.filter(todo => todo.completed) + return completed.length + } + ) + + const caCounters1 = csDefault( + (state: RootState) => + state.counter.deeply.nested.really.deeply.nested.c1.value, + (state: RootState) => state.counter.c2.value, + (c1, c2) => { + return c1 + c2 + } + ) + + const caCounters2 = csAutotrack( + (state: RootState) => state.counter.deeply.nested.really.deeply.nested.c1, + (state: RootState) => state.counter.c2, + (c1, c2) => { + // console.log('inside caCounters2: ', { c1, c2 }) + return c1.value + c2.value + } + ) + + const caTodoIds = csAutotrack( + (state: RootState) => state.todos, + todos => { + return todos.map(todo => todo.id) + } + ) + + const caTodoIdsAndNames = csAutotrack( + (state: RootState) => state.todos, + todos => { + return todos.map(todo => ({ id: todo.id, name: todo.name })) + } + ) + + const caCompletedTodos = csAutotrack( + (state: RootState) => state.todos, + todos => { + const completed = todos.filter(todo => todo.completed) + return completed.length + } + ) + + const caCompletedTodos2 = csAutotrack( + (state: RootState) => state.todos, + todos => { + const completed = todos.filter(todo => todo.completed) + return completed.length + } + ) + + const defaultStart = performance.now() + for (const state of states) { + cdCounters1(state) + cdCounters2(state) + // console.log('csCounters2', cdCounters2(state)) + cdTodoIds(state) + cdTodoIdsAndNames(state) + cdCompletedTodos(state) + cdCompletedTodos2(state) + } + const defaultEnd = performance.now() + + const autotrackStart = performance.now() + for (const state of states) { + caCounters1(state) + caCounters2(state) + // console.log('State.counter: ', state.counter) + // console.log('caCounters2', caCounters2(state)) + caTodoIds(state) + caTodoIdsAndNames(state) + caCompletedTodos(state) + caCompletedTodos2(state) + } + const autotrackEnd = performance.now() + + const allSelectors = { + cdCounters1, + cdCounters2, + cdTodoIds, + cdTodoIdsAndNames, + cdCompletedTodos, + cdCompletedTodos2, + caCounters1, + caCounters2, + caTodoIds, + caTodoIdsAndNames, + caCompletedTodos, + caCompletedTodos2 + } + + // console.log('\nTotal recomputations:') + // Object.entries(allSelectors).forEach(([name, selector]) => { + // console.log(name, selector.recomputations()) + // }) + + // console.log('Total elapsed times: ', { + // defaultElapsed: defaultEnd - defaultStart, + // autotrackElapsed: autotrackEnd - autotrackStart + // }) + }) + + it.skip('weakMapMemoizer recalcs', () => { + const state1 = store.getState() + + store.dispatch(counterSlice.actions.increment1()) + const state2 = store.getState() + + const csWeakmap = createSelectorCreator(weakMapMemoize) + + const cwCounters2 = csWeakmap( + (state: RootState) => state.counter.deeply.nested.really.deeply.nested.c1, + (state: RootState) => state.counter.c2, + (c1, c2) => { + // console.log('inside caCounters2: ', { c1, c2 }) + return c1.value + c2.value + } + ) + + for (let i = 0; i < 10; i++) { + cwCounters2(state1) + cwCounters2(state2) + } + + console.log('cwCounters2.recomputations()', cwCounters2.recomputations()) + }) + + test('Weakmap memoizer has an infinite cache size', async () => { + const fn = vi.fn() + + let resolve: () => void + const promise = new Promise(r => (resolve = r)) + + const registry = new FinalizationRegistry(heldValue => { + resolve() + fn(heldValue) + }) + + const createSelectorWeakmap = createSelectorCreator(weakMapMemoize) + + const store = configureStore({ + reducer: { + counter: counterSlice.reducer, + todos: todosSlice.reducer + }, + middleware: gDM => + gDM({ + serializableCheck: false, + immutableCheck: false + }) + }) + + const reduxStates: RootState[] = [] + + const NUM_ITEMS = 10 + + for (let i = 0; i < NUM_ITEMS; i++) { + store.dispatch(todosSlice.actions.toggleCompleted(1)) + const state = store.getState() + reduxStates.push(state) + registry.register(state, i) + } + + const cdTodoIdsAndNames = createSelectorWeakmap( + (state: RootState) => state.todos, + todos => { + // console.log('Recalculating todo IDs') + return todos.map(todo => ({ id: todo.id, name: todo.name })) + } + ) + + for (const state of reduxStates) { + cdTodoIdsAndNames(state) + } + + expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS) + + for (const state of reduxStates) { + cdTodoIdsAndNames(state) + } + + expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS) + + cdTodoIdsAndNames.memoizedResultFunc.clearCache() + + cdTodoIdsAndNames(reduxStates[0]) + + expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS + 1) + + cdTodoIdsAndNames(reduxStates[1]) + + expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS + 2) + + // @ts-ignore + reduxStates[0] = null + if (global.gc) { + global.gc() + } + + await promise + expect(fn).toHaveBeenCalledWith(0) + + // garbage-collected for ID: 3 + }) +}) diff --git a/test/reselect.spec.ts b/test/reselect.spec.ts index 95e43be60..a201b2115 100644 --- a/test/reselect.spec.ts +++ b/test/reselect.spec.ts @@ -123,7 +123,6 @@ describe('Basic selector behavior', () => { expect(selector.recomputations()).toBe(1) // Expected a million calls to a selector with the same arguments to take less than 1 second expect(totalTime).toBeLessThan(1000) - console.log('Total basic time: ', totalTime) }) test('basic selector cache hit performance for state changes but shallowly equal selector args', () => { @@ -384,1060 +383,3 @@ describe('Customizing selectors', () => { expect(memoizer3Calls).toBeGreaterThan(0) }) }) - -describe('defaultMemoize', () => { - test('Basic memoization', () => { - let called = 0 - const memoized = defaultMemoize(state => { - called++ - return state.a - }) - - const o1 = { a: 1 } - const o2 = { a: 2 } - expect(memoized(o1)).toBe(1) - expect(memoized(o1)).toBe(1) - expect(called).toBe(1) - expect(memoized(o2)).toBe(2) - expect(called).toBe(2) - }) - - test('Memoizes with multiple arguments', () => { - const memoized = defaultMemoize((...args) => - args.reduce((sum, value) => sum + value, 0) - ) - expect(memoized(1, 2)).toBe(3) - expect(memoized(1)).toBe(1) - }) - - test('Memoizes with equalityCheck override', () => { - // a rather absurd equals operation we can verify in tests - let called = 0 - const valueEquals = (a: any, b: any) => typeof a === typeof b - const memoized = defaultMemoize(a => { - called++ - return a - }, valueEquals) - expect(memoized(1)).toBe(1) - expect(memoized(2)).toBe(1) // yes, really true - expect(called).toBe(1) - expect(memoized('A')).toBe('A') - expect(called).toBe(2) - }) - - test('Passes correct objects to equalityCheck', () => { - let fallthroughs = 0 - function shallowEqual(newVal: any, oldVal: any) { - if (newVal === oldVal) return true - - fallthroughs += 1 // code below is expensive and should be bypassed when possible - - let countA = 0 - let countB = 0 - for (const key in newVal) { - if ( - Object.hasOwnProperty.call(newVal, key) && - newVal[key] !== oldVal[key] - ) - return false - countA++ - } - for (const key in oldVal) { - if (Object.hasOwnProperty.call(oldVal, key)) countB++ - } - return countA === countB - } - - const someObject = { foo: 'bar' } - const anotherObject = { foo: 'bar' } - const memoized = defaultMemoize(a => a, shallowEqual) - - // the first call to `memoized` doesn't hit because `defaultMemoize.lastArgs` is uninitialized - // and so `equalityCheck` is never called - memoized(someObject) - // first call does not shallow compare - expect(fallthroughs).toBe(0) - - // the next call, with a different object reference, does fall through - memoized(anotherObject) - - // call with different object does shallow compare - expect(fallthroughs).toBe(1) - - /* - This test was useful when we had a cache size of 1 previously, and always saved `lastArgs`. - But, with the new implementation, this doesn't make sense any more. - - // the third call does not fall through because `defaultMemoize` passes `anotherObject` as - // both the `newVal` and `oldVal` params. This allows `shallowEqual` to be much more performant - // than if it had passed `someObject` as `oldVal`, even though `someObject` and `anotherObject` - // are shallowly equal - memoized(anotherObject) - // call with same object as previous call does not shallow compare - expect(fallthroughs).toBe(1) - - */ - }) - - test('Accepts a max size greater than 1 with LRU cache behavior', () => { - let funcCalls = 0 - - const memoizer = defaultMemoize( - (state: any) => { - funcCalls++ - return state - }, - { - maxSize: 3 - } - ) - - // Initial call - memoizer('a') // ['a'] - expect(funcCalls).toBe(1) - - // In cache - memoized - memoizer('a') // ['a'] - expect(funcCalls).toBe(1) - - // Added - memoizer('b') // ['b', 'a'] - expect(funcCalls).toBe(2) - - // Added - memoizer('c') // ['c', 'b', 'a'] - expect(funcCalls).toBe(3) - - // Added, removes 'a' - memoizer('d') // ['d', 'c', 'b'] - expect(funcCalls).toBe(4) - - // No longer in cache, re-added, removes 'b' - memoizer('a') // ['a', 'd', 'c'] - expect(funcCalls).toBe(5) - - // In cache, moved to front - memoizer('c') // ['c', 'a', 'd'] - expect(funcCalls).toBe(5) - - // Added, removes 'd' - memoizer('e') // ['e', 'c', 'a'] - expect(funcCalls).toBe(6) - - // No longer in cache, re-added, removes 'a' - memoizer('d') // ['d', 'e', 'c'] - expect(funcCalls).toBe(7) - }) - - test('Allows reusing an existing result if they are equivalent', () => { - interface Todo { - id: number - name: string - } - - const todos1: Todo[] = [ - { id: 1, name: 'a' }, - { id: 2, name: 'b' }, - { id: 3, name: 'c' } - ] - const todos2 = todos1.slice() - todos2[2] = { id: 3, name: 'd' } - - function is(x: unknown, y: unknown) { - if (x === y) { - return x !== 0 || y !== 0 || 1 / x === 1 / y - } else { - return x !== x && y !== y - } - } - - function shallowEqual(objA: any, objB: any) { - if (is(objA, objB)) return true - - if ( - typeof objA !== 'object' || - objA === null || - typeof objB !== 'object' || - objB === null - ) { - return false - } - - const keysA = Object.keys(objA) - const keysB = Object.keys(objB) - - if (keysA.length !== keysB.length) return false - - for (let i = 0; i < keysA.length; i++) { - if ( - !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || - !is(objA[keysA[i]], objB[keysA[i]]) - ) { - return false - } - } - - return true - } - - for (const maxSize of [1, 3]) { - let funcCalls = 0 - - const memoizer = defaultMemoize( - (state: Todo[]) => { - funcCalls++ - return state.map(todo => todo.id) - }, - { - maxSize, - resultEqualityCheck: shallowEqual - } - ) - - const ids1 = memoizer(todos1) - expect(funcCalls).toBe(1) - - const ids2 = memoizer(todos1) - expect(funcCalls).toBe(1) - expect(ids2).toBe(ids1) - - const ids3 = memoizer(todos2) - expect(funcCalls).toBe(2) - expect(ids3).toBe(ids1) - } - }) - - test('updates the cache key even if resultEqualityCheck is a hit', () => { - const selector = vi.fn(x => x) - const equalityCheck = vi.fn((a, b) => a === b) - const resultEqualityCheck = vi.fn((a, b) => typeof a === typeof b) - - const memoizedFn = defaultMemoize(selector, { - maxSize: 1, - resultEqualityCheck, - equalityCheck - }) - - // initialize the cache - memoizedFn('cache this result') - expect(selector).toBeCalledTimes(1) - - // resultEqualityCheck hit (with a different cache key) - const result = memoizedFn('arg1') - expect(equalityCheck).toHaveLastReturnedWith(false) - expect(resultEqualityCheck).toHaveLastReturnedWith(true) - expect(result).toBe('cache this result') - expect(selector).toBeCalledTimes(2) - - // cache key should now be updated - const result2 = memoizedFn('arg1') - expect(result2).toBe('cache this result') - expect(equalityCheck).toHaveLastReturnedWith(true) - expect(selector).toBeCalledTimes(2) - }) - - // Issue #527 - test('Allows caching a value of `undefined`', () => { - const state = { - foo: { baz: 'baz' }, - bar: 'qux' - } - - const fooChangeSpy = vi.fn() - - const fooChangeHandler = createSelector( - (state: any) => state.foo, - fooChangeSpy - ) - - fooChangeHandler(state) - expect(fooChangeSpy.mock.calls.length).toEqual(1) - - // no change - fooChangeHandler(state) - // this would fail - expect(fooChangeSpy.mock.calls.length).toEqual(1) - - const state2 = { a: 1 } - let count = 0 - - const selector = createSelector([(state: any) => state.a], () => { - count++ - return undefined - }) - - selector(state) - expect(count).toBe(1) - selector(state) - expect(count).toBe(1) - }) - - test('Accepts an options object as an arg', () => { - let memoizer1Calls = 0 - - const acceptsEqualityCheckAsOption = defaultMemoize((a: any) => a, { - equalityCheck: (a, b) => { - memoizer1Calls++ - return a === b - } - }) - - acceptsEqualityCheckAsOption(42) - acceptsEqualityCheckAsOption(43) - - expect(memoizer1Calls).toBeGreaterThan(0) - - let called = 0 - const fallsBackToDefaultEqualityIfNoArgGiven = defaultMemoize( - state => { - called++ - return state.a - }, - { - // no args - } - ) - - const o1 = { a: 1 } - const o2 = { a: 2 } - expect(fallsBackToDefaultEqualityIfNoArgGiven(o1)).toBe(1) - expect(fallsBackToDefaultEqualityIfNoArgGiven(o1)).toBe(1) - expect(called).toBe(1) - expect(fallsBackToDefaultEqualityIfNoArgGiven(o2)).toBe(2) - expect(called).toBe(2) - }) - - test('Exposes a clearCache method on the memoized function', () => { - let funcCalls = 0 - - // Cache size of 1 - const memoizer = defaultMemoize( - (state: any) => { - funcCalls++ - return state - }, - { - maxSize: 1 - } - ) - - // Initial call - memoizer('a') // ['a'] - expect(funcCalls).toBe(1) - - // In cache - memoized - memoizer('a') // ['a'] - expect(funcCalls).toBe(1) - - memoizer.clearCache() - - // Cache was cleared - memoizer('a') - expect(funcCalls).toBe(2) - - funcCalls = 0 - - // Test out maxSize of 3 + exposure via createSelector - const selector = createSelector( - (state: string) => state, - state => { - funcCalls++ - return state - }, - { - memoizeOptions: { maxSize: 3 } - } - ) - - // Initial call - selector('a') // ['a'] - expect(funcCalls).toBe(1) - - // In cache - memoized - selector('a') // ['a'] - expect(funcCalls).toBe(1) - - // Added - selector('b') // ['b', 'a'] - expect(funcCalls).toBe(2) - - // Added - selector('c') // ['c', 'b', 'a'] - expect(funcCalls).toBe(3) - - // Already in cache - selector('c') // ['c', 'b', 'a'] - expect(funcCalls).toBe(3) - - selector.memoizedResultFunc.clearCache() - - // Added - selector('a') // ['a'] - expect(funcCalls).toBe(4) - - // Already in cache - selector('a') // ['a'] - expect(funcCalls).toBe(4) - - // make sure clearCache is passed to the selector correctly - selector.clearCache() - - // Cache was cleared - // Note: the outer arguments wrapper function still has 'a' in its own size-1 cache, so passing - // 'a' here would _not_ recalculate - selector('b') // ['b'] - expect(funcCalls).toBe(5) - - try { - //@ts-expect-error issue 591 - selector.resultFunc.clearCache() - fail('should have thrown for issue 591') - } catch (err) { - //expected catch - } - }) -}) - -describe('createStructureSelector', () => { - test('structured selector', () => { - const selector = createStructuredSelector({ - x: (state: StateAB) => state.a, - y: (state: StateAB) => state.b - }) - const firstResult = selector({ a: 1, b: 2 }) - expect(firstResult).toEqual({ x: 1, y: 2 }) - expect(selector({ a: 1, b: 2 })).toBe(firstResult) - const secondResult = selector({ a: 2, b: 2 }) - expect(secondResult).toEqual({ x: 2, y: 2 }) - expect(selector({ a: 2, b: 2 })).toBe(secondResult) - }) - - test('structured selector with invalid arguments', () => { - expect(() => - // @ts-expect-error - createStructuredSelector( - (state: StateAB) => state.a, - (state: StateAB) => state.b - ) - ).toThrow(/expects first argument to be an object.*function/) - expect(() => - createStructuredSelector({ - a: state => state.b, - // @ts-expect-error - c: 'd' - }) - ).toThrow( - 'createSelector expects all input-selectors to be functions, but received the following types: [function a(), string]' - ) - }) - - test('structured selector with custom selector creator', () => { - const customSelectorCreator = createSelectorCreator( - defaultMemoize, - (a, b) => a === b - ) - const selector = createStructuredSelector( - { - x: (state: StateAB) => state.a, - y: (state: StateAB) => state.b - }, - customSelectorCreator - ) - const firstResult = selector({ a: 1, b: 2 }) - expect(firstResult).toEqual({ x: 1, y: 2 }) - expect(selector({ a: 1, b: 2 })).toBe(firstResult) - expect(selector({ a: 2, b: 2 })).toEqual({ x: 2, y: 2 }) - }) -}) - -describe('createSelector exposed utils', () => { - test('resetRecomputations', () => { - const selector = createSelector( - (state: StateA) => state.a, - a => a - ) - expect(selector({ a: 1 })).toBe(1) - expect(selector({ a: 1 })).toBe(1) - expect(selector.recomputations()).toBe(1) - expect(selector({ a: 2 })).toBe(2) - expect(selector.recomputations()).toBe(2) - - selector.resetRecomputations() - expect(selector.recomputations()).toBe(0) - - expect(selector({ a: 1 })).toBe(1) - expect(selector({ a: 1 })).toBe(1) - expect(selector.recomputations()).toBe(1) - expect(selector({ a: 2 })).toBe(2) - expect(selector.recomputations()).toBe(2) - }) - - test('export last function as resultFunc', () => { - const lastFunction = () => {} - const selector = createSelector((state: StateA) => state.a, lastFunction) - expect(selector.resultFunc).toBe(lastFunction) - }) - - test('export dependencies as dependencies', () => { - const dependency1 = (state: StateA) => { - state.a - } - const dependency2 = (state: StateA) => { - state.a - } - - const selector = createSelector(dependency1, dependency2, () => {}) - expect(selector.dependencies).toEqual([dependency1, dependency2]) - }) - - test('export lastResult function', () => { - const selector = createSelector( - (state: StateAB) => state.a, - (state: StateAB) => state.b, - (a, b) => a + b - ) - - const result = selector({ a: 1, b: 2 }) - expect(result).toBe(3) - expect(selector.lastResult()).toBe(3) - }) -}) - -describe('Basic selector behavior with autotrack', () => { - const createSelector = createSelectorCreator(autotrackMemoize) - - test('basic selector', () => { - // console.log('Selector test') - const selector = createSelector( - (state: StateA) => state.a, - a => a - ) - const firstState = { a: 1 } - const firstStateNewPointer = { a: 1 } - const secondState = { a: 2 } - - expect(selector(firstState)).toBe(1) - expect(selector(firstState)).toBe(1) - expect(selector.recomputations()).toBe(1) - expect(selector(firstStateNewPointer)).toBe(1) - expect(selector.recomputations()).toBe(1) - expect(selector(secondState)).toBe(2) - expect(selector.recomputations()).toBe(2) - }) - - test("don't pass extra parameters to inputSelector when only called with the state", () => { - const selector = createSelector( - (...params: any[]) => params.length, - a => a - ) - expect(selector({})).toBe(1) - }) - - test('basic selector multiple keys', () => { - const selector = createSelector( - (state: StateAB) => state.a, - (state: StateAB) => state.b, - (a, b) => a + b - ) - const state1 = { a: 1, b: 2 } - expect(selector(state1)).toBe(3) - expect(selector(state1)).toBe(3) - expect(selector.recomputations()).toBe(1) - const state2 = { a: 3, b: 2 } - expect(selector(state2)).toBe(5) - expect(selector(state2)).toBe(5) - expect(selector.recomputations()).toBe(2) - }) - - test('basic selector invalid input selector', () => { - expect(() => - createSelector( - // @ts-ignore - (state: StateAB) => state.a, - function input2(state: StateAB) { - return state.b - }, - 'not a function', - (a: any, b: any) => a + b - ) - ).toThrow( - 'createSelector expects all input-selectors to be functions, but received the following types: [function unnamed(), function input2(), string]' - ) - - expect(() => - // @ts-ignore - createSelector((state: StateAB) => state.a, 'not a function') - ).toThrow( - 'createSelector expects an output function after the inputs, but received: [string]' - ) - }) - - test('basic selector cache hit performance', () => { - if (process.env.COVERAGE) { - return // don't run performance tests for coverage - } - - const selector = createSelector( - (state: StateAB) => state.a, - (state: StateAB) => state.b, - (a, b) => a + b - ) - const state1 = { a: 1, b: 2 } - - const start = performance.now() - for (let i = 0; i < 1000000; i++) { - selector(state1) - } - const totalTime = performance.now() - start - console.log('Total time', totalTime) - - expect(selector(state1)).toBe(3) - expect(selector.recomputations()).toBe(1) - // Expected a million calls to a selector with the same arguments to take less than 1 second - expect(totalTime).toBeLessThan(1000) - }) - - test('basic selector cache hit performance for state changes but shallowly equal selector args', () => { - if (process.env.COVERAGE) { - return // don't run performance tests for coverage - } - - const selector = createSelector( - (state: StateAB) => state.a, - (state: StateAB) => state.b, - (a, b) => a + b - ) - - const start = performance.now() - for (let i = 0; i < 1000000; i++) { - selector(states[i]) - } - const totalTime = performance.now() - start - - console.log('Total time', totalTime) - - expect(selector(states[0])).toBe(3) - expect(selector.recomputations()).toBe(1) - - // Expected a million calls to a selector with the same arguments to take less than 1 second - expect(totalTime).toBeLessThan(1000) - }) - - test('memoized composite arguments', () => { - const selector = createSelector( - (state: StateSub) => state.sub, - sub => sub.a - ) - const state1 = { sub: { a: 1 } } - expect(selector(state1)).toEqual(1) - expect(selector(state1)).toEqual(1) - expect(selector.recomputations()).toBe(1) - const state2 = { sub: { a: 2 } } - expect(selector(state2)).toEqual(2) - expect(selector.recomputations()).toBe(2) - }) - - test('first argument can be an array', () => { - const selector = createSelector( - [state => state.a, state => state.b], - (a, b) => { - return a + b - } - ) - expect(selector({ a: 1, b: 2 })).toBe(3) - expect(selector({ a: 1, b: 2 })).toBe(3) - expect(selector.recomputations()).toBe(1) - expect(selector({ a: 3, b: 2 })).toBe(5) - expect(selector.recomputations()).toBe(2) - }) - - test('can accept props', () => { - let called = 0 - const selector = createSelector( - (state: StateAB) => state.a, - (state: StateAB) => state.b, - (state: StateAB, props: { c: number }) => props.c, - (a, b, c) => { - called++ - return a + b + c - } - ) - expect(selector({ a: 1, b: 2 }, { c: 100 })).toBe(103) - }) - - test('recomputes result after exception', () => { - let called = 0 - const selector = createSelector( - (state: StateA) => state.a, - () => { - called++ - throw Error('test error') - } - ) - expect(() => selector({ a: 1 })).toThrow('test error') - expect(() => selector({ a: 1 })).toThrow('test error') - expect(called).toBe(2) - }) - - test('memoizes previous result before exception', () => { - let called = 0 - const selector = createSelector( - (state: StateA) => state.a, - a => { - called++ - if (a > 1) throw Error('test error') - return a - } - ) - const state1 = { a: 1 } - const state2 = { a: 2 } - expect(selector(state1)).toBe(1) - expect(() => selector(state2)).toThrow('test error') - expect(selector(state1)).toBe(1) - expect(called).toBe(2) - }) -}) - -describe('More perf comparisons', () => { - const csDefault = createSelectorCreator(defaultMemoize) - const csAutotrack = createSelectorCreator(autotrackMemoize) - - interface Todo { - id: number - name: string - completed: boolean - } - - type TodosState = Todo[] - - const counterSlice = createSlice({ - name: 'counters', - initialState: { - deeply: { - nested: { - really: { - deeply: { - nested: { - c1: { value: 0 } - } - } - } - } - }, - - c2: { value: 0 } - }, - reducers: { - increment1(state) { - // state.c1.value++ - state.deeply.nested.really.deeply.nested.c1.value++ - }, - increment2(state) { - state.c2.value++ - } - } - }) - - const todosSlice = createSlice({ - name: 'todos', - initialState: [ - { id: 0, name: 'a', completed: false }, - { id: 1, name: 'b', completed: false }, - { id: 2, name: 'c', completed: false } - ] as TodosState, - reducers: { - toggleCompleted(state, action: PayloadAction) { - const todo = state.find(todo => todo.id === action.payload) - if (todo) { - todo.completed = !todo.completed - } - }, - setName(state) { - state[1].name = 'd' - } - } - }) - - const store = configureStore({ - reducer: { - counter: counterSlice.reducer, - todos: todosSlice.reducer - }, - middleware: gDM => - gDM({ - serializableCheck: false, - immutableCheck: false - }) - }) - - type RootState = ReturnType - - const states: RootState[] = [] - - for (let i = 0; i < 1000; i++) { - states.push(store.getState()) - store.dispatch(counterSlice.actions.increment1()) - states.push(store.getState()) - store.dispatch(counterSlice.actions.increment2()) - states.push(store.getState()) - store.dispatch(todosSlice.actions.toggleCompleted(1)) - states.push(store.getState()) - store.dispatch(todosSlice.actions.setName()) - states.push(store.getState()) - } - - it('More detailed perf comparison', () => { - const cdCounters1 = csDefault( - (state: RootState) => - state.counter.deeply.nested.really.deeply.nested.c1.value, - (state: RootState) => state.counter.c2.value, - (c1, c2) => { - return c1 + c2 - } - ) - - const cdCounters2 = csDefault( - (state: RootState) => state.counter.deeply.nested.really.deeply.nested.c1, - (state: RootState) => state.counter.c2, - (c1, c2) => { - return c1.value + c2.value - } - ) - - const cdTodoIds = csDefault( - (state: RootState) => state.todos, - todos => { - return todos.map(todo => todo.id) - } - ) - - const cdTodoIdsAndNames = csDefault( - (state: RootState) => state.todos, - todos => { - return todos.map(todo => ({ id: todo.id, name: todo.name })) - } - ) - - const cdCompletedTodos = csDefault( - (state: RootState) => state.todos, - todos => { - const completed = todos.filter(todo => todo.completed) - return completed.length - } - ) - - const cdCompletedTodos2 = csDefault( - (state: RootState) => state.todos, - todos => { - const completed = todos.filter(todo => todo.completed) - return completed.length - } - ) - - const caCounters1 = csDefault( - (state: RootState) => - state.counter.deeply.nested.really.deeply.nested.c1.value, - (state: RootState) => state.counter.c2.value, - (c1, c2) => { - return c1 + c2 - } - ) - - const caCounters2 = csAutotrack( - (state: RootState) => state.counter.deeply.nested.really.deeply.nested.c1, - (state: RootState) => state.counter.c2, - (c1, c2) => { - // console.log('inside caCounters2: ', { c1, c2 }) - return c1.value + c2.value - } - ) - - const caTodoIds = csAutotrack( - (state: RootState) => state.todos, - todos => { - return todos.map(todo => todo.id) - } - ) - - const caTodoIdsAndNames = csAutotrack( - (state: RootState) => state.todos, - todos => { - return todos.map(todo => ({ id: todo.id, name: todo.name })) - } - ) - - const caCompletedTodos = csAutotrack( - (state: RootState) => state.todos, - todos => { - const completed = todos.filter(todo => todo.completed) - return completed.length - } - ) - - const caCompletedTodos2 = csAutotrack( - (state: RootState) => state.todos, - todos => { - const completed = todos.filter(todo => todo.completed) - return completed.length - } - ) - - const defaultStart = performance.now() - for (const state of states) { - cdCounters1(state) - cdCounters2(state) - // console.log('csCounters2', cdCounters2(state)) - cdTodoIds(state) - cdTodoIdsAndNames(state) - cdCompletedTodos(state) - cdCompletedTodos2(state) - } - const defaultEnd = performance.now() - - const autotrackStart = performance.now() - for (const state of states) { - caCounters1(state) - caCounters2(state) - // console.log('State.counter: ', state.counter) - // console.log('caCounters2', caCounters2(state)) - caTodoIds(state) - caTodoIdsAndNames(state) - caCompletedTodos(state) - caCompletedTodos2(state) - } - const autotrackEnd = performance.now() - - const allSelectors = { - cdCounters1, - cdCounters2, - cdTodoIds, - cdTodoIdsAndNames, - cdCompletedTodos, - cdCompletedTodos2, - caCounters1, - caCounters2, - caTodoIds, - caTodoIdsAndNames, - caCompletedTodos, - caCompletedTodos2 - } - - console.log('\nTotal recomputations:') - Object.entries(allSelectors).forEach(([name, selector]) => { - console.log(name, selector.recomputations()) - }) - - console.log('Total elapsed times: ', { - defaultElapsed: defaultEnd - defaultStart, - autotrackElapsed: autotrackEnd - autotrackStart - }) - }) - - it.skip('weakMapMemoizer recalcs', () => { - const state1 = store.getState() - - store.dispatch(counterSlice.actions.increment1()) - const state2 = store.getState() - - const csWeakmap = createSelectorCreator(weakMapMemoize) - - const cwCounters2 = csWeakmap( - (state: RootState) => state.counter.deeply.nested.really.deeply.nested.c1, - (state: RootState) => state.counter.c2, - (c1, c2) => { - // console.log('inside caCounters2: ', { c1, c2 }) - return c1.value + c2.value - } - ) - - for (let i = 0; i < 10; i++) { - cwCounters2(state1) - cwCounters2(state2) - } - - console.log('cwCounters2.recomputations()', cwCounters2.recomputations()) - }) - - test.only('Does something?', async () => { - const fn = vi.fn() - - let resolve: () => void - const promise = new Promise(r => (resolve = r)) - - const registry = new FinalizationRegistry(heldValue => { - resolve() - console.log('Garbage-collected value for ID: ', heldValue) - fn(heldValue) - }) - - const createSelectorWeakmap = createSelectorCreator(weakMapMemoize) - - const store = configureStore({ - reducer: { - counter: counterSlice.reducer, - todos: todosSlice.reducer - }, - middleware: gDM => - gDM({ - serializableCheck: false, - immutableCheck: false - }) - }) - - const reduxStates: RootState[] = [] - - const NUM_ITEMS = 10 - - for (let i = 0; i < NUM_ITEMS; i++) { - store.dispatch(todosSlice.actions.toggleCompleted(1)) - const state = store.getState() - reduxStates.push(state) - registry.register(state, i) - } - - const cdTodoIdsAndNames = createSelectorWeakmap( - (state: RootState) => state.todos, - todos => { - // console.log('Recalculating todo IDs') - return todos.map(todo => ({ id: todo.id, name: todo.name })) - } - ) - - for (const state of reduxStates) { - cdTodoIdsAndNames(state) - } - - expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS) - - for (const state of reduxStates) { - cdTodoIdsAndNames(state) - } - - expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS) - - console.log('clearCache: ', cdTodoIdsAndNames.clearCache) - cdTodoIdsAndNames.memoizedResultFunc.clearCache() - - cdTodoIdsAndNames(reduxStates[0]) - - expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS + 1) - - cdTodoIdsAndNames(reduxStates[1]) - - expect(cdTodoIdsAndNames.recomputations()).toBe(NUM_ITEMS + 2) - - console.log('Before nulling out') - // @ts-ignore - reduxStates[0] = null - console.log('After nulling out') - if (global.gc) { - global.gc() - } - console.log('After GC') - - await promise - expect(fn).toHaveBeenCalledWith(0) - - // garbage-collected for ID: 3 - }) -}) diff --git a/test/selectorUtils.spec.ts b/test/selectorUtils.spec.ts new file mode 100644 index 000000000..2e34b4d87 --- /dev/null +++ b/test/selectorUtils.spec.ts @@ -0,0 +1,54 @@ +import { createSelector } from 'reselect' + +describe('createSelector exposed utils', () => { + test('resetRecomputations', () => { + const selector = createSelector( + (state: StateA) => state.a, + a => a + ) + expect(selector({ a: 1 })).toBe(1) + expect(selector({ a: 1 })).toBe(1) + expect(selector.recomputations()).toBe(1) + expect(selector({ a: 2 })).toBe(2) + expect(selector.recomputations()).toBe(2) + + selector.resetRecomputations() + expect(selector.recomputations()).toBe(0) + + expect(selector({ a: 1 })).toBe(1) + expect(selector({ a: 1 })).toBe(1) + expect(selector.recomputations()).toBe(1) + expect(selector({ a: 2 })).toBe(2) + expect(selector.recomputations()).toBe(2) + }) + + test('export last function as resultFunc', () => { + const lastFunction = () => {} + const selector = createSelector((state: StateA) => state.a, lastFunction) + expect(selector.resultFunc).toBe(lastFunction) + }) + + test('export dependencies as dependencies', () => { + const dependency1 = (state: StateA) => { + state.a + } + const dependency2 = (state: StateA) => { + state.a + } + + const selector = createSelector(dependency1, dependency2, () => {}) + expect(selector.dependencies).toEqual([dependency1, dependency2]) + }) + + test('export lastResult function', () => { + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + + const result = selector({ a: 1, b: 2 }) + expect(result).toBe(3) + expect(selector.lastResult()).toBe(3) + }) +}) diff --git a/test/testTypes.ts b/test/testTypes.ts new file mode 100644 index 000000000..cdaa25319 --- /dev/null +++ b/test/testTypes.ts @@ -0,0 +1,14 @@ +export interface StateA { + a: number +} + +export interface StateAB { + a: number + b: number +} + +export interface StateSub { + sub: { + a: number + } +} From bfc14273b9d6354d9588bf50fb52935d4072bc56 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 9 May 2023 23:12:17 -0400 Subject: [PATCH 6/9] Add basic weakmap memoizer tests --- test/weakmapMemoize.spec.ts | 216 ++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 test/weakmapMemoize.spec.ts diff --git a/test/weakmapMemoize.spec.ts b/test/weakmapMemoize.spec.ts new file mode 100644 index 000000000..369e121ad --- /dev/null +++ b/test/weakmapMemoize.spec.ts @@ -0,0 +1,216 @@ +import { createSelectorCreator, weakMapMemoize } from 'reselect' + +// Construct 1E6 states for perf test outside of the perf test so as to not change the execute time of the test function +const numOfStates = 1000000 +interface StateA { + a: number +} + +interface StateAB { + a: number + b: number +} + +interface StateSub { + sub: { + a: number + } +} + +const states: StateAB[] = [] + +for (let i = 0; i < numOfStates; i++) { + states.push({ a: 1, b: 2 }) +} + +describe('Basic selector behavior with autotrack', () => { + const createSelector = createSelectorCreator(weakMapMemoize) + + test('basic selector', () => { + // console.log('Selector test') + const selector = createSelector( + (state: StateA) => state.a, + a => a + ) + const firstState = { a: 1 } + const firstStateNewPointer = { a: 1 } + const secondState = { a: 2 } + + expect(selector(firstState)).toBe(1) + expect(selector(firstState)).toBe(1) + expect(selector.recomputations()).toBe(1) + expect(selector(firstStateNewPointer)).toBe(1) + expect(selector.recomputations()).toBe(1) + expect(selector(secondState)).toBe(2) + expect(selector.recomputations()).toBe(2) + }) + + test("don't pass extra parameters to inputSelector when only called with the state", () => { + const selector = createSelector( + (...params: any[]) => params.length, + a => a + ) + expect(selector({})).toBe(1) + }) + + test('basic selector multiple keys', () => { + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + const state1 = { a: 1, b: 2 } + expect(selector(state1)).toBe(3) + expect(selector(state1)).toBe(3) + expect(selector.recomputations()).toBe(1) + const state2 = { a: 3, b: 2 } + expect(selector(state2)).toBe(5) + expect(selector(state2)).toBe(5) + expect(selector.recomputations()).toBe(2) + }) + + test('basic selector invalid input selector', () => { + expect(() => + createSelector( + // @ts-ignore + (state: StateAB) => state.a, + function input2(state: StateAB) { + return state.b + }, + 'not a function', + (a: any, b: any) => a + b + ) + ).toThrow( + 'createSelector expects all input-selectors to be functions, but received the following types: [function unnamed(), function input2(), string]' + ) + + expect(() => + // @ts-ignore + createSelector((state: StateAB) => state.a, 'not a function') + ).toThrow( + 'createSelector expects an output function after the inputs, but received: [string]' + ) + }) + + test('basic selector cache hit performance', () => { + if (process.env.COVERAGE) { + return // don't run performance tests for coverage + } + + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + const state1 = { a: 1, b: 2 } + + const start = performance.now() + for (let i = 0; i < 1000000; i++) { + selector(state1) + } + const totalTime = performance.now() - start + + expect(selector(state1)).toBe(3) + expect(selector.recomputations()).toBe(1) + // Expected a million calls to a selector with the same arguments to take less than 1 second + expect(totalTime).toBeLessThan(1000) + }) + + test('basic selector cache hit performance for state changes but shallowly equal selector args', () => { + if (process.env.COVERAGE) { + return // don't run performance tests for coverage + } + + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + + const start = performance.now() + for (let i = 0; i < 1000000; i++) { + selector(states[i]) + } + const totalTime = performance.now() - start + + expect(selector(states[0])).toBe(3) + expect(selector.recomputations()).toBe(1) + + // Expected a million calls to a selector with the same arguments to take less than 1 second + expect(totalTime).toBeLessThan(1000) + }) + + test('memoized composite arguments', () => { + const selector = createSelector( + (state: StateSub) => state.sub, + sub => sub.a + ) + const state1 = { sub: { a: 1 } } + expect(selector(state1)).toEqual(1) + expect(selector(state1)).toEqual(1) + expect(selector.recomputations()).toBe(1) + const state2 = { sub: { a: 2 } } + expect(selector(state2)).toEqual(2) + expect(selector.recomputations()).toBe(2) + }) + + test('first argument can be an array', () => { + const selector = createSelector( + [state => state.a, state => state.b], + (a, b) => { + return a + b + } + ) + expect(selector({ a: 1, b: 2 })).toBe(3) + expect(selector({ a: 1, b: 2 })).toBe(3) + expect(selector.recomputations()).toBe(1) + expect(selector({ a: 3, b: 2 })).toBe(5) + expect(selector.recomputations()).toBe(2) + }) + + test('can accept props', () => { + let called = 0 + const selector = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (state: StateAB, props: { c: number }) => props.c, + (a, b, c) => { + called++ + return a + b + c + } + ) + expect(selector({ a: 1, b: 2 }, { c: 100 })).toBe(103) + }) + + test('recomputes result after exception', () => { + let called = 0 + const selector = createSelector( + (state: StateA) => state.a, + () => { + called++ + throw Error('test error') + } + ) + expect(() => selector({ a: 1 })).toThrow('test error') + expect(() => selector({ a: 1 })).toThrow('test error') + expect(called).toBe(2) + }) + + test('memoizes previous result before exception', () => { + let called = 0 + const selector = createSelector( + (state: StateA) => state.a, + a => { + called++ + if (a > 1) throw Error('test error') + return a + } + ) + const state1 = { a: 1 } + const state2 = { a: 2 } + expect(selector(state1)).toBe(1) + expect(() => selector(state2)).toThrow('test error') + expect(selector(state1)).toBe(1) + expect(called).toBe(2) + }) +}) From ca25a77507e409c070ab89319420a960e9e74347 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 9 May 2023 23:14:35 -0400 Subject: [PATCH 7/9] Clean up weakmapMemoize --- src/weakMapMemoize.ts | 104 +----------------------------------------- 1 file changed, 2 insertions(+), 102 deletions(-) diff --git a/src/weakMapMemoize.ts b/src/weakMapMemoize.ts index af71bc98a..6730c10bd 100644 --- a/src/weakMapMemoize.ts +++ b/src/weakMapMemoize.ts @@ -1,6 +1,5 @@ const UNTERMINATED = 0 const TERMINATED = 1 -const ERRORED = 2 type UnterminatedCacheNode = { s: 0 @@ -16,21 +15,7 @@ type TerminatedCacheNode = { p: null | Map> } -type ErroredCacheNode = { - s: 2 - v: any - o: null | WeakMap> - p: null | Map> -} - -type CacheNode = - | TerminatedCacheNode - | UnterminatedCacheNode - | ErroredCacheNode - -function createCacheRoot(): WeakMap> { - return new WeakMap() -} +type CacheNode = TerminatedCacheNode | UnterminatedCacheNode function createCacheNode(): CacheNode { return { @@ -86,22 +71,12 @@ export function weakMapMemoize any>(func: F) { if (cacheNode.s === TERMINATED) { return cacheNode.v } - if (cacheNode.s === ERRORED) { - throw cacheNode.v - } - //try { + // Allow errors to propagate const result = func.apply(null, arguments as unknown as any[]) const terminatedNode = cacheNode as unknown as TerminatedCacheNode terminatedNode.s = TERMINATED terminatedNode.v = result return result - // } catch (error) { - // // We store the first error that's thrown and rethrow it. - // const erroredNode = cacheNode as unknown as ErroredCacheNode - // erroredNode.s = ERRORED - // erroredNode.v = error - // throw error - // } } memoized.clearCache = () => { @@ -110,78 +85,3 @@ export function weakMapMemoize any>(func: F) { return memoized as F & { clearCache: () => void } } - -/* -function cache, T>(fn: (...A) => T): (...A) => T { - return function() { - const dispatcher = ReactCurrentCache.current; - if (!dispatcher) { - // If there is no dispatcher, then we treat this as not being cached. - // $FlowFixMe: We don't want to use rest arguments since we transpile the code. - return fn.apply(null, arguments); - } - const fnMap = dispatcher.getCacheForType(createCacheRoot); - const fnNode = fnMap.get(fn); - let cacheNode: CacheNode; - if (fnNode === undefined) { - cacheNode = createCacheNode(); - fnMap.set(fn, cacheNode); - } else { - cacheNode = fnNode; - } - for (let i = 0, l = arguments.length; i < l; i++) { - const arg = arguments[i]; - if ( - typeof arg === 'function' || - (typeof arg === 'object' && arg !== null) - ) { - // Objects go into a WeakMap - let objectCache = cacheNode.o; - if (objectCache === null) { - cacheNode.o = objectCache = new WeakMap(); - } - const objectNode = objectCache.get(arg); - if (objectNode === undefined) { - cacheNode = createCacheNode(); - objectCache.set(arg, cacheNode); - } else { - cacheNode = objectNode; - } - } else { - // Primitives go into a regular Map - let primitiveCache = cacheNode.p; - if (primitiveCache === null) { - cacheNode.p = primitiveCache = new Map(); - } - const primitiveNode = primitiveCache.get(arg); - if (primitiveNode === undefined) { - cacheNode = createCacheNode(); - primitiveCache.set(arg, cacheNode); - } else { - cacheNode = primitiveNode; - } - } - } - if (cacheNode.s === TERMINATED) { - return cacheNode.v; - } - if (cacheNode.s === ERRORED) { - throw cacheNode.v; - } - try { - // $FlowFixMe: We don't want to use rest arguments since we transpile the code. - const result = fn.apply(null, arguments); - const terminatedNode: TerminatedCacheNode = (cacheNode: any); - terminatedNode.s = TERMINATED; - terminatedNode.v = result; - return result; - } catch (error) { - // We store the first error that's thrown and rethrow it. - const erroredNode: ErroredCacheNode = (cacheNode: any); - erroredNode.s = ERRORED; - erroredNode.v = error; - throw error; - } - }; -} -*/ From 7efdeea61f4af6f784a836de86a7e4e21633ffb7 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 9 May 2023 23:19:21 -0400 Subject: [PATCH 8/9] Clean up autotrackMemoize --- src/autotrackMemoize/autotrackMemoize.ts | 19 --- src/autotrackMemoize/autotracking.ts | 15 +-- src/autotrackMemoize/proxy.ts | 152 +---------------------- src/autotrackMemoize/tracking.ts | 12 -- src/autotrackMemoize/utils.ts | 10 -- 5 files changed, 3 insertions(+), 205 deletions(-) diff --git a/src/autotrackMemoize/autotrackMemoize.ts b/src/autotrackMemoize/autotrackMemoize.ts index c7cae55cf..e065b31e9 100644 --- a/src/autotrackMemoize/autotrackMemoize.ts +++ b/src/autotrackMemoize/autotrackMemoize.ts @@ -10,7 +10,6 @@ import { export function autotrackMemoize any>(func: F) { // we reference arguments instead of spreading them for performance reasons - // console.log('Creating autotrack memoizer node') const node: Node> = createNode( [] as unknown as Record ) @@ -19,34 +18,16 @@ export function autotrackMemoize any>(func: F) { const shallowEqual = createCacheKeyComparator(defaultEqualityCheck) - // console.log('Creating cache') const cache = createCache(() => { - // console.log('Executing cache: ', node.value) const res = func.apply(null, node.proxy as unknown as any[]) - // console.log('Res: ', res) return res }) - // console.log('Creating memoized function') function memoized() { - // console.log('Memoized running') if (!shallowEqual(lastArgs, arguments)) { - // console.log( - // 'Args are different: lastArgs =', - // lastArgs, - // 'newArgs =', - // arguments - // ) updateNode(node, arguments as unknown as Record) lastArgs = arguments - } else { - // console.log('Same args: ', lastArgs, arguments) } - // const start = performance.now() - // console.log('Calling memoized: ', arguments) - - // const end = performance.now() - // console.log('Memoized execution time: ', end - start) return cache.value } diff --git a/src/autotrackMemoize/autotracking.ts b/src/autotrackMemoize/autotracking.ts index 5d19b8185..aec0ca0bb 100644 --- a/src/autotrackMemoize/autotracking.ts +++ b/src/autotrackMemoize/autotracking.ts @@ -19,7 +19,6 @@ export class Cell { _isEqual: EqualityFn = tripleEq constructor(initialValue: T, isEqual: EqualityFn = tripleEq) { - // console.log('Constructing cell: ', initialValue) this._value = this._lastValue = initialValue this._isEqual = isEqual } @@ -27,7 +26,6 @@ export class Cell { // 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() { - // console.log('Getting cell value: ', this._value) CURRENT_TRACKER?.add(this) return this._value @@ -39,12 +37,10 @@ export class Cell { // 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) { - // console.log('Setting value: ', this.value, newValue) - // if (this.value === newValue) return + if (this.value === newValue) return this._value = newValue this.revision = ++$REVISION - // scheduleRerender() } } @@ -138,16 +134,7 @@ export function setValue>( 'setValue must be passed a tracked store created with `createStorage`.' ) - // console.log('setValue: ', storage, value) - - // console.log('Setting value: ', storage.value, value) - // storage.value = value - - const { _isEqual: isEqual, _lastValue: lastValue } = storage - - // if (!isEqual(value, lastValue)) { storage.value = storage._lastValue = value - // } } export function createCell( diff --git a/src/autotrackMemoize/proxy.ts b/src/autotrackMemoize/proxy.ts index c4aed98f3..c18c7f6e1 100644 --- a/src/autotrackMemoize/proxy.ts +++ b/src/autotrackMemoize/proxy.ts @@ -1,8 +1,3 @@ -// import { DEBUG } from '@glimmer/env' - -// import { consumeTag, createTag, dirtyTag, Tag } from '@glimmer/validator' -// import { consumeTag, createTag, dirtyTag, Tag } from '../tracked-storage' -import { formatMs, logLater } from './utils' import { consumeCollection, dirtyCollection, @@ -28,7 +23,6 @@ class ObjectTreeNode> implements Node { id = nextId++ constructor(public value: T) { - // console.log('Object node: ', this.value) this.value = value this.tag.value = value } @@ -36,25 +30,10 @@ class ObjectTreeNode> implements Node { const objectProxyHandler = { get(node: Node, key: string | symbol): unknown { - // if (DEBUG && key === REDUX_PROXY_LABEL) { - // // logLater('Bailing out of getter: ', key) - // return true - // } - // let res : unknown; - - const keyString = key.toString() - // if (keyString === 'constructor') { - // console.log('Constructor: ', node) - // } - const start = performance.now() - function calculateResult() { - // try { const { value } = node - // console.time('Reflect.get: ' + keyString) const childValue = Reflect.get(value, key) - // console.timeEnd('Reflect.get: ' + keyString) if (typeof key === 'symbol') { return childValue @@ -65,55 +44,31 @@ const objectProxyHandler = { } if (typeof childValue === 'object' && childValue !== null) { - // logLater('Getting child node: ', key, childValue) let childNode = node.children[key] if (childNode === undefined) { - // console.time('Creating child node') - - // console.log('Creating node: ', key, childValue) childNode = node.children[key] = createNode(childValue) - // console.timeEnd('Creating child node') } if (childNode.tag) { - // logLater('Consuming tag: ', childNode) - // console.time('Consuming tag A: ' + keyString) - // console.log('Consuming tag: ', keyString) consumeTag(childNode.tag) - // console.timeEnd('Consuming tag A: ' + keyString) } return childNode.proxy } else { let tag = node.tags[key] - if (key === 'constructor') { - // console.log('Constructor tag: ', tag) - } if (tag === undefined) { - // console.time('Creating tag: ' + key) - // console.log('Creating tag: ', key) tag = node.tags[key] = createTag() - // console.timeEnd('Creating tag: ' + key) - // console.time('Assigning tag value: ' + keyString) tag.value = childValue - // console.timeEnd('Assigning tag value: ' + keyString) } - // console.time('Consuming tag B: ' + keyString) - // console.log('Consuming tag: ', keyString, tag) consumeTag(tag) - // console.timeEnd('Consuming tag B: ' + keyString) - return childValue } } const res = calculateResult() - - const end = performance.now() - // logLater(`Proxy get trap: ${keyString}: ${formatMs(end - start)}`) return res }, @@ -126,7 +81,6 @@ const objectProxyHandler = { node: Node, prop: string | symbol ): PropertyDescriptor | undefined { - console.log('getOwnPropertyDescriptor', prop) return Reflect.getOwnPropertyDescriptor(node.value, prop) }, @@ -144,7 +98,6 @@ class ArrayTreeNode> implements Node { id = nextId++ constructor(public value: T) { - // console.log('Array node: ', value) this.value = value this.tag.value = value } @@ -194,14 +147,10 @@ export function updateNode | Record>( node: Node, newValue: T ): void { - // console.log('UpdateNode: ', newValue) const { value, tags, children } = node node.value = newValue - const start = performance.now() - - // console.time('updateNode: array check: ' + node.id) if ( Array.isArray(value) && Array.isArray(newValue) && @@ -214,8 +163,6 @@ export function updateNode | Record>( let newKeysSize = 0 let anyKeysAdded = false - // console.log('Key check: ', value, newValue) - for (const _key in value) { oldKeysSize++ } @@ -228,82 +175,19 @@ export function updateNode | Record>( } } - // let oldKeys = keysMap.get(value) - // if (!oldKeys) { - // oldKeys = new Set() - // for (let key in value) { - // oldKeys.add(key) - // } - // keysMap.set(value, oldKeys) - // } - // oldKeyIteration = performance.now() - // let newKeys = keysMap.get(newValue) - // if (!newKeys) { - // newKeys = new Set() - // for (let key in newValue) { - // newKeys.add(key) - // } - // keysMap.set(newValue, newKeys) - // } - // newKeyIteration = performance.now() - // // const oldKeys = Object.keys(value) - // // const newKeys = Object.keys(newValue) - // const isDifferent = - // oldKeys.size !== newKeys.size || anyKeysDifferent(oldKeys, newKeys) - const isDifferent = anyKeysAdded || oldKeysSize !== newKeysSize - if ( - isDifferent - // [...oldKeys].some((k) => !newKeys!.has(k)) - ) { - // console.log('Dirtying collection: ', node) + if (isDifferent) { dirtyCollection(node) } } - // console.time('Checking object keys') - // let oldKeys = keysMap.get(value) - // if (!oldKeys) { - // oldKeys = new Set() - // for (const key in value) { - // oldKeys.add(key) - // } - // keysMap.set(value, oldKeys) - // } - // let newKeys = keysMap.get(value) - // if (!newKeys) { - // newKeys = new Set() - // for (const key in newValue) { - // newKeys.add(key) - // } - // keysMap.set(newValue, newKeys) - // } - // // const oldKeys = Object.keys(value) - // // const newKeys = Object.keys(newValue) - - // if ( - // oldKeys.size !== newKeys.size || - // [...oldKeys].some(k => !newKeys!.has(k)) - // ) { - // dirtyCollection(node) - // } - // console.timeEnd('Checking object keys') } - const arrayDone = performance.now() - - // console.timeEnd('updateNode: array check: ' + node.id) - - // console.time('updateNode: tags check: ' + node.id) - - // console.log('Tags: ', tags) for (const key in tags) { - // logLater('Tag key: ', key) const childValue = (value as Record)[key] const newChildValue = (newValue as Record)[key] if (childValue !== newChildValue) { - // console.log('Dirtying tag: ', { key, childValue, newChildValue }) dirtyCollection(node) dirtyTag(tags[key], newChildValue) } @@ -313,51 +197,21 @@ export function updateNode | Record>( } } - const tagsDone = performance.now() - - // console.timeEnd('updateNode: tags check: ' + node.id) - - // console.time('updateNode: keys check: ' + node.id) - for (const key in children) { - // logLater('Child key: ', key) const childNode = children[key] const newChildValue = (newValue as Record)[key] const childValue = childNode.value if (childValue === newChildValue) { - // logLater('Skipping child node: ', key, childValue, newChildValue) continue - } else if ( - typeof newChildValue === 'object' && - newChildValue !== null // && - // Object.getPrototypeOf(newChildValue) === Object.getPrototypeOf(childValue) - ) { - // logLater('Updating child node: ', key, childValue, newChildValue) - // console.time('Nested updateNode: ' + key) + } else if (typeof newChildValue === 'object' && newChildValue !== null) { updateNode(childNode, newChildValue as Record) - // console.timeEnd('Nested updateNode: ' + key) } else { deleteNode(childNode) delete children[key] } } - - const keysDone = performance.now() - - // logLater( - // 'updateNode: ', - // { - // total: formatMs(keysDone - start), - // array: formatMs(arrayDone - start), - // tags: formatMs(tagsDone - arrayDone), - // keys: formatMs(keysDone - tagsDone) - // }, - // node.value - // ) - - // console.timeEnd('updateNode: keys check: ' + node.id) } function deleteNode(node: Node): void { @@ -371,6 +225,4 @@ function deleteNode(node: Node): void { for (const key in node.children) { deleteNode(node.children[key]) } - // Object.values(node.tags).map(dirtyTag) - // Object.values(node.children).map(deleteNode) } diff --git a/src/autotrackMemoize/tracking.ts b/src/autotrackMemoize/tracking.ts index 1d12ab817..3d70303d0 100644 --- a/src/autotrackMemoize/tracking.ts +++ b/src/autotrackMemoize/tracking.ts @@ -1,9 +1,3 @@ -// import { -// createStorage, -// getValue as consumeTag, -// setValue, -// } from '../tracked-storage' - import { createCell as createStorage, getValue as consumeTag, @@ -11,10 +5,6 @@ import { Cell } from './autotracking' -// import { consumeTag, createTag, dirtyTag, Tag } from '@glimmer/validator' - -// export { consumeTag, createTag, dirtyTag, Tag } from '@glimmer/validator' - export type Tag = Cell const neverEq = (a: any, b: any): boolean => false @@ -27,8 +17,6 @@ export function dirtyTag(tag: Tag, value: any): void { setValue(tag, value) } -//////////// - export interface Node< T extends Array | Record = | Array diff --git a/src/autotrackMemoize/utils.ts b/src/autotrackMemoize/utils.ts index bdcd1c6db..cef655a08 100644 --- a/src/autotrackMemoize/utils.ts +++ b/src/autotrackMemoize/utils.ts @@ -7,13 +7,3 @@ export function assert( throw new Error(msg) } } - -export function formatMs(n: number) { - return n.toFixed(4) + 'ms' -} - -export const loggedValues: any[] = [] - -export const logLater: typeof console.log = (...args: any[]) => { - loggedValues.push([new Date(), ...args]) -} From 41959cd3ee0bac77907a4168c57921c4afa1c983 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 9 May 2023 23:26:01 -0400 Subject: [PATCH 9/9] Add attributions --- src/autotrackMemoize/autotracking.ts | 5 +++++ src/autotrackMemoize/proxy.ts | 3 +++ src/weakMapMemoize.ts | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/autotrackMemoize/autotracking.ts b/src/autotrackMemoize/autotracking.ts index aec0ca0bb..e96fce9d2 100644 --- a/src/autotrackMemoize/autotracking.ts +++ b/src/autotrackMemoize/autotracking.ts @@ -1,3 +1,8 @@ +// Original autotracking implementation source: +// - https://gist.github.com/pzuraq/79bf862e0f8cd9521b79c4b6eccdc4f9 +// Additional references: +// - https://www.pzuraq.com/blog/how-autotracking-works +// - https://v5.chriskrycho.com/journal/autotracking-elegant-dx-via-cutting-edge-cs/ import { assert } from './utils' // The global revision clock. Every time state changes, the clock increments. diff --git a/src/autotrackMemoize/proxy.ts b/src/autotrackMemoize/proxy.ts index c18c7f6e1..8ad463aa7 100644 --- a/src/autotrackMemoize/proxy.ts +++ b/src/autotrackMemoize/proxy.ts @@ -1,3 +1,6 @@ +// Original source: +// - https://github.com/simonihmig/tracked-redux/blob/master/packages/tracked-redux/src/-private/proxy.ts + import { consumeCollection, dirtyCollection, diff --git a/src/weakMapMemoize.ts b/src/weakMapMemoize.ts index 6730c10bd..39361a42d 100644 --- a/src/weakMapMemoize.ts +++ b/src/weakMapMemoize.ts @@ -1,3 +1,6 @@ +// Original source: +// - https://github.com/facebook/react/blob/0b974418c9a56f6c560298560265dcf4b65784bc/packages/react/src/ReactCache.js + const UNTERMINATED = 0 const TERMINATED = 1 @@ -31,7 +34,6 @@ export function weakMapMemoize any>(func: F) { let fnNode = createCacheNode() - // console.log('Creating memoized function') function memoized() { let cacheNode = fnNode