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/src/autotrackMemoize/autotrackMemoize.ts b/src/autotrackMemoize/autotrackMemoize.ts new file mode 100644 index 000000000..e065b31e9 --- /dev/null +++ b/src/autotrackMemoize/autotrackMemoize.ts @@ -0,0 +1,37 @@ +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 + + const node: Node> = createNode( + [] as unknown as Record + ) + + let lastArgs: IArguments | null = null + + const shallowEqual = createCacheKeyComparator(defaultEqualityCheck) + + const cache = createCache(() => { + const res = func.apply(null, node.proxy as unknown as any[]) + return res + }) + + function memoized() { + if (!shallowEqual(lastArgs, arguments)) { + updateNode(node, arguments as unknown as Record) + lastArgs = arguments + } + 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..e96fce9d2 --- /dev/null +++ b/src/autotrackMemoize/autotracking.ts @@ -0,0 +1,159 @@ +// 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. +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) { + 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() { + CURRENT_TRACKER?.add(this) + + return this._value + } + + // Whenever a storage value is updated, we bump the global revision clock, + // assign the revision for this storage to the new value, _and_ we schedule a + // rerender. This is important, and it's what makes autotracking _pull_ + // based. We don't actively tell the caches which depend on the storage that + // anything has happened. Instead, we recompute the caches when needed. + set value(newValue) { + if (this.value === newValue) return + + this._value = newValue + this.revision = ++$REVISION + } +} + +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`.' + ) + + 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..8ad463aa7 --- /dev/null +++ b/src/autotrackMemoize/proxy.ts @@ -0,0 +1,231 @@ +// Original source: +// - https://github.com/simonihmig/tracked-redux/blob/master/packages/tracked-redux/src/-private/proxy.ts + +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) { + this.value = value + this.tag.value = value + } +} + +const objectProxyHandler = { + get(node: Node, key: string | symbol): unknown { + function calculateResult() { + const { value } = node + + const childValue = Reflect.get(value, key) + + if (typeof key === 'symbol') { + return childValue + } + + if (key in proto) { + return childValue + } + + if (typeof childValue === 'object' && childValue !== null) { + let childNode = node.children[key] + + if (childNode === undefined) { + childNode = node.children[key] = createNode(childValue) + } + + if (childNode.tag) { + consumeTag(childNode.tag) + } + + return childNode.proxy + } else { + let tag = node.tags[key] + + if (tag === undefined) { + tag = node.tags[key] = createTag() + tag.value = childValue + } + + consumeTag(tag) + + return childValue + } + } + const res = calculateResult() + return res + }, + + ownKeys(node: Node): ArrayLike { + consumeCollection(node) + return Reflect.ownKeys(node.value) + }, + + getOwnPropertyDescriptor( + node: Node, + prop: string | symbol + ): PropertyDescriptor | undefined { + 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) { + 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 { + const { value, tags, children } = node + + node.value = newValue + + 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 + + for (const _key in value) { + oldKeysSize++ + } + + for (const key in newValue) { + newKeysSize++ + if (!(key in value)) { + anyKeysAdded = true + break + } + } + + const isDifferent = anyKeysAdded || oldKeysSize !== newKeysSize + + if (isDifferent) { + dirtyCollection(node) + } + } + } + + for (const key in tags) { + const childValue = (value as Record)[key] + const newChildValue = (newValue as Record)[key] + + if (childValue !== newChildValue) { + dirtyCollection(node) + dirtyTag(tags[key], newChildValue) + } + + if (typeof newChildValue === 'object' && newChildValue !== null) { + delete tags[key] + } + } + + for (const key in children) { + const childNode = children[key] + const newChildValue = (newValue as Record)[key] + + const childValue = childNode.value + + if (childValue === newChildValue) { + continue + } else if (typeof newChildValue === 'object' && newChildValue !== null) { + updateNode(childNode, newChildValue as Record) + } else { + deleteNode(childNode) + delete children[key] + } + } +} + +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]) + } +} diff --git a/src/autotrackMemoize/tracking.ts b/src/autotrackMemoize/tracking.ts new file mode 100644 index 000000000..3d70303d0 --- /dev/null +++ b/src/autotrackMemoize/tracking.ts @@ -0,0 +1,50 @@ +import { + createCell as createStorage, + getValue as consumeTag, + setValue, + Cell +} from './autotracking' + +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..cef655a08 --- /dev/null +++ b/src/autotrackMemoize/utils.ts @@ -0,0 +1,9 @@ +export function assert( + condition: any, + msg = 'Assertion failed!' +): asserts condition { + if (!condition) { + console.error(msg) + throw new Error(msg) + } +} diff --git a/src/index.ts b/src/index.ts index a231decc9..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 } @@ -118,7 +121,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 @@ -192,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..39361a42d --- /dev/null +++ b/src/weakMapMemoize.ts @@ -0,0 +1,89 @@ +// Original source: +// - https://github.com/facebook/react/blob/0b974418c9a56f6c560298560265dcf4b65784bc/packages/react/src/ReactCache.js + +const UNTERMINATED = 0 +const TERMINATED = 1 + +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 CacheNode = TerminatedCacheNode | UnterminatedCacheNode + +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() + + 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 + } + // 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 + } + + memoized.clearCache = () => { + fnNode = createCacheNode() + } + + return memoized as F & { clearCache: () => void } +} 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 new file mode 100644 index 000000000..a201b2115 --- /dev/null +++ b/test/reselect.spec.ts @@ -0,0 +1,385 @@ +// TODO: Add test for React Redux connect function + +import { + createSelector, + createSelectorCreator, + defaultMemoize, + 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 +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', () => { + test('basic selector', () => { + 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 = new Date() + for (let i = 0; i < numOfStates; i++) { + selector(states[i]) + } + const totalTime = new Date().getTime() - start.getTime() + + 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 + ) + const state1 = { sub: { a: 1 } } + expect(selector(state1)).toEqual({ a: 1 }) + expect(selector(state1)).toEqual({ a: 1 }) + expect(selector.recomputations()).toBe(1) + const state2 = { sub: { a: 2 } } + expect(selector(state2)).toEqual({ a: 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('Combining selectors', () => { + test('chained selector', () => { + const selector1 = createSelector( + (state: StateSub) => state.sub, + sub => sub + ) + const selector2 = createSelector(selector1, sub => sub.a) + const state1 = { sub: { a: 1 } } + expect(selector2(state1)).toBe(1) + expect(selector2(state1)).toBe(1) + expect(selector2.recomputations()).toBe(1) + const state2 = { sub: { a: 2 } } + expect(selector2(state2)).toBe(2) + expect(selector2.recomputations()).toBe(2) + }) + + test('chained selector with props', () => { + const selector1 = createSelector( + (state: StateSub) => state.sub, + (state: StateSub, props: { x: number; y: number }) => props.x, + (sub, x) => ({ sub, x }) + ) + const selector2 = createSelector( + selector1, + (state: StateSub, props: { x: number; y: number }) => props.y, + (param, y) => param.sub.a + param.x + y + ) + const state1 = { sub: { a: 1 } } + expect(selector2(state1, { x: 100, y: 200 })).toBe(301) + expect(selector2(state1, { x: 100, y: 200 })).toBe(301) + expect(selector2.recomputations()).toBe(1) + const state2 = { sub: { a: 2 } } + expect(selector2(state2, { x: 100, y: 201 })).toBe(303) + expect(selector2.recomputations()).toBe(2) + }) + + test('chained selector with variadic args', () => { + const selector1 = createSelector( + (state: StateSub) => state.sub, + (state: StateSub, props: { x: number; y: number }, another: number) => + props.x + another, + (sub, x) => ({ sub, x }) + ) + const selector2 = createSelector( + selector1, + (state: StateSub, props: { x: number; y: number }) => props.y, + (param, y) => param.sub.a + param.x + y + ) + const state1 = { sub: { a: 1 } } + expect(selector2(state1, { x: 100, y: 200 }, 100)).toBe(401) + expect(selector2(state1, { x: 100, y: 200 }, 100)).toBe(401) + expect(selector2.recomputations()).toBe(1) + const state2 = { sub: { a: 2 } } + expect(selector2(state2, { x: 100, y: 201 }, 200)).toBe(503) + expect(selector2.recomputations()).toBe(2) + }) + + test('override valueEquals', () => { + // a rather absurd equals operation we can verify in tests + const createOverridenSelector = createSelectorCreator( + defaultMemoize, + (a, b) => typeof a === typeof b + ) + const selector = createOverridenSelector( + (state: StateA) => state.a, + a => a + ) + expect(selector({ a: 1 })).toBe(1) + expect(selector({ a: 2 })).toBe(1) // yes, really true + expect(selector.recomputations()).toBe(1) + // @ts-expect-error + expect(selector({ a: 'A' })).toBe('A') + expect(selector.recomputations()).toBe(2) + }) +}) + +describe('Customizing selectors', () => { + test('custom memoize', () => { + const hashFn = (...args: any[]) => + args.reduce((acc, val) => acc + '-' + JSON.stringify(val)) + const customSelectorCreator = createSelectorCreator(lodashMemoize, hashFn) + const selector = customSelectorCreator( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => 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: 1, b: 3 })).toBe(4) + expect(selector.recomputations()).toBe(2) + expect(selector({ a: 1, b: 3 })).toBe(4) + expect(selector.recomputations()).toBe(2) + expect(selector({ a: 2, b: 3 })).toBe(5) + expect(selector.recomputations()).toBe(3) + // TODO: Check correct memoize function was called + }) + + test('createSelector accepts direct memoizer arguments', () => { + let memoizer1Calls = 0 + let memoizer2Calls = 0 + let memoizer3Calls = 0 + + const defaultMemoizeAcceptsFirstArgDirectly = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b, + { + memoizeOptions: (a, b) => { + memoizer1Calls++ + return a === b + } + } + ) + + defaultMemoizeAcceptsFirstArgDirectly({ a: 1, b: 2 }) + defaultMemoizeAcceptsFirstArgDirectly({ a: 1, b: 3 }) + + expect(memoizer1Calls).toBeGreaterThan(0) + + const defaultMemoizeAcceptsArgsAsArray = createSelector( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b, + { + memoizeOptions: [ + (a, b) => { + memoizer2Calls++ + return a === b + } + ] + } + ) + + defaultMemoizeAcceptsArgsAsArray({ a: 1, b: 2 }) + defaultMemoizeAcceptsArgsAsArray({ a: 1, b: 3 }) + + expect(memoizer2Calls).toBeGreaterThan(0) + + const createSelectorWithSeparateArg = createSelectorCreator( + defaultMemoize, + (a, b) => { + memoizer3Calls++ + return a === b + } + ) + + const defaultMemoizeAcceptsArgFromCSC = createSelectorWithSeparateArg( + (state: StateAB) => state.a, + (state: StateAB) => state.b, + (a, b) => a + b + ) + + defaultMemoizeAcceptsArgFromCSC({ a: 1, b: 2 }) + defaultMemoizeAcceptsArgFromCSC({ a: 1, b: 3 }) + + expect(memoizer3Calls).toBeGreaterThan(0) + }) +}) 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 + } +} diff --git a/test/test_selector.ts b/test/test_selector.ts deleted file mode 100644 index ff9d2fad8..000000000 --- a/test/test_selector.ts +++ /dev/null @@ -1,901 +0,0 @@ -// TODO: Add test for React Redux connect function - -import { - createSelector, - createSelectorCreator, - defaultMemoize, - createStructuredSelector -} from 'reselect' -import lodashMemoize from 'lodash/memoize' -import { vi } from 'vitest' - -// 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', () => { - test('basic selector', () => { - 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 = new Date() - for (let i = 0; i < 1000000; i++) { - selector(state1) - } - const totalTime = new Date().getTime() - start.getTime() - - 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 = new Date() - for (let i = 0; i < numOfStates; i++) { - selector(states[i]) - } - const totalTime = new Date().getTime() - start.getTime() - - 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 - ) - const state1 = { sub: { a: 1 } } - expect(selector(state1)).toEqual({ a: 1 }) - expect(selector(state1)).toEqual({ a: 1 }) - expect(selector.recomputations()).toBe(1) - const state2 = { sub: { a: 2 } } - expect(selector(state2)).toEqual({ a: 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('Combining selectors', () => { - test('chained selector', () => { - const selector1 = createSelector( - (state: StateSub) => state.sub, - sub => sub - ) - const selector2 = createSelector(selector1, sub => sub.a) - const state1 = { sub: { a: 1 } } - expect(selector2(state1)).toBe(1) - expect(selector2(state1)).toBe(1) - expect(selector2.recomputations()).toBe(1) - const state2 = { sub: { a: 2 } } - expect(selector2(state2)).toBe(2) - expect(selector2.recomputations()).toBe(2) - }) - - test('chained selector with props', () => { - const selector1 = createSelector( - (state: StateSub) => state.sub, - (state: StateSub, props: { x: number; y: number }) => props.x, - (sub, x) => ({ sub, x }) - ) - const selector2 = createSelector( - selector1, - (state: StateSub, props: { x: number; y: number }) => props.y, - (param, y) => param.sub.a + param.x + y - ) - const state1 = { sub: { a: 1 } } - expect(selector2(state1, { x: 100, y: 200 })).toBe(301) - expect(selector2(state1, { x: 100, y: 200 })).toBe(301) - expect(selector2.recomputations()).toBe(1) - const state2 = { sub: { a: 2 } } - expect(selector2(state2, { x: 100, y: 201 })).toBe(303) - expect(selector2.recomputations()).toBe(2) - }) - - test('chained selector with variadic args', () => { - const selector1 = createSelector( - (state: StateSub) => state.sub, - (state: StateSub, props: { x: number; y: number }, another: number) => - props.x + another, - (sub, x) => ({ sub, x }) - ) - const selector2 = createSelector( - selector1, - (state: StateSub, props: { x: number; y: number }) => props.y, - (param, y) => param.sub.a + param.x + y - ) - const state1 = { sub: { a: 1 } } - expect(selector2(state1, { x: 100, y: 200 }, 100)).toBe(401) - expect(selector2(state1, { x: 100, y: 200 }, 100)).toBe(401) - expect(selector2.recomputations()).toBe(1) - const state2 = { sub: { a: 2 } } - expect(selector2(state2, { x: 100, y: 201 }, 200)).toBe(503) - expect(selector2.recomputations()).toBe(2) - }) - - test('override valueEquals', () => { - // a rather absurd equals operation we can verify in tests - const createOverridenSelector = createSelectorCreator( - defaultMemoize, - (a, b) => typeof a === typeof b - ) - const selector = createOverridenSelector( - (state: StateA) => state.a, - a => a - ) - expect(selector({ a: 1 })).toBe(1) - expect(selector({ a: 2 })).toBe(1) // yes, really true - expect(selector.recomputations()).toBe(1) - // @ts-expect-error - expect(selector({ a: 'A' })).toBe('A') - expect(selector.recomputations()).toBe(2) - }) -}) - -describe('Customizing selectors', () => { - test('custom memoize', () => { - const hashFn = (...args: any[]) => - args.reduce((acc, val) => acc + '-' + JSON.stringify(val)) - const customSelectorCreator = createSelectorCreator(lodashMemoize, hashFn) - const selector = customSelectorCreator( - (state: StateAB) => state.a, - (state: StateAB) => state.b, - (a, b) => 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: 1, b: 3 })).toBe(4) - expect(selector.recomputations()).toBe(2) - expect(selector({ a: 1, b: 3 })).toBe(4) - expect(selector.recomputations()).toBe(2) - expect(selector({ a: 2, b: 3 })).toBe(5) - expect(selector.recomputations()).toBe(3) - // TODO: Check correct memoize function was called - }) - - test('createSelector accepts direct memoizer arguments', () => { - let memoizer1Calls = 0 - let memoizer2Calls = 0 - let memoizer3Calls = 0 - - const defaultMemoizeAcceptsFirstArgDirectly = createSelector( - (state: StateAB) => state.a, - (state: StateAB) => state.b, - (a, b) => a + b, - { - memoizeOptions: (a, b) => { - memoizer1Calls++ - return a === b - } - } - ) - - defaultMemoizeAcceptsFirstArgDirectly({ a: 1, b: 2 }) - defaultMemoizeAcceptsFirstArgDirectly({ a: 1, b: 3 }) - - expect(memoizer1Calls).toBeGreaterThan(0) - - const defaultMemoizeAcceptsArgsAsArray = createSelector( - (state: StateAB) => state.a, - (state: StateAB) => state.b, - (a, b) => a + b, - { - memoizeOptions: [ - (a, b) => { - memoizer2Calls++ - return a === b - } - ] - } - ) - - defaultMemoizeAcceptsArgsAsArray({ a: 1, b: 2 }) - defaultMemoizeAcceptsArgsAsArray({ a: 1, b: 3 }) - - expect(memoizer2Calls).toBeGreaterThan(0) - - const createSelectorWithSeparateArg = createSelectorCreator( - defaultMemoize, - (a, b) => { - memoizer3Calls++ - return a === b - } - ) - - const defaultMemoizeAcceptsArgFromCSC = createSelectorWithSeparateArg( - (state: StateAB) => state.a, - (state: StateAB) => state.b, - (a, b) => a + b - ) - - defaultMemoizeAcceptsArgFromCSC({ a: 1, b: 2 }) - defaultMemoizeAcceptsArgFromCSC({ a: 1, b: 3 }) - - 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) - }) -}) 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) + }) +}) 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/'