reselect-kit 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +307 -36
  2. package/dist/cjs/reselectKit.development.cjs +256 -95
  3. package/dist/cjs/reselectKit.development.cjs.map +1 -1
  4. package/dist/cjs/reselectKit.production.min.cjs +1 -1
  5. package/dist/cjs/reselectKit.production.min.cjs.map +1 -1
  6. package/dist/reselectKit.d.ts +201 -92
  7. package/dist/reselectKit.mjs +257 -97
  8. package/dist/reselectKit.mjs.map +1 -1
  9. package/package.json +6 -6
  10. package/src/__tests__/createAdaptedSelector.test.ts +3 -4
  11. package/src/__tests__/createBoundSelector.test.ts +98 -5
  12. package/src/__tests__/createChainSelector.bench.ts +117 -0
  13. package/src/__tests__/createChainSelector.test.ts +162 -6
  14. package/src/__tests__/createPathSelector.test.ts +14 -0
  15. package/src/__tests__/createSequenceSelector.test.ts +10 -14
  16. package/src/_helpers/__tests__/generateSelectorKey.test.ts +24 -0
  17. package/src/_helpers/__tests__/getSelectorName.test.ts +30 -0
  18. package/src/_helpers/unfreezeProps.ts +14 -0
  19. package/src/_hooks/__tests__/useSelector.bench.ts +133 -0
  20. package/src/_hooks/__tests__/useSelector.test.ts +69 -0
  21. package/src/_hooks/__tests__/useSelectors.bench.ts +160 -0
  22. package/src/_hooks/__tests__/useSelectors.test.ts +219 -0
  23. package/src/_hooks/selectorOptions.ts +11 -0
  24. package/src/_hooks/useSelector.ts +68 -26
  25. package/src/_hooks/useSelectors.ts +162 -0
  26. package/src/cache/__tests__/intervalMapCache.test.ts +11 -0
  27. package/src/createAdaptedSelector.ts +9 -5
  28. package/src/createBoundSelector.ts +92 -84
  29. package/src/createCachedSequenceSelector.ts +12 -8
  30. package/src/createCachedStructuredSelector.ts +11 -20
  31. package/src/createChainSelector.ts +134 -89
  32. package/src/createPathSelector.ts +71 -104
  33. package/src/createPropSelector.ts +9 -8
  34. package/src/createSequenceSelector.ts +14 -9
  35. package/src/index.ts +2 -0
  36. package/src/keys/createKeySelectorComposer.ts +12 -23
  37. package/src/types.ts +115 -7
@@ -0,0 +1,160 @@
1
+ import { createElement, Fragment, ReactNode } from 'react';
2
+ import { bench, describe } from 'vitest';
3
+ import { cleanup, render } from '@testing-library/react';
4
+ import { Provider } from '@veksa/react-redux';
5
+ import { useSelector } from '../useSelector';
6
+ import { useSelectors } from '../useSelectors';
7
+ import { createCachedStructuredSelector } from '../../createCachedStructuredSelector';
8
+
9
+ type Quote = {
10
+ bid: number;
11
+ ask: number;
12
+ last: number;
13
+ high: number;
14
+ low: number;
15
+ volume: number;
16
+ };
17
+
18
+ type State = { quotes: Record<string, Quote> };
19
+ type Props = { symbol: string };
20
+
21
+ const StoreProvider = Provider as unknown as (props: {
22
+ store: unknown;
23
+ children?: ReactNode;
24
+ }) => ReactNode;
25
+
26
+ // A quote table: one row per symbol, seven fields read per row.
27
+ const ROWS = 100;
28
+
29
+ const symbols = Array.from({ length: ROWS }, (_, index) => `SYM${index}`);
30
+
31
+ const state: State = {
32
+ quotes: Object.fromEntries(
33
+ symbols.map((symbol, index) => [
34
+ symbol,
35
+ { bid: index, ask: index + 1, last: index, high: index + 2, low: index - 2, volume: index },
36
+ ]),
37
+ ),
38
+ };
39
+
40
+ const store = {
41
+ getState: () => state,
42
+ dispatch: (action: unknown) => action,
43
+ subscribe: () => () => undefined,
44
+ replaceReducer: () => undefined,
45
+ } as unknown as never;
46
+
47
+ const selectSymbol = (_state: State, props: Props) => props.symbol;
48
+ const selectBid = (state: State, props: Props) => state.quotes[props.symbol].bid;
49
+ const selectAsk = (state: State, props: Props) => state.quotes[props.symbol].ask;
50
+ const selectLast = (state: State, props: Props) => state.quotes[props.symbol].last;
51
+ const selectHigh = (state: State, props: Props) => state.quotes[props.symbol].high;
52
+ const selectLow = (state: State, props: Props) => state.quotes[props.symbol].low;
53
+ const selectVolume = (state: State, props: Props) => state.quotes[props.symbol].volume;
54
+
55
+ const fields = {
56
+ symbol: selectSymbol,
57
+ bid: selectBid,
58
+ ask: selectAsk,
59
+ last: selectLast,
60
+ high: selectHigh,
61
+ low: selectLow,
62
+ volume: selectVolume,
63
+ };
64
+
65
+ // The alternative that already exists: one module-level cached structured
66
+ // selector, read through a single `useSelector`. One subscription per row too,
67
+ // but the memoization is shared by every row and keyed by symbol.
68
+ const selectQuoteView = createCachedStructuredSelector(fields)(
69
+ (_state: State, props: Props) => props.symbol,
70
+ );
71
+
72
+ const HooksRow = ({ symbol }: Props) => {
73
+ const props = { symbol };
74
+
75
+ useSelector(selectSymbol, props);
76
+ useSelector(selectBid, props);
77
+ useSelector(selectAsk, props);
78
+ useSelector(selectLast, props);
79
+ useSelector(selectHigh, props);
80
+ useSelector(selectLow, props);
81
+ useSelector(selectVolume, props);
82
+
83
+ return null;
84
+ };
85
+
86
+ const BatchRow = ({ symbol }: Props) => {
87
+ useSelectors(fields, { symbol });
88
+
89
+ return null;
90
+ };
91
+
92
+ const StructuredRow = ({ symbol }: Props) => {
93
+ useSelector(selectQuoteView, { symbol });
94
+
95
+ return null;
96
+ };
97
+
98
+ const createTree = (Row: (props: Props) => ReactNode) => {
99
+ return ({ generation }: { generation: number }) => {
100
+ const children: ReactNode[] = [];
101
+
102
+ for (let index = 0; index < ROWS; index++) {
103
+ children.push(createElement(Row, { key: index, symbol: symbols[index] }));
104
+ }
105
+
106
+ return createElement(
107
+ StoreProvider,
108
+ { store },
109
+ createElement(Fragment, { key: generation }, ...children),
110
+ );
111
+ };
112
+ };
113
+
114
+ const trees = {
115
+ 'useSelector x7': createTree(HooksRow),
116
+ useSelectors: createTree(BatchRow),
117
+ 'structured selector': createTree(StructuredRow),
118
+ };
119
+
120
+ const benchRerender = (name: string, Tree: (props: { generation: number }) => ReactNode) => {
121
+ let mounted: ReturnType<typeof render> | null = null;
122
+ let generation = 0;
123
+
124
+ bench(
125
+ name,
126
+ () => {
127
+ // The key is stable: the same rows re-render, nothing remounts.
128
+ mounted!.rerender(createElement(Tree, { generation: 0 }));
129
+ generation++;
130
+ },
131
+ {
132
+ setup: () => {
133
+ mounted = render(createElement(Tree, { generation }));
134
+ },
135
+ teardown: () => {
136
+ cleanup();
137
+ mounted = null;
138
+ },
139
+ },
140
+ );
141
+ };
142
+
143
+ // A parent re-render of the whole table: no state change, no props change, so
144
+ // nothing recomputes. This is subscription and hook overhead only.
145
+ describe(`re-render ${ROWS} rows x 7 fields`, () => {
146
+ for (const [name, Tree] of Object.entries(trees)) {
147
+ benchRerender(name, Tree);
148
+ }
149
+ });
150
+
151
+ // Mount pays for the subscriptions themselves: 700 store listeners for the
152
+ // per-field variant against 100 for the other two.
153
+ describe(`mount ${ROWS} rows x 7 fields`, () => {
154
+ for (const [name, Tree] of Object.entries(trees)) {
155
+ bench(name, () => {
156
+ render(createElement(Tree, { generation: 0 }));
157
+ cleanup();
158
+ });
159
+ }
160
+ });
@@ -0,0 +1,219 @@
1
+ import { createElement, ReactNode } from 'react';
2
+ import { describe, expect, test, vi } from 'vitest';
3
+ import { act, renderHook } from '@testing-library/react';
4
+ import { Provider } from '@veksa/react-redux';
5
+ import { useSelectors } from '../useSelectors';
6
+
7
+ // `children` is passed positionally (oxlint forbids it as a prop), so relax the
8
+ // Provider's prop type to make `children` optional in the props object.
9
+ const StoreProvider = Provider as unknown as (props: {
10
+ store: unknown;
11
+ children?: ReactNode;
12
+ }) => ReactNode;
13
+
14
+ type Quote = { bid: number; ask: number };
15
+ type State = { quotes: Record<string, Quote>; ui: { theme: string } };
16
+ type Props = { symbol: string };
17
+
18
+ const initialState: State = {
19
+ quotes: { EURUSD: { bid: 1, ask: 2 } },
20
+ ui: { theme: 'dark' },
21
+ };
22
+
23
+ const createMockStore = (initial: State) => {
24
+ const listeners = new Set<() => void>();
25
+ let state = initial;
26
+
27
+ const store = {
28
+ getState: () => state,
29
+ dispatch: (next: State) => {
30
+ state = next;
31
+ listeners.forEach((listener) => listener());
32
+ return next;
33
+ },
34
+ subscribe: (listener: () => void) => {
35
+ listeners.add(listener);
36
+ return () => listeners.delete(listener);
37
+ },
38
+ replaceReducer: () => undefined,
39
+ };
40
+
41
+ return store;
42
+ };
43
+
44
+ const wrapperWith =
45
+ (store: unknown) =>
46
+ ({ children }: { children: ReactNode }) =>
47
+ createElement(StoreProvider, { store }, children);
48
+
49
+ // react-redux notifies its listeners through a MessageChannel, so a dispatch
50
+ // only reaches the components after the next macrotask.
51
+ const dispatch = async (store: ReturnType<typeof createMockStore>, next: State) => {
52
+ await act(async () => {
53
+ store.dispatch(next);
54
+ await new Promise((resolve) => setTimeout(resolve, 0));
55
+ });
56
+ };
57
+
58
+ const selectSymbol = (_state: State, props: Props) => props.symbol;
59
+ const selectBid = (state: State, props: Props) => state.quotes[props.symbol].bid;
60
+ const selectAsk = (state: State, props: Props) => state.quotes[props.symbol].ask;
61
+
62
+ describe('useSelectors', () => {
63
+ test('returns every selector result keyed by its name', () => {
64
+ const store = createMockStore(initialState);
65
+
66
+ const { result } = renderHook(
67
+ () =>
68
+ useSelectors(
69
+ { symbol: selectSymbol, bid: selectBid, ask: selectAsk },
70
+ { symbol: 'EURUSD' },
71
+ ),
72
+ { wrapper: wrapperWith(store) },
73
+ );
74
+
75
+ expect(result.current).toEqual({ symbol: 'EURUSD', bid: 1, ask: 2 });
76
+ });
77
+
78
+ test('keeps the result reference across renders that changed nothing', () => {
79
+ const store = createMockStore(initialState);
80
+
81
+ const { result, rerender } = renderHook(
82
+ ({ props }) => useSelectors({ bid: selectBid, ask: selectAsk }, props),
83
+ {
84
+ wrapper: wrapperWith(store),
85
+ initialProps: { props: { symbol: 'EURUSD' } },
86
+ },
87
+ );
88
+
89
+ const first = result.current;
90
+ // A new-but-shallow-equal props object must not rebuild the batch.
91
+ rerender({ props: { symbol: 'EURUSD' } });
92
+
93
+ expect(result.current).toBe(first);
94
+ });
95
+
96
+ test('does not re-render when the update leaves every field untouched', async () => {
97
+ const store = createMockStore(initialState);
98
+ const renders = vi.fn();
99
+
100
+ const { result } = renderHook(
101
+ () => {
102
+ renders();
103
+ return useSelectors({ bid: selectBid, ask: selectAsk }, { symbol: 'EURUSD' });
104
+ },
105
+ { wrapper: wrapperWith(store) },
106
+ );
107
+
108
+ const first = result.current;
109
+ const rendersAfterMount = renders.mock.calls.length;
110
+
111
+ await dispatch(store, { ...initialState, ui: { theme: 'light' } });
112
+
113
+ expect(renders.mock.calls.length).toBe(rendersAfterMount);
114
+ expect(result.current).toBe(first);
115
+ });
116
+
117
+ test('keeps the result object when a tracked change leaves every value equal', async () => {
118
+ const store = createMockStore(initialState);
119
+ const bid = vi.fn(selectBid);
120
+ const ask = vi.fn(selectAsk);
121
+
122
+ const { result } = renderHook(() => useSelectors({ bid, ask }, { symbol: 'EURUSD' }), {
123
+ wrapper: wrapperWith(store),
124
+ });
125
+
126
+ const first = result.current;
127
+ const callsAfterMount = bid.mock.calls.length;
128
+
129
+ // A new `quotes` object holding the same numbers: the batch re-runs, because
130
+ // the segment it reads changed identity, but nothing it produced did — so
131
+ // the previous result object is handed back rather than an equal new one.
132
+ await dispatch(store, { ...initialState, quotes: { EURUSD: { bid: 1, ask: 2 } } });
133
+
134
+ expect(bid.mock.calls.length).toBeGreaterThan(callsAfterMount);
135
+ expect(ask.mock.calls.length).toBeGreaterThan(callsAfterMount);
136
+ expect(result.current).toBe(first);
137
+ });
138
+
139
+ test('rebuilds the result when a single field changes', async () => {
140
+ const store = createMockStore(initialState);
141
+
142
+ const { result } = renderHook(
143
+ () => useSelectors({ bid: selectBid, ask: selectAsk }, { symbol: 'EURUSD' }),
144
+ { wrapper: wrapperWith(store) },
145
+ );
146
+
147
+ const first = result.current;
148
+
149
+ await dispatch(store, { ...initialState, quotes: { EURUSD: { bid: 3, ask: 2 } } });
150
+
151
+ expect(result.current).not.toBe(first);
152
+ expect(result.current).toEqual({ bid: 3, ask: 2 });
153
+ });
154
+
155
+ test('re-runs the batch when props change without a state change', () => {
156
+ const store = createMockStore({
157
+ quotes: { EURUSD: { bid: 1, ask: 2 }, GBPUSD: { bid: 10, ask: 20 } },
158
+ ui: { theme: 'dark' },
159
+ });
160
+
161
+ const { result, rerender } = renderHook(
162
+ ({ props }) => useSelectors({ bid: selectBid, ask: selectAsk }, props),
163
+ {
164
+ wrapper: wrapperWith(store),
165
+ initialProps: { props: { symbol: 'EURUSD' } },
166
+ },
167
+ );
168
+
169
+ expect(result.current).toEqual({ bid: 1, ask: 2 });
170
+
171
+ rerender({ props: { symbol: 'GBPUSD' } });
172
+
173
+ expect(result.current).toEqual({ bid: 10, ask: 20 });
174
+ });
175
+
176
+ test('picks up a replaced selector in the batch', () => {
177
+ const store = createMockStore(initialState);
178
+
179
+ const { result, rerender } = renderHook(
180
+ ({ factor }) =>
181
+ useSelectors(
182
+ { bid: (state: State, props: Props) => state.quotes[props.symbol].bid * factor },
183
+ { symbol: 'EURUSD' },
184
+ ),
185
+ {
186
+ wrapper: wrapperWith(store),
187
+ initialProps: { factor: 1 },
188
+ },
189
+ );
190
+
191
+ expect(result.current).toEqual({ bid: 1 });
192
+
193
+ rerender({ factor: 10 });
194
+
195
+ expect(result.current).toEqual({ bid: 10 });
196
+ });
197
+
198
+ test('unfreezes frozen props before passing them to the selectors', () => {
199
+ const store = createMockStore(initialState);
200
+ let receivedProps: Props | undefined;
201
+
202
+ renderHook(
203
+ () =>
204
+ useSelectors(
205
+ {
206
+ bid: (state: State, props: Props) => {
207
+ receivedProps = props;
208
+ return state.quotes[props.symbol].bid;
209
+ },
210
+ },
211
+ Object.freeze({ symbol: 'EURUSD' }),
212
+ ),
213
+ { wrapper: wrapperWith(store) },
214
+ );
215
+
216
+ expect(Object.isFrozen(receivedProps)).toBe(false);
217
+ expect(receivedProps?.symbol).toBe('EURUSD');
218
+ });
219
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Hoisted: react-redux reads the options object on every render, so there is no
3
+ * reason to allocate a fresh one per call site per render. The dev-mode checks
4
+ * re-run the selector to compare results, which these hooks control themselves.
5
+ */
6
+ export const selectorOptions = {
7
+ devModeChecks: {
8
+ stabilityCheck: 'never',
9
+ identityFunctionCheck: 'never',
10
+ },
11
+ } as const;
@@ -1,34 +1,76 @@
1
1
  import { shallowEqual, useSelector as useReduxSelector } from '@veksa/react-redux';
2
- import { useMemoWith } from './useMemoWith';
3
- import { useCallback, useMemo } from 'react';
2
+ import { useRef } from 'react';
3
+ import { unfreezeProps } from '../_helpers/unfreezeProps';
4
+ import { selectorOptions } from './selectorOptions';
4
5
 
5
- export function useSelector<S extends object, R>(selector: (state: S) => R): R;
6
+ type SelectorCache = {
7
+ props: unknown;
8
+ unfrozenProps: unknown;
9
+ selector: Function;
10
+ boundSelector: (state: unknown) => unknown;
11
+ };
6
12
 
7
- export function useSelector<S extends object, P, R>(
8
- selector: (state: S, props: P) => R,
9
- props: P,
13
+ /**
14
+ * Reads one selector from the store. Props are compared shallowly, so an inline
15
+ * object literal does not invalidate the selector on every render.
16
+ *
17
+ * A single variadic signature covers both the plain and the parametric case:
18
+ * `Params` is inferred from the selector itself and is empty when the selector
19
+ * takes nothing but `state`, so the props argument is required exactly when the
20
+ * selector declares one. `NoInfer` keeps the props argument out of that
21
+ * inference -- otherwise an inline object literal would widen `Params` instead
22
+ * of being checked against what the selector declares. It defaults to empty so
23
+ * that `useSelector<State, Result>(selector)` -- the call site of the former
24
+ * plain overload -- keeps compiling without spelling the tuple out.
25
+ *
26
+ * Use {@link useSelectors} to read several selectors through a single store
27
+ * subscription.
28
+ */
29
+ export function useSelector<S extends object, R, Params extends readonly any[] = []>(
30
+ selector: (state: S, ...params: Params) => R,
31
+ ...params: NoInfer<Params>
10
32
  ): R;
11
33
 
12
34
  export function useSelector(selector: Function, ...params: any[]) {
13
35
  const props = params[0];
14
- const memoizedProps = useMemoWith(props, shallowEqual);
15
-
16
- // unfreeze props for performance optimization (see temporaryAssign.ts)
17
- const unfrozenProps = useMemo(() => {
18
- return Object.isFrozen(memoizedProps) ? { ...memoizedProps } : memoizedProps;
19
- }, [memoizedProps]);
20
-
21
- const memoizedPropsSelector = useCallback(
22
- (state: unknown) => {
23
- return selector(state, unfrozenProps);
24
- },
25
- [selector, unfrozenProps],
26
- );
27
-
28
- return useReduxSelector(memoizedPropsSelector, {
29
- devModeChecks: {
30
- stabilityCheck: 'never',
31
- identityFunctionCheck: 'never',
32
- },
33
- });
36
+
37
+ // One ref cell instead of a memo-by-comparator hook, a `useMemo` and a
38
+ // `useCallback`: three hook slots and two dependency arrays per call site,
39
+ // none of which this hook needs — every transition it cares about is an
40
+ // explicit comparison it already performs.
41
+ const cacheRef = useRef<SelectorCache | null>(null);
42
+
43
+ let cache = cacheRef.current;
44
+
45
+ if (cache === null) {
46
+ const unfrozenProps = unfreezeProps(props);
47
+
48
+ cache = cacheRef.current = {
49
+ props,
50
+ unfrozenProps,
51
+ selector,
52
+ boundSelector: (state: unknown) => selector(state, unfrozenProps),
53
+ };
54
+ } else {
55
+ // A new-but-shallow-equal props object keeps the previous reference, so the
56
+ // bound selector stays stable across renders that changed nothing.
57
+ const propsChanged = !shallowEqual(props, cache.props);
58
+
59
+ if (propsChanged) {
60
+ cache.props = props;
61
+ cache.unfrozenProps = unfreezeProps(props);
62
+ }
63
+
64
+ if (propsChanged || cache.selector !== selector) {
65
+ const { unfrozenProps } = cache;
66
+
67
+ cache.selector = selector;
68
+ // react-redux memoizes the selection on the selector's identity, so a new
69
+ // identity is what makes it recompute when the state has not changed but
70
+ // the props or the selector have.
71
+ cache.boundSelector = (state: unknown) => selector(state, unfrozenProps);
72
+ }
73
+ }
74
+
75
+ return useReduxSelector(cache.boundSelector, selectorOptions);
34
76
  }
@@ -0,0 +1,162 @@
1
+ import { shallowEqual, useSelector as useReduxSelector } from '@veksa/react-redux';
2
+ import { SelectorResultsMap, SelectorsObject } from '@veksa/reselect';
3
+ import { useRef } from 'react';
4
+ import { unfreezeProps } from '../_helpers/unfreezeProps';
5
+ import { MergedParams } from '../types';
6
+ import { selectorOptions } from './selectorOptions';
7
+
8
+ type SelectorsMap = Record<string, Function>;
9
+
10
+ type SelectorsCache = {
11
+ keys: string[];
12
+ selectors: Function[];
13
+ props: unknown;
14
+ unfrozenProps: unknown;
15
+ results: unknown[];
16
+ value: Record<string, unknown> | null;
17
+ boundSelector: (state: unknown) => unknown;
18
+ };
19
+
20
+ const readSelectors = (cache: SelectorsCache, selectors: SelectorsMap) => {
21
+ const keys: string[] = [];
22
+ const fns: Function[] = [];
23
+
24
+ for (const key in selectors) {
25
+ if (Object.prototype.hasOwnProperty.call(selectors, key)) {
26
+ keys.push(key);
27
+ fns.push(selectors[key]);
28
+ }
29
+ }
30
+
31
+ cache.keys = keys;
32
+ cache.selectors = fns;
33
+ // Filled in ascending index order on the first run, so the array stays packed.
34
+ cache.results = [];
35
+ cache.value = null;
36
+ };
37
+
38
+ /**
39
+ * One pass over the incoming object instead of `shallowEqual`, which would
40
+ * allocate a key array for each side on every render of every call site. Key
41
+ * order is part of the comparison, so a renamed key is caught as well.
42
+ */
43
+ const hasSameSelectors = (cache: SelectorsCache, selectors: SelectorsMap) => {
44
+ const { keys, selectors: fns } = cache;
45
+ let index = 0;
46
+
47
+ for (const key in selectors) {
48
+ if (Object.prototype.hasOwnProperty.call(selectors, key)) {
49
+ if (index === keys.length || keys[index] !== key || fns[index] !== selectors[key]) {
50
+ return false;
51
+ }
52
+
53
+ index++;
54
+ }
55
+ }
56
+
57
+ return index === keys.length;
58
+ };
59
+
60
+ /**
61
+ * The structured part: every selector runs, but the result object is rebuilt
62
+ * only when some field actually changed. An unchanged batch keeps its previous
63
+ * reference, so react-redux's default reference equality already bails out —
64
+ * no result allocation and no comparison pass on the steady path.
65
+ */
66
+ const select = (cache: SelectorsCache, state: unknown) => {
67
+ const { keys, selectors, unfrozenProps, results } = cache;
68
+ const count = keys.length;
69
+
70
+ let changed = cache.value === null;
71
+
72
+ for (let index = 0; index < count; index++) {
73
+ const result = selectors[index](state, unfrozenProps);
74
+
75
+ if (!Object.is(result, results[index])) {
76
+ results[index] = result;
77
+ changed = true;
78
+ }
79
+ }
80
+
81
+ if (!changed) {
82
+ return cache.value;
83
+ }
84
+
85
+ const value: Record<string, unknown> = {};
86
+
87
+ for (let index = 0; index < count; index++) {
88
+ value[keys[index]] = results[index];
89
+ }
90
+
91
+ cache.value = value;
92
+
93
+ return value;
94
+ };
95
+
96
+ /**
97
+ * Reads a batch of selectors through a single store subscription and returns
98
+ * their results as one object.
99
+ *
100
+ * Seven `useSelector` calls in a row component mean seven store listeners,
101
+ * seven `useSyncExternalStore` instances and seven snapshot comparisons per
102
+ * dispatch. This collapses them into one of each; the trade-off is that any
103
+ * tracked state segment changing re-runs the whole batch, which for memoized
104
+ * selectors is a cache hit per field.
105
+ *
106
+ * The memoization lives in the component instance, so rows do not compete for
107
+ * a shared cache the way a module-level structured selector would.
108
+ */
109
+ export function useSelectors<M extends SelectorsObject>(
110
+ selectors: M,
111
+ ...params: MergedParams<M>
112
+ ): SelectorResultsMap<M>;
113
+
114
+ export function useSelectors(selectors: SelectorsMap, ...params: any[]) {
115
+ const props = params[0];
116
+
117
+ const cacheRef = useRef<SelectorsCache | null>(null);
118
+
119
+ let cache = cacheRef.current;
120
+
121
+ if (cache === null) {
122
+ const created: SelectorsCache = {
123
+ keys: [],
124
+ selectors: [],
125
+ props,
126
+ unfrozenProps: unfreezeProps(props),
127
+ results: [],
128
+ value: null,
129
+ boundSelector: (state: unknown) => select(created, state),
130
+ };
131
+
132
+ readSelectors(created, selectors);
133
+
134
+ cache = cacheRef.current = created;
135
+ } else {
136
+ // An object literal is a new reference on every render, so the batch is
137
+ // compared by content — the common case is the very same selectors.
138
+ const selectorsChanged = !hasSameSelectors(cache, selectors);
139
+ const propsChanged = !shallowEqual(props, cache.props);
140
+
141
+ if (selectorsChanged) {
142
+ readSelectors(cache, selectors);
143
+ }
144
+
145
+ if (propsChanged) {
146
+ cache.props = props;
147
+ cache.unfrozenProps = unfreezeProps(props);
148
+ }
149
+
150
+ if (selectorsChanged || propsChanged) {
151
+ // react-redux memoizes the selection on the selector's identity, so a new
152
+ // identity is what makes it recompute when the state has not changed but
153
+ // the batch or the props have. The closure reads the cell, so it never
154
+ // captures a stale batch.
155
+ const current = cache;
156
+
157
+ cache.boundSelector = (state: unknown) => select(current, state);
158
+ }
159
+ }
160
+
161
+ return useReduxSelector(cache.boundSelector, selectorOptions) as Record<string, unknown>;
162
+ }
@@ -36,6 +36,17 @@ describe('IntervalMapCache', () => {
36
36
  expect(cache.get('missing')).toBeUndefined();
37
37
  });
38
38
 
39
+ test('overwrites the value of a key that is already present', () => {
40
+ const cache = new IntervalMapCache();
41
+ const first = { value: 1 };
42
+ const second = { value: 2 };
43
+
44
+ cache.set('key', first);
45
+ cache.set('key', second);
46
+
47
+ expect(cache.get('key')).toBe(second);
48
+ });
49
+
39
50
  test('removes a value by key', () => {
40
51
  const cache = new IntervalMapCache();
41
52