Description
Hi there!
I think I've uncovered a typing bug with this library, where selectors return shared mutable copies of their datasets.
Basically, if you return Immutable state directly from redux the result is immutable (the store can't be mutated, which is good). But if you return a mutable version of that array (for example, filtering store data) a mutable copy is returned.
This is problematic because that mutable copy is memoized and then used for any other usages of that selector. So one component can break the state of all the components using that selector with the same inputs!
Here is a reproduction sandbox: https://codesandbox.io/p/sandbox/amazing-curran-cfvj53?file=%2Fsrc%2FApp.js%3A16%2C32
We were able to make a patch fix on our end that resolved this issue quite nicely for us, by changing Result
to Immutable<Result>
.
diff --git a/node_modules/reselect/es/index.d.ts b/node_modules/reselect/es/index.d.ts
index 944961d..b1e087c 100644
--- a/node_modules/reselect/es/index.d.ts
+++ b/node_modules/reselect/es/index.d.ts
@@ -3,6 +3,49 @@ export type { Selector, GetParamsFromSelectors, GetStateFromSelectors, OutputSel
import { defaultMemoize, defaultEqualityCheck, DefaultMemoizeOptions } from './defaultMemoize';
export { defaultMemoize, defaultEqualityCheck };
export type { DefaultMemoizeOptions };
+
+/** Imported from Immer to avoid having an implicit dependency in this patch */
+type AnyFunc = (...args: any[]) => any
+type PrimitiveType = number | string | boolean
+type AtomicObject = Function | Promise<any> | Date | RegExp
+export type IfAvailable<T, Fallback = void> =
+ // fallback if any
+ true | false extends (T extends never
+ ? true
+ : false)
+ ? Fallback // fallback if empty type
+ : keyof T extends never
+ ? Fallback // original type
+ : T
+type WeakReferences = IfAvailable<WeakMap<any, any>> | IfAvailable<WeakSet<any>>
+export type WritableDraft<T> = {-readonly [K in keyof T]: Draft<T[K]>}
+export type Draft<T> = T extends PrimitiveType
+ ? T
+ : T extends AtomicObject
+ ? T
+ : T extends IfAvailable<ReadonlyMap<infer K, infer V>> // Map extends ReadonlyMap
+ ? Map<Draft<K>, Draft<V>>
+ : T extends IfAvailable<ReadonlySet<infer V>> // Set extends ReadonlySet
+ ? Set<Draft<V>>
+ : T extends WeakReferences
+ ? T
+ : T extends object
+ ? WritableDraft<T>
+ : T
+export type Immutable<T> = T extends PrimitiveType
+ ? T
+ : T extends AtomicObject
+ ? T
+ : T extends IfAvailable<ReadonlyMap<infer K, infer V>> // Map extends ReadonlyMap
+ ? ReadonlyMap<Immutable<K>, Immutable<V>>
+ : T extends IfAvailable<ReadonlySet<infer V>> // Set extends ReadonlySet
+ ? ReadonlySet<Immutable<V>>
+ : T extends WeakReferences
+ ? T
+ : T extends object
+ ? {readonly [K in keyof T]: Immutable<T[K]>}
+ : T
+
export declare function createSelectorCreator<
/** Selectors will eventually accept some function to be memoized */
F extends (...args: unknown[]) => unknown,
@@ -20,16 +63,16 @@ export interface CreateSelectorFunction<F extends (...args: unknown[]) => unknow
/** Input selectors as separate inline arguments */
<Selectors extends SelectorArray, Result>(...items: [
...Selectors,
- (...args: SelectorResultArray<Selectors>) => Result
- ]): OutputSelector<Selectors, Result, (...args: SelectorResultArray<Selectors>) => Result, GetParamsFromSelectors<Selectors>, Keys> & Keys;
+ (...args: SelectorResultArray<Selectors>) => Immutable<Result>
+ ]): OutputSelector<Selectors, Immutable<Result>, (...args: SelectorResultArray<Selectors>) => Result, GetParamsFromSelectors<Selectors>, Keys> & Keys;
/** Input selectors as separate inline arguments with memoizeOptions passed */
<Selectors extends SelectorArray, Result>(...items: [
...Selectors,
- (...args: SelectorResultArray<Selectors>) => Result,
+ (...args: SelectorResultArray<Selectors>) => Immutable<Result>,
CreateSelectorOptions<MemoizeOptions>
- ]): OutputSelector<Selectors, Result, ((...args: SelectorResultArray<Selectors>) => Result), GetParamsFromSelectors<Selectors>, Keys> & Keys;
+ ]): OutputSelector<Selectors, Immutable<Result>, ((...args: SelectorResultArray<Selectors>) => Immutable<Result>), GetParamsFromSelectors<Selectors>, Keys> & Keys;
/** Input selectors as a separate array */
- <Selectors extends SelectorArray, Result>(selectors: [...Selectors], combiner: (...args: SelectorResultArray<Selectors>) => Result, options?: CreateSelectorOptions<MemoizeOptions>): OutputSelector<Selectors, Result, (...args: SelectorResultArray<Selectors>) => Result, GetParamsFromSelectors<Selectors>, Keys> & Keys;
+ <Selectors extends SelectorArray, Result>(selectors: [...Selectors], combiner: (...args: SelectorResultArray<Selectors>) => Immutable<Result>, options?: CreateSelectorOptions<MemoizeOptions>): OutputSelector<Selectors, Immutable<Result>, (...args: SelectorResultArray<Selectors>) => Result, GetParamsFromSelectors<Selectors>, Keys> & Keys;
}
export declare const createSelector: CreateSelectorFunction<(...args: unknown[]) => unknown, typeof defaultMemoize, [equalityCheckOrOptions?: import("./types").EqualityFn | DefaultMemoizeOptions | undefined], {
clearCache: () => void;
Other than the fact that it's a breaking change requiring a lot of readonly
additions to codebases, I don't think there is any inherent risk here as these selector return values should not be modified. We found that a lot of our code had inconsistent types, depending on whether their selectors returned immutable values straight from the store, or filtered mutable values from selectors.
I will make this diff into a PR on the repo, but wanted to mark this as an issue beforehand to gauge reactions.
Edit: Just to note, I am using reselect v4 here as we have not migrated yet. I didn't see this as a breaking change in the "What's New in 5.0.0?" so it is likely still an issue.