Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix resultEqualityCheck behavior in weakMapMemoize #699

Merged
34 changes: 19 additions & 15 deletions src/weakMapMemoize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,25 +227,29 @@ export function weakMapMemoize<Func extends AnyFunction>(
// Allow errors to propagate
result = func.apply(null, arguments as unknown as any[])
resultsCount++
}

terminatedNode.s = TERMINATED
if (resultEqualityCheck) {
const lastResultValue = lastResult?.deref?.() ?? lastResult

if (resultEqualityCheck) {
const lastResultValue = lastResult?.deref?.() ?? lastResult
if (
lastResultValue != null &&
resultEqualityCheck(lastResultValue as ReturnType<Func>, result)
) {
result = lastResultValue
resultsCount !== 0 && resultsCount--
}
if (
lastResultValue != null &&
resultEqualityCheck(lastResultValue as ReturnType<Func>, result)
) {
result = lastResultValue

resultsCount !== 0 && resultsCount--
}

const needsWeakRef =
(typeof result === 'object' && result !== null) ||
typeof result === 'function'

const needsWeakRef =
(typeof result === 'object' && result !== null) ||
typeof result === 'function'
lastResult = needsWeakRef ? new Ref(result) : result
lastResult = needsWeakRef ? new Ref(result) : result
}
}

terminatedNode.s = TERMINATED

terminatedNode.v = result
return result
}
Expand Down
190 changes: 190 additions & 0 deletions test/benchmarks/resultEqualityCheck.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import type { AnyFunction } from '@internal/types'
import type { OutputSelector, Selector } from 'reselect'
import {
createSelector,
lruMemoize,
referenceEqualityCheck,
weakMapMemoize
} from 'reselect'
import type { Options } from 'tinybench'
import { bench } from 'vitest'
import {
logSelectorRecomputations,
setFunctionNames,
setupStore,
toggleCompleted,
type RootState
} from '../testUtils'

describe('memoize functions performance with resultEqualityCheck set to referenceEqualityCheck vs. without resultEqualityCheck', () => {
describe('comparing selectors created with createSelector', () => {
const store = setupStore()

const arrayOfNumbers = Array.from({ length: 1_000 }, (num, index) => index)

const commonOptions: Options = {
iterations: 10_000,
time: 0
}

const runSelector = <S extends Selector>(selector: S) => {
arrayOfNumbers.forEach(num => {
selector(store.getState())
})
}

const createAppSelector = createSelector.withTypes<RootState>()

const selectTodoIdsWeakMap = createAppSelector(
[state => state.todos],
todos => todos.map(({ id }) => id)
)

const selectTodoIdsWeakMapWithResultEqualityCheck = createAppSelector(
[state => state.todos],
todos => todos.map(({ id }) => id),
{
memoizeOptions: { resultEqualityCheck: referenceEqualityCheck },
argsMemoizeOptions: { resultEqualityCheck: referenceEqualityCheck }
}
)

const selectTodoIdsLru = createAppSelector(
[state => state.todos],
todos => todos.map(({ id }) => id),
{ memoize: lruMemoize, argsMemoize: lruMemoize }
)

const selectTodoIdsLruWithResultEqualityCheck = createAppSelector(
[state => state.todos],
todos => todos.map(({ id }) => id),
{
memoize: lruMemoize,
memoizeOptions: { resultEqualityCheck: referenceEqualityCheck },
argsMemoize: lruMemoize,
argsMemoizeOptions: { resultEqualityCheck: referenceEqualityCheck }
}
)

const selectors = {
selectTodoIdsWeakMap,
selectTodoIdsWeakMapWithResultEqualityCheck,
selectTodoIdsLru,
selectTodoIdsLruWithResultEqualityCheck
}

setFunctionNames(selectors)

const createOptions = <S extends OutputSelector>(selector: S) => {
const options: Options = {
setup: (task, mode) => {
if (mode === 'warmup') return

task.opts = {
beforeEach: () => {
store.dispatch(toggleCompleted(1))
},

afterAll: () => {
logSelectorRecomputations(selector)
}
}
}
}
return { ...commonOptions, ...options }
}

Object.values(selectors).forEach(selector => {
bench(
selector,
() => {
runSelector(selector)
},
createOptions(selector)
)
})
})

describe('comparing selectors created with memoize functions', () => {
const store = setupStore()

const arrayOfNumbers = Array.from(
{ length: 100_000 },
(num, index) => index
)

const commonOptions: Options = {
iterations: 1000,
time: 0
}

const runSelector = <S extends Selector>(selector: S) => {
arrayOfNumbers.forEach(num => {
selector(store.getState())
})
}

const selectTodoIdsWeakMap = weakMapMemoize((state: RootState) =>
state.todos.map(({ id }) => id)
)

const selectTodoIdsWeakMapWithResultEqualityCheck = weakMapMemoize(
(state: RootState) => state.todos.map(({ id }) => id),
{ resultEqualityCheck: referenceEqualityCheck }
)

const selectTodoIdsLru = lruMemoize((state: RootState) =>
state.todos.map(({ id }) => id)
)

const selectTodoIdsLruWithResultEqualityCheck = lruMemoize(
(state: RootState) => state.todos.map(({ id }) => id),
{ resultEqualityCheck: referenceEqualityCheck }
)

const memoizedFunctions = {
selectTodoIdsWeakMap,
selectTodoIdsWeakMapWithResultEqualityCheck,
selectTodoIdsLru,
selectTodoIdsLruWithResultEqualityCheck
}

setFunctionNames(memoizedFunctions)

const createOptions = <
Func extends AnyFunction & { resultsCount: () => number }
>(
memoizedFunction: Func
) => {
const options: Options = {
setup: (task, mode) => {
if (mode === 'warmup') return

task.opts = {
beforeEach: () => {
store.dispatch(toggleCompleted(1))
},

afterAll: () => {
console.log(
memoizedFunction.name,
memoizedFunction.resultsCount()
)
}
}
}
}
return { ...commonOptions, ...options }
}

Object.values(memoizedFunctions).forEach(memoizedFunction => {
bench(
memoizedFunction,
() => {
runSelector(memoizedFunction)
},
createOptions(memoizedFunction)
)
})
})
})
10 changes: 5 additions & 5 deletions test/computationComparisons.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,18 +304,18 @@ describe('resultEqualityCheck in weakMapMemoize', () => {
expect(memoized.resultsCount()).toBe(5)

expect(memoizedShallow(state)).toBe(memoizedShallow(state))
expect(memoizedShallow.resultsCount()).toBe(0)
expect(memoizedShallow.resultsCount()).toBe(1)
expect(memoizedShallow({ ...state })).toBe(memoizedShallow(state))
expect(memoizedShallow.resultsCount()).toBe(0)
expect(memoizedShallow.resultsCount()).toBe(1)
expect(memoizedShallow({ ...state })).toBe(memoizedShallow(state))
// We spread the state to force the function to re-run but the
// result maintains the same reference because of `resultEqualityCheck`.
const first = memoizedShallow({ ...state })
expect(memoizedShallow.resultsCount()).toBe(0)
expect(memoizedShallow.resultsCount()).toBe(1)
memoizedShallow({ ...state })
expect(memoizedShallow.resultsCount()).toBe(0)
expect(memoizedShallow.resultsCount()).toBe(1)
const second = memoizedShallow({ ...state })
expect(memoizedShallow.resultsCount()).toBe(0)
expect(memoizedShallow.resultsCount()).toBe(1)
expect(first).toBe(second)
})
})
111 changes: 110 additions & 1 deletion test/inputStabilityCheck.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { shallowEqual } from 'react-redux'
import { createSelector, lruMemoize, setGlobalDevModeChecks } from 'reselect'
import {
createSelector,
lruMemoize,
referenceEqualityCheck,
setGlobalDevModeChecks
} from 'reselect'
import type { RootState } from './testUtils'
import { localTest } from './testUtils'

describe('inputStabilityCheck', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
Expand Down Expand Up @@ -164,3 +171,105 @@ describe('inputStabilityCheck', () => {
expect(consoleSpy).not.toHaveBeenCalled()
})
})

describe('the effects of inputStabilityCheck with resultEqualityCheck', () => {
const createAppSelector = createSelector.withTypes<RootState>()

const resultEqualityCheck = vi
.fn(referenceEqualityCheck)
.mockName('resultEqualityCheck')

afterEach(() => {
resultEqualityCheck.mockClear()
})

localTest(
'resultEqualityCheck should not be called with empty objects when inputStabilityCheck is set to once and input selectors are stable',
({ store }) => {
const selectTodoIds = createAppSelector(
[state => state.todos],
todos => todos.map(({ id }) => id),
{
memoizeOptions: { resultEqualityCheck },
devModeChecks: { inputStabilityCheck: 'once' }
}
)

const firstResult = selectTodoIds(store.getState())

expect(resultEqualityCheck).not.toHaveBeenCalled()

const secondResult = selectTodoIds(store.getState())

expect(firstResult).toBe(secondResult)

expect(resultEqualityCheck).not.toHaveBeenCalled()

const thirdResult = selectTodoIds(store.getState())

expect(secondResult).toBe(thirdResult)

expect(resultEqualityCheck).not.toHaveBeenCalled()
}
)

localTest(
'resultEqualityCheck should not be called with empty objects when inputStabilityCheck is set to always and input selectors are stable',
({ store }) => {
const selectTodoIds = createAppSelector(
[state => state.todos],
todos => todos.map(({ id }) => id),
{
memoizeOptions: { resultEqualityCheck },
devModeChecks: { inputStabilityCheck: 'always' }
}
)

const firstResult = selectTodoIds(store.getState())

expect(resultEqualityCheck).not.toHaveBeenCalled()

const secondResult = selectTodoIds(store.getState())

expect(firstResult).toBe(secondResult)

expect(resultEqualityCheck).not.toHaveBeenCalled()

const thirdResult = selectTodoIds(store.getState())

expect(secondResult).toBe(thirdResult)

expect(resultEqualityCheck).not.toHaveBeenCalled()
}
)

localTest(
'resultEqualityCheck should not be called with empty objects when inputStabilityCheck is set to never and input selectors are unstable',
({ store }) => {
const selectTodoIds = createAppSelector(
[state => [...state.todos]],
todos => todos.map(({ id }) => id),
{
memoizeOptions: { resultEqualityCheck },
devModeChecks: { inputStabilityCheck: 'never' }
}
)

const firstResult = selectTodoIds(store.getState())

expect(resultEqualityCheck).not.toHaveBeenCalled()

const secondResult = selectTodoIds(store.getState())

expect(firstResult).toBe(secondResult)

expect(resultEqualityCheck).not.toHaveBeenCalled()

const thirdResult = selectTodoIds(store.getState())

expect(secondResult).toBe(thirdResult)

expect(resultEqualityCheck).not.toHaveBeenCalled()
}
)
})
Loading
Loading