Skip to content

Commit

Permalink
Hack up weakMapMemoize to try adding resultEqualityCheck
Browse files Browse the repository at this point in the history
  • Loading branch information
markerikson committed Nov 27, 2023
1 parent 9e929ca commit e99227e
Show file tree
Hide file tree
Showing 2 changed files with 346 additions and 6 deletions.
99 changes: 93 additions & 6 deletions src/weakMapMemoize.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// Original source:
// - https://github.com/facebook/react/blob/0b974418c9a56f6c560298560265dcf4b65784bc/packages/react/src/ReactCache.js

import type { AnyFunction, DefaultMemoizeFields, Simplify } from './types'
import type {
AnyFunction,
DefaultMemoizeFields,
EqualityFn,
Simplify
} from './types'

import type { NOT_FOUND_TYPE } from './utils'
import { NOT_FOUND } from './utils'

const UNTERMINATED = 0
const TERMINATED = 1
Expand Down Expand Up @@ -55,6 +63,22 @@ function createCacheNode<T>(): CacheNode<T> {
}
}

/**
* @public
*/
export interface WeakMapMemoizeOptions {
/**
* If provided, used to compare a newly generated output value against previous values in the cache.
* If a match is found, the old value is returned. This addresses the common
* ```ts
* todos.map(todo => todo.id)
* ```
* use case, where an update to another field in the original data causes a recalculation
* due to changed references, but the output is still effectively the same.
*/
resultEqualityCheck?: EqualityFn
}

/**
* Creates a tree of `WeakMap`-based cache nodes based on the identity of the
* arguments it's been called with (in this case, the extracted values from your input selectors).
Expand Down Expand Up @@ -128,8 +152,16 @@ function createCacheNode<T>(): CacheNode<T> {
* @public
* @experimental
*/
export function weakMapMemoize<Func extends AnyFunction>(func: Func) {
export function weakMapMemoize<Func extends AnyFunction>(
func: Func,
options: WeakMapMemoizeOptions = {}
) {
let fnNode = createCacheNode()
const { resultEqualityCheck } = options

let lastResult: WeakRef<object> | undefined = undefined

let resultsCount = 0

function memoized() {
let cacheNode = fnNode
Expand Down Expand Up @@ -167,19 +199,74 @@ export function weakMapMemoize<Func extends AnyFunction>(func: Func) {
}
}
}

// 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<any>
// terminatedNode.s = TERMINATED
// terminatedNode.v = result
// return result

// // Not found in cache
// if (cacheNode.s == TERMINATED && !hasResultEqualityCheck) {
// return cacheNode.v
// }

// // Allow errors to propagate
// let result = func.apply(null, arguments as unknown as any[])

// const existingResult = cacheNode.s === TERMINATED ? cacheNode.v : NOT_FOUND
// let finalResult = existingResult

// if (existingResult === NOT_FOUND) {
// finalResult = func.apply(null, arguments as unknown as any[])
// }

// terminatedNode.s = TERMINATED
// terminatedNode.v = finalResult

const terminatedNode = cacheNode as unknown as TerminatedCacheNode<any>

let result

if (cacheNode.s === TERMINATED) {
return cacheNode.v
result = cacheNode.v
} else {
// Allow errors to propagate
result = func.apply(null, arguments as unknown as any[])
resultsCount++
}
// Allow errors to propagate
const result = func.apply(null, arguments as unknown as any[])
const terminatedNode = cacheNode as unknown as TerminatedCacheNode<any>

terminatedNode.s = TERMINATED

if (resultEqualityCheck) {
const lastResultValue = lastResult?.deref() ?? lastResult
if (lastResultValue && resultEqualityCheck(lastResultValue, result)) {
console.log('Last results were equal: ', lastResultValue)
result = lastResultValue
resultsCount--
}
}
terminatedNode.v = result
// console.log('result', result)
lastResult = ['object', 'function'].includes(typeof result)
? new WeakRef(result)
: result
return result
}

memoized.clearCache = () => {
fnNode = createCacheNode()
memoized.resetResultsCount()
}

memoized.resultsCount = () => resultsCount

memoized.resetResultsCount = () => {
resultsCount = 0
}

return memoized as Func & Simplify<DefaultMemoizeFields>
Expand Down
253 changes: 253 additions & 0 deletions test/computationComparisons.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
/**
* @vitest-environment jsdom
*/

import { createSelector, weakMapMemoize } from 'reselect'
import React, { useLayoutEffect, useMemo } from 'react'
import type { TypedUseSelectorHook } from 'react-redux'
import { useSelector, Provider, shallowEqual } from 'react-redux'
import * as rtl from '@testing-library/react'

import type {
OutputSelector,
OutputSelectorFields,
Selector,
defaultMemoize
} from 'reselect'
import type { RootState, Todo } from './testUtils'
import { logSelectorRecomputations } from './testUtils'
import {
addTodo,
deepClone,
localTest,
toggleCompleted,
setupStore
} from './testUtils'

describe('Computations and re-rendering with React components', () => {
const selector = createSelector(
(a: number) => a,
a => a
)

test('passes', () => {
console.log(selector(1))
})

let store: ReturnType<typeof setupStore>

beforeEach(() => {
store = setupStore()
listItemRenders = 0
listRenders = 0
listItemMounts = 0
})

type SelectTodoIds = OutputSelector<
number[],
typeof defaultMemoize,
any,
[(state: RootState) => RootState['todos']]
>

type SelectTodoById = OutputSelector<
readonly [todo: Todo | undefined],
typeof defaultMemoize,
any,
[
(state: RootState) => RootState['todos'],
(state: RootState, id: number) => number
]
>

const selectTodos = (state: RootState) => state.todos
const mapTodoIds = (todos: RootState['todos']) => todos.map(({ id }) => id)
const selectTodoId = (todos: RootState, id: number) => id
const mapTodoById = (todos: RootState['todos'], id: number) => {
// Intentionally return this wrapped in an array to force a new reference each time
return [todos.find(todo => todo.id === id)] as const
}

const selectTodoIdsDefault = createSelector([selectTodos], mapTodoIds)
console.log(`selectTodoIdsDefault name: ${selectTodoIdsDefault.name}`)

const selectTodoIdsResultEquality = createSelector(
[selectTodos],
mapTodoIds,
{ memoizeOptions: { resultEqualityCheck: shallowEqual } }
)

const selectTodoIdsWeakMap = createSelector([selectTodos], mapTodoIds, {
argsMemoize: weakMapMemoize,
memoize: weakMapMemoize
})

const selectTodoIdsWeakMapResultEquality = createSelector(
[selectTodos],
mapTodoIds,
{
argsMemoize: weakMapMemoize,
memoize: weakMapMemoize,
memoizeOptions: { resultEqualityCheck: shallowEqual }
}
)

const selectTodoByIdDefault = createSelector(
[selectTodos, selectTodoId],
mapTodoById
)

const selectTodoByIdResultEquality = createSelector(
[selectTodos, selectTodoId],
mapTodoById,
{ memoizeOptions: { resultEqualityCheck: shallowEqual, maxSize: 500 } }
)

const selectTodoByIdWeakMap = createSelector(
[selectTodos, selectTodoId],
mapTodoById,
{ argsMemoize: weakMapMemoize, memoize: weakMapMemoize }
)

const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

let listItemRenders = 0
let listRenders = 0
let listItemMounts = 0

const TodoListItem = React.memo(function TodoListItem({
id,
selectTodoById
}: {
id: number
selectTodoById: SelectTodoById
}) {
// Prevent `useSelector` from re-running the selector while rendering
// due to passing in a new selector reference
const memoizedSelectTodoById = useMemo(
() => (state: RootState) => selectTodoById(state, id),
[id]
)
const [todo] = useAppSelector(memoizedSelectTodoById)

useLayoutEffect(() => {
listItemRenders++
})

useLayoutEffect(() => {
listItemMounts++
}, [])

return <li>{todo?.title}</li>
})

const TodoList = ({
selectTodoIds,
selectTodoById
}: {
selectTodoIds: SelectTodoIds
selectTodoById: SelectTodoById
}) => {
const todoIds = useAppSelector(selectTodoIds)

useLayoutEffect(() => {
listRenders++
})

return (
<ul>
{todoIds.map(id => (
<TodoListItem key={id} id={id} selectTodoById={selectTodoById} />
))}
</ul>
)
}

const testCases: [string, SelectTodoIds, SelectTodoById][] = [
['default', selectTodoIdsDefault, selectTodoByIdDefault],
[
'resultEquality',
selectTodoIdsResultEquality,
selectTodoByIdResultEquality
],
['weakMap', selectTodoIdsWeakMap, selectTodoByIdWeakMap] as any,

[
'weakMapResultEquality',
selectTodoIdsWeakMapResultEquality,
selectTodoByIdWeakMap
]
]

test.each(testCases)(
`%s`,
async (
name,
selectTodoIds: SelectTodoIds,
selectTodoById: SelectTodoById
) => {
selectTodoIds.resetRecomputations()
selectTodoIds.resetDependencyRecomputations()
selectTodoById.resetRecomputations()
selectTodoById.resetDependencyRecomputations()
selectTodoIds.memoizedResultFunc.resetResultsCount()
selectTodoById.memoizedResultFunc.resetResultsCount()

const numTodos = store.getState().todos.length
rtl.render(
<Provider store={store}>
<TodoList
selectTodoIds={selectTodoIds}
selectTodoById={selectTodoById}
/>
</Provider>
)

console.log(`Recomputations after render (${name}): `)
console.log('selectTodoIds: ')
logSelectorRecomputations(selectTodoIds)
console.log('selectTodoById: ')
logSelectorRecomputations(selectTodoById)

console.log('Render count: ', {
listRenders,
listItemRenders,
listItemMounts
})

expect(listItemRenders).toBe(numTodos)

rtl.act(() => {
store.dispatch(toggleCompleted(3))
})

console.log(`\nRecomputations after toggle completed (${name}): `)
console.log('selectTodoIds: ')
logSelectorRecomputations(selectTodoIds)
console.log('selectTodoById: ')
logSelectorRecomputations(selectTodoById)

console.log('Render count: ', {
listRenders,
listItemRenders,
listItemMounts
})

rtl.act(() => {
store.dispatch(addTodo({ title: 'a', description: 'b' }))
})

console.log(`\nRecomputations after added (${name}): `)
console.log('selectTodoIds: ')
logSelectorRecomputations(selectTodoIds)
console.log('selectTodoById: ')
logSelectorRecomputations(selectTodoById)

console.log('Render count: ', {
listRenders,
listItemRenders,
listItemMounts
})
}
)
})

0 comments on commit e99227e

Please sign in to comment.