Skip to content

Commit 1a85288

Browse files
authored
Merge pull request #612 from reduxjs/stability-check
2 parents b7c0617 + afc8bd3 commit 1a85288

File tree

8 files changed

+389
-84
lines changed

8 files changed

+389
-84
lines changed

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ console.log(selectTotal(exampleState)) // { total: 2.322 }
8888
- [Customize `equalityCheck` for `defaultMemoize`](#customize-equalitycheck-for-defaultmemoize)
8989
- [Use memoize function from Lodash for an unbounded cache](#use-memoize-function-from-lodash-for-an-unbounded-cache)
9090
- [createStructuredSelector({inputSelectors}, selectorCreator = createSelector)](#createstructuredselectorinputselectors-selectorcreator--createselector)
91+
- [Development-only checks](#development-only-checks)
92+
- [`inputStabilityCheck`](#inputstabilitycheck)
93+
- [Global configuration](#global-configuration)
94+
- [Per-selector configuration](#per-selector-configuration)
9195
- [FAQ](#faq)
9296
- [Q: Why isn’t my selector recomputing when the input state changes?](#q-why-isnt-my-selector-recomputing-when-the-input-state-changes)
9397
- [Q: Why is my selector recomputing when the input state stays the same?](#q-why-is-my-selector-recomputing-when-the-input-state-stays-the-same)
@@ -309,6 +313,66 @@ const nestedSelector = createStructuredSelector({
309313
})
310314
```
311315

316+
## Development-only checks
317+
318+
### `inputStabilityCheck`
319+
320+
In development, an extra check is conducted on your input selectors. It runs your input selectors an extra time with the same parameters, and warns in console if they return a different result (based on your `memoize` method).
321+
322+
This is important, as an input selector returning a materially different result with the same parameters means that the output selector will be run unnecessarily, thus (potentially) creating a new result and causing rerenders.
323+
324+
```js
325+
const addNumbers = createSelector(
326+
// this input selector will always return a new reference when run
327+
// so cache will never be used
328+
(a, b) => ({ a, b }),
329+
({ a, b }) => ({ total: a + b })
330+
)
331+
// instead, you should have an input selector for each stable piece of data
332+
const addNumbersStable = createSelector(
333+
(a, b) => a,
334+
(a, b) => b,
335+
(a, b) => ({
336+
total: a + b
337+
})
338+
)
339+
```
340+
341+
By default, this will only happen when the selector is first called. You can configure the check globally or per selector, to change it to always run when the selector is called, or to never run.
342+
343+
_This check is disabled for production environments._
344+
345+
#### Global configuration
346+
347+
A `setInputStabilityCheckEnabled` function is exported from `reselect`, which should be called with the desired setting.
348+
349+
```js
350+
import { setInputStabilityCheckEnabled } from 'reselect'
351+
352+
// run when selector is first called (default)
353+
setInputStabilityCheckEnabled('once')
354+
355+
// always run
356+
setInputStabilityCheckEnabled(true)
357+
358+
// never run
359+
setInputStabilityCheckEnabled(false)
360+
```
361+
362+
#### Per-selector configuration
363+
364+
A value can be passed as part of the selector options object, which will override the global setting for the given selector.
365+
366+
```ts
367+
const selectPersonName = createSelector(
368+
selectPerson,
369+
person => person.firstName + ' ' + person.lastName,
370+
// `inputStabilityCheck` accepts the same settings
371+
// as `setInputStabilityCheckEnabled`
372+
{ inputStabilityCheck: false }
373+
)
374+
```
375+
312376
## FAQ
313377

314378
### Q: Why isn’t my selector recomputing when the input state changes?

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@
6767
"memoize-one": "^6.0.0",
6868
"micro-memoize": "^4.0.9",
6969
"prettier": "^2.7.1",
70+
"react": "^18.2.0",
71+
"react-dom": "^18.2.0",
7072
"react-redux": "^8.0.2",
7173
"rimraf": "^3.0.2",
7274
"shelljs": "^0.8.5",

src/index.ts

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export { defaultMemoize, defaultEqualityCheck }
3838

3939
export type { DefaultMemoizeOptions }
4040

41+
type StabilityCheck = 'always' | 'once' | 'never'
42+
43+
let globalStabilityCheck: StabilityCheck = 'once'
44+
45+
export function setInputStabilityCheckEnabled(enabled: StabilityCheck) {
46+
globalStabilityCheck = enabled
47+
}
48+
4149
function getDependencies(funcs: unknown[]) {
4250
const dependencies = Array.isArray(funcs[0]) ? funcs[0] : funcs
4351

@@ -76,9 +84,7 @@ export function createSelectorCreator<
7684
// Due to the intricacies of rest params, we can't do an optional arg after `...funcs`.
7785
// So, start by declaring the default value here.
7886
// (And yes, the words 'memoize' and 'options' appear too many times in this next sequence.)
79-
let directlyPassedOptions: CreateSelectorOptions<MemoizeOptions> = {
80-
memoizeOptions: undefined
81-
}
87+
let directlyPassedOptions: CreateSelectorOptions<MemoizeOptions> = {}
8288

8389
// Normally, the result func or "output selector" is the last arg
8490
let resultFunc = funcs.pop()
@@ -98,7 +104,8 @@ export function createSelectorCreator<
98104

99105
// Determine which set of options we're using. Prefer options passed directly,
100106
// but fall back to options given to createSelectorCreator.
101-
const { memoizeOptions = memoizeOptionsFromArgs } = directlyPassedOptions
107+
const { memoizeOptions = memoizeOptionsFromArgs, inputStabilityCheck } =
108+
directlyPassedOptions
102109

103110
// Simplifying assumption: it's unlikely that the first options arg of the provided memoizer
104111
// is an array. In most libs I've looked at, it's an equality function or options object.
@@ -120,6 +127,15 @@ export function createSelectorCreator<
120127
...finalMemoizeOptions
121128
)
122129

130+
// @ts-ignore
131+
const makeAnObject: (...args: unknown[]) => object = memoize(
132+
// @ts-ignore
133+
() => ({}),
134+
...finalMemoizeOptions
135+
)
136+
137+
let firstRun = true
138+
123139
// If a selector is called with the exact same arguments we don't need to traverse our dependencies again.
124140
// TODO This was changed to `memoize` in 4.0.0 ( #297 ), but I changed it back.
125141
// The original intent was to allow customizing things like skortchmark's
@@ -131,16 +147,54 @@ export function createSelectorCreator<
131147
// TODO Rethink this change, or find a way to expose more options?
132148
const selector = defaultMemoize(function dependenciesChecker() {
133149
const params = []
134-
const length = dependencies.length
150+
const { length } = dependencies
135151

136152
for (let i = 0; i < length; i++) {
137153
// apply arguments instead of spreading and mutate a local list of params for performance.
138154
// @ts-ignore
139155
params.push(dependencies[i].apply(null, arguments))
140156
}
141157

158+
const finalStabilityCheck = inputStabilityCheck ?? globalStabilityCheck
159+
160+
if (
161+
process.env.NODE_ENV !== 'production' &&
162+
(finalStabilityCheck === 'always' ||
163+
(finalStabilityCheck === 'once' && firstRun))
164+
) {
165+
const paramsCopy = []
166+
167+
for (let i = 0; i < length; i++) {
168+
// make a second copy of the params, to check if we got the same results
169+
// @ts-ignore
170+
paramsCopy.push(dependencies[i].apply(null, arguments))
171+
}
172+
173+
// if the memoize method thinks the parameters are equal, these *should* be the same reference
174+
const equal =
175+
makeAnObject.apply(null, params) ===
176+
makeAnObject.apply(null, paramsCopy)
177+
if (!equal) {
178+
// do we want to log more information about the selector?
179+
console.warn(
180+
'An input selector returned a different result when passed same arguments.' +
181+
'\nThis means your output selector will likely run more frequently than intended.' +
182+
'\nAvoid returning a new reference inside your input selector, e.g.' +
183+
'\n`createSelector([(arg1, arg2) => ({ arg1, arg2 })],(arg1, arg2) => {})`',
184+
{
185+
arguments,
186+
firstInputs: params,
187+
secondInputs: paramsCopy
188+
}
189+
)
190+
}
191+
192+
if (firstRun) firstRun = false
193+
}
194+
142195
// apply arguments instead of spreading for performance.
143196
lastResult = memoizedResultFunc.apply(null, params)
197+
144198
return lastResult
145199
} as F)
146200

@@ -164,7 +218,8 @@ export function createSelectorCreator<
164218
}
165219

166220
export interface CreateSelectorOptions<MemoizeOptions extends unknown[]> {
167-
memoizeOptions: MemoizeOptions[0] | MemoizeOptions
221+
memoizeOptions?: MemoizeOptions[0] | MemoizeOptions
222+
inputStabilityCheck?: StabilityCheck
168223
}
169224

170225
/**

test/autotrackMemoize.spec.ts

Lines changed: 49 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -92,52 +92,63 @@ describe('Basic selector behavior with autotrack', () => {
9292
)
9393
})
9494

95-
test('basic selector cache hit performance', () => {
96-
if (process.env.COVERAGE) {
97-
return // don't run performance tests for coverage
98-
}
95+
describe('performance checks', () => {
96+
const originalEnv = process.env.NODE_ENV
97+
98+
beforeAll(() => {
99+
process.env.NODE_ENV = 'production'
100+
})
101+
afterAll(() => {
102+
process.env.NODE_NV = originalEnv
103+
})
104+
105+
test('basic selector cache hit performance', () => {
106+
if (process.env.COVERAGE) {
107+
return // don't run performance tests for coverage
108+
}
99109

100-
const selector = createSelector(
101-
(state: StateAB) => state.a,
102-
(state: StateAB) => state.b,
103-
(a, b) => a + b
104-
)
105-
const state1 = { a: 1, b: 2 }
110+
const selector = createSelector(
111+
(state: StateAB) => state.a,
112+
(state: StateAB) => state.b,
113+
(a, b) => a + b
114+
)
115+
const state1 = { a: 1, b: 2 }
106116

107-
const start = performance.now()
108-
for (let i = 0; i < 1000000; i++) {
109-
selector(state1)
110-
}
111-
const totalTime = performance.now() - start
117+
const start = performance.now()
118+
for (let i = 0; i < 1000000; i++) {
119+
selector(state1)
120+
}
121+
const totalTime = performance.now() - start
112122

113-
expect(selector(state1)).toBe(3)
114-
expect(selector.recomputations()).toBe(1)
115-
// Expected a million calls to a selector with the same arguments to take less than 1 second
116-
expect(totalTime).toBeLessThan(1000)
117-
})
123+
expect(selector(state1)).toBe(3)
124+
expect(selector.recomputations()).toBe(1)
125+
// Expected a million calls to a selector with the same arguments to take less than 1 second
126+
expect(totalTime).toBeLessThan(1000)
127+
})
118128

119-
test('basic selector cache hit performance for state changes but shallowly equal selector args', () => {
120-
if (process.env.COVERAGE) {
121-
return // don't run performance tests for coverage
122-
}
129+
test('basic selector cache hit performance for state changes but shallowly equal selector args', () => {
130+
if (process.env.COVERAGE) {
131+
return // don't run performance tests for coverage
132+
}
123133

124-
const selector = createSelector(
125-
(state: StateAB) => state.a,
126-
(state: StateAB) => state.b,
127-
(a, b) => a + b
128-
)
134+
const selector = createSelector(
135+
(state: StateAB) => state.a,
136+
(state: StateAB) => state.b,
137+
(a, b) => a + b
138+
)
129139

130-
const start = performance.now()
131-
for (let i = 0; i < 1000000; i++) {
132-
selector(states[i])
133-
}
134-
const totalTime = performance.now() - start
140+
const start = performance.now()
141+
for (let i = 0; i < 1000000; i++) {
142+
selector(states[i])
143+
}
144+
const totalTime = performance.now() - start
135145

136-
expect(selector(states[0])).toBe(3)
137-
expect(selector.recomputations()).toBe(1)
146+
expect(selector(states[0])).toBe(3)
147+
expect(selector.recomputations()).toBe(1)
138148

139-
// Expected a million calls to a selector with the same arguments to take less than 1 second
140-
expect(totalTime).toBeLessThan(1000)
149+
// Expected a million calls to a selector with the same arguments to take less than 1 second
150+
expect(totalTime).toBeLessThan(1000)
151+
})
141152
})
142153

143154
test('memoized composite arguments', () => {

0 commit comments

Comments
 (0)