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

Reduce identityFunctionCheck false positives #660

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/createSelectorCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,18 @@ export function createSelectorCreator<
arguments
)

// apply arguments instead of spreading for performance.
// @ts-ignore
lastResult = memoizedResultFunc.apply(null, inputSelectorResults)

if (process.env.NODE_ENV !== 'production') {
const { identityFunctionCheck, inputStabilityCheck } =
getDevModeChecksExecutionInfo(firstRun, devModeChecks)
if (identityFunctionCheck.shouldRun) {
identityFunctionCheck.run(
resultFunc as Combiner<InputSelectors, Result>
resultFunc as Combiner<InputSelectors, Result>,
inputSelectorResults,
lastResult
)
}

Expand All @@ -390,10 +396,6 @@ export function createSelectorCreator<
if (firstRun) firstRun = false
}

// apply arguments instead of spreading for performance.
// @ts-ignore
lastResult = memoizedResultFunc.apply(null, inputSelectorResults)

return lastResult
}, ...finalArgsMemoizeOptions) as unknown as Selector<
GetStateFromSelectors<InputSelectors>,
Expand Down
53 changes: 32 additions & 21 deletions src/devModeChecks/identityFunctionCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,45 @@ import type { AnyFunction } from '../types'
* extraction logic in input selectors.
*
* @param resultFunc - The result function to be checked.
* @param inputSelectorsResults - The results of the input selectors.
* @param outputSelectorResult - The result of the output selector.
*
* @see {@link https://reselect.js.org/api/development-only-stability-checks#identityfunctioncheck `identityFunctionCheck`}
*
* @since 5.0.0
* @internal
*/
export const runIdentityFunctionCheck = (resultFunc: AnyFunction) => {
let isInputSameAsOutput = false
try {
const emptyObject = {}
if (resultFunc(emptyObject) === emptyObject) isInputSameAsOutput = true
} catch {
// Do nothing
}
if (isInputSameAsOutput) {
let stack: string | undefined = undefined
export const runIdentityFunctionCheck = (
resultFunc: AnyFunction,
inputSelectorsResults: unknown[],
outputSelectorResult: unknown
) => {
if (
inputSelectorsResults.length === 1 &&
inputSelectorsResults[0] === outputSelectorResult
) {
let isInputSameAsOutput = false
try {
throw new Error()
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-extra-semi, no-extra-semi
;({ stack } = e as Error)
const emptyObject = {}
if (resultFunc(emptyObject) === emptyObject) isInputSameAsOutput = true
} catch {
// Do nothing
}
if (isInputSameAsOutput) {
let stack: string | undefined = undefined
try {
throw new Error()
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-extra-semi, no-extra-semi
;({ stack } = e as Error)
}
console.warn(
'The result function returned its own inputs without modification. e.g' +
'\n`createSelector([state => state.todos], todos => todos)`' +
'\nThis could lead to inefficient memoization and unnecessary re-renders.' +
'\nEnsure transformation logic is in the result function, and extraction logic is in the input selectors.',
{ stack }
)
}
console.warn(
'The result function returned its own inputs without modification. e.g' +
'\n`createSelector([state => state.todos], todos => todos)`' +
'\nThis could lead to inefficient memoization and unnecessary re-renders.' +
'\nEnsure transformation logic is in the result function, and extraction logic is in the input selectors.',
{ stack }
)
}
}
52 changes: 52 additions & 0 deletions test/identityFunctionCheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,56 @@ describe('identityFunctionCheck', () => {

expect(consoleSpy).toHaveBeenCalledOnce()
})

localTest(
'does not warn if result function is not identity function (case 1)',
({ state }) => {
// This test demonstrates why in some cases it can be useful to compare the first argument of the result
// function with the returned value (and not just checking for an identity function by passing `{}` to the result
// function).
const getFirstAlertIfMessageIsEmpty = createSelector(
[(state: RootState) => state.alerts[0]],
firstAlert => (!firstAlert.message ? firstAlert : null)
)

expect(getFirstAlertIfMessageIsEmpty(state)).toBeNull()

expect(consoleSpy).not.toHaveBeenCalled()
}
)

localTest(
'does not warn if result function is not identity function (case 2)',
({ state }) => {
// This test demonstrates why in some cases it can be useful to pass `{}` into the result function and compare it
// with the returned value (and not just checking for an identity function by passing the first argument to the
// result function).
const getFirstAlertIfMessageIsNotEmpty = createSelector(
[(state: RootState) => state.alerts[0]],
firstAlert => (firstAlert.message ? firstAlert : null)
)

expect(getFirstAlertIfMessageIsNotEmpty(state)).toBe(state.alerts[0])

expect(consoleSpy).not.toHaveBeenCalled()
}
)

localTest(
'does not warn if result function is passed more than one argument',
({ state }) => {
const getAllNotificationsIfSmsNotEnabled = createSelector(
[
(state: RootState) => state.alerts,
(state: RootState) =>
state.users.user.details.preferences.notifications.sms
],
(alerts, smsEnabled) => (!smsEnabled ? alerts : [])
)

expect(getAllNotificationsIfSmsNotEnabled(state)).toBe(state.alerts)

expect(consoleSpy).not.toHaveBeenCalled()
}
)
})
4 changes: 2 additions & 2 deletions test/lruMemoize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,12 @@ describe(lruMemoize, () => {
)

fooChangeHandler(state)
expect(fooChangeSpy.mock.calls.length).toEqual(2)
expect(fooChangeSpy.mock.calls.length).toEqual(1)

// no change
fooChangeHandler(state)
// this would fail
expect(fooChangeSpy.mock.calls.length).toEqual(2)
expect(fooChangeSpy.mock.calls.length).toEqual(1)

const state2 = { a: 1 }
let count = 0
Expand Down
2 changes: 1 addition & 1 deletion test/reselect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ describe('Basic selector behavior', () => {
)
expect(() => selector({ a: 1 })).toThrow('test error')
expect(() => selector({ a: 1 })).toThrow('test error')
expect(called).toBe(3)
expect(called).toBe(2)
})

test('memoizes previous result before exception', () => {
Expand Down
2 changes: 1 addition & 1 deletion test/weakmapMemoize.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ describe('Basic selector behavior with weakMapMemoize', () => {
)
expect(() => selector({ a: 1 })).toThrow('test error')
expect(() => selector({ a: 1 })).toThrow('test error')
expect(called).toBe(3)
expect(called).toBe(2)
})

test('memoizes previous result before exception', () => {
Expand Down
Loading