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.
- package/README.md +307 -36
- package/dist/cjs/reselectKit.development.cjs +256 -95
- package/dist/cjs/reselectKit.development.cjs.map +1 -1
- package/dist/cjs/reselectKit.production.min.cjs +1 -1
- package/dist/cjs/reselectKit.production.min.cjs.map +1 -1
- package/dist/reselectKit.d.ts +201 -92
- package/dist/reselectKit.mjs +257 -97
- package/dist/reselectKit.mjs.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/createAdaptedSelector.test.ts +3 -4
- package/src/__tests__/createBoundSelector.test.ts +98 -5
- package/src/__tests__/createChainSelector.bench.ts +117 -0
- package/src/__tests__/createChainSelector.test.ts +162 -6
- package/src/__tests__/createPathSelector.test.ts +14 -0
- package/src/__tests__/createSequenceSelector.test.ts +10 -14
- package/src/_helpers/__tests__/generateSelectorKey.test.ts +24 -0
- package/src/_helpers/__tests__/getSelectorName.test.ts +30 -0
- package/src/_helpers/unfreezeProps.ts +14 -0
- package/src/_hooks/__tests__/useSelector.bench.ts +133 -0
- package/src/_hooks/__tests__/useSelector.test.ts +69 -0
- package/src/_hooks/__tests__/useSelectors.bench.ts +160 -0
- package/src/_hooks/__tests__/useSelectors.test.ts +219 -0
- package/src/_hooks/selectorOptions.ts +11 -0
- package/src/_hooks/useSelector.ts +68 -26
- package/src/_hooks/useSelectors.ts +162 -0
- package/src/cache/__tests__/intervalMapCache.test.ts +11 -0
- package/src/createAdaptedSelector.ts +9 -5
- package/src/createBoundSelector.ts +92 -84
- package/src/createCachedSequenceSelector.ts +12 -8
- package/src/createCachedStructuredSelector.ts +11 -20
- package/src/createChainSelector.ts +134 -89
- package/src/createPathSelector.ts +71 -104
- package/src/createPropSelector.ts +9 -8
- package/src/createSequenceSelector.ts +14 -9
- package/src/index.ts +2 -0
- package/src/keys/createKeySelectorComposer.ts +12 -23
- package/src/types.ts +115 -7
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { bench, describe } from 'vitest';
|
|
2
|
+
import { createSelector } from '@veksa/reselect';
|
|
3
|
+
import { createCachedSelector } from '@veksa/re-reselect';
|
|
4
|
+
import { createChainSelector } from '../createChainSelector';
|
|
5
|
+
import { createBoundSelector } from '../createBoundSelector';
|
|
6
|
+
import { createPropSelector } from '../createPropSelector';
|
|
7
|
+
|
|
8
|
+
type Position = { id: number; symbol: string; qty: number; price: number };
|
|
9
|
+
|
|
10
|
+
type State = {
|
|
11
|
+
positions: Record<number, Position>;
|
|
12
|
+
rates: Record<string, number>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const POSITIONS = 50;
|
|
16
|
+
const LEVELS = 10;
|
|
17
|
+
|
|
18
|
+
const symbols = ['EUR', 'GBP', 'JPY', 'CHF', 'AUD'];
|
|
19
|
+
|
|
20
|
+
const state: State = {
|
|
21
|
+
positions: Object.fromEntries(
|
|
22
|
+
Array.from({ length: POSITIONS }, (_, index) => [
|
|
23
|
+
index,
|
|
24
|
+
{
|
|
25
|
+
id: index,
|
|
26
|
+
symbol: symbols[index % symbols.length],
|
|
27
|
+
qty: index + 1,
|
|
28
|
+
price: index * 1.5,
|
|
29
|
+
},
|
|
30
|
+
]),
|
|
31
|
+
),
|
|
32
|
+
rates: Object.fromEntries(symbols.map((symbol, index) => [symbol, 1 + index / 10])),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const ids = Array.from({ length: POSITIONS }, (_, index) => index);
|
|
36
|
+
|
|
37
|
+
// The base as a plain selector: nothing in the graph is keyed, so every level's
|
|
38
|
+
// key is the default one.
|
|
39
|
+
const positionSelector = (state: State, props: { id: number }) => state.positions[props.id];
|
|
40
|
+
|
|
41
|
+
// The base as a cached selector: every level's key is the position id.
|
|
42
|
+
const cachedPositionSelector = createCachedSelector(
|
|
43
|
+
[(state: State) => state.positions, createPropSelector<{ id: number }>().id()],
|
|
44
|
+
(positions, id) => positions[id],
|
|
45
|
+
)({
|
|
46
|
+
keySelector: createPropSelector<{ id: number }>().id(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const rateSelector = createCachedSelector(
|
|
50
|
+
[(state: State) => state.rates, (_state: State, props: { symbol: string }) => props.symbol],
|
|
51
|
+
(rates, symbol) => rates[symbol],
|
|
52
|
+
)({
|
|
53
|
+
keySelector: (_state: State, props: { symbol: string }) => props.symbol,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// `map` only transforms a value, so no level of the chain introduces a key.
|
|
57
|
+
const withMapLevels = (base: unknown) => {
|
|
58
|
+
let chain: any = createChainSelector(base as never);
|
|
59
|
+
|
|
60
|
+
for (let level = 0; level < LEVELS; level++) {
|
|
61
|
+
chain = chain.map((value: any) => ({ value, level }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return chain.build();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type Carrier = { position: Position; total: number };
|
|
68
|
+
|
|
69
|
+
// The join shape: half the levels pull in another keyed selector, the value
|
|
70
|
+
// carried through the chain keeps the same shape at every level.
|
|
71
|
+
const withJoinLevels = (base: unknown) => {
|
|
72
|
+
let chain: any = createChainSelector(base as never).map(
|
|
73
|
+
(position: unknown): Carrier => ({ position: position as Position, total: 0 }),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
for (let level = 0; level < LEVELS; level++) {
|
|
77
|
+
chain =
|
|
78
|
+
level % 2 === 0
|
|
79
|
+
? chain.chain((carrier: Carrier) => {
|
|
80
|
+
const bound = createBoundSelector(rateSelector, { symbol: carrier.position.symbol });
|
|
81
|
+
|
|
82
|
+
return createSelector([bound], (rate: number) => ({
|
|
83
|
+
...carrier,
|
|
84
|
+
total: carrier.total + rate,
|
|
85
|
+
}));
|
|
86
|
+
})
|
|
87
|
+
: chain.map((carrier: Carrier) => ({ ...carrier, total: carrier.total + level }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return chain.build();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const mapOverPlain = withMapLevels(positionSelector);
|
|
94
|
+
const mapOverCached = withMapLevels(cachedPositionSelector);
|
|
95
|
+
const joinOverCached = withJoinLevels(cachedPositionSelector);
|
|
96
|
+
|
|
97
|
+
const readAll = (selector: any) => {
|
|
98
|
+
for (let index = 0; index < POSITIONS; index++) {
|
|
99
|
+
selector(state, { id: ids[index] });
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Steady state: every position is read again and again, so every level should be
|
|
104
|
+
// a cache hit. What is left is the per-level bookkeeping.
|
|
105
|
+
describe(`${POSITIONS} positions x ${LEVELS} levels`, () => {
|
|
106
|
+
bench('map levels, plain base', () => {
|
|
107
|
+
readAll(mapOverPlain);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
bench('map levels, cached base', () => {
|
|
111
|
+
readAll(mapOverCached);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
bench('join levels, cached base', () => {
|
|
115
|
+
readAll(joinOverCached);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { createSelector, setGlobalDevModeChecks } from '@veksa/reselect';
|
|
1
|
+
import { createSelector, createSelectorCreator, setGlobalDevModeChecks } from '@veksa/reselect';
|
|
2
2
|
import { createCachedSelector } from '@veksa/re-reselect';
|
|
3
|
-
import { createChainSelector } from '../createChainSelector';
|
|
3
|
+
import { createChainSelector, CreateSelectorOptions } from '../createChainSelector';
|
|
4
4
|
import { createBoundSelector } from '../createBoundSelector';
|
|
5
5
|
import { commonState, MessageStateSegment, PersonStateSegment, State } from '../__data__/state';
|
|
6
6
|
import { createPathSelector } from '../createPathSelector';
|
|
7
7
|
import { createPropSelector } from '../createPropSelector';
|
|
8
8
|
import { createKeySelectorCreator } from '../keys/createKeySelectorCreator';
|
|
9
9
|
import { stringComposeKeySelectors } from '../keys/stringComposeKeySelectors';
|
|
10
|
+
import { arrayComposeKeySelectors } from '../keys/arrayComposeKeySelectors';
|
|
10
11
|
import { createSequenceSelector } from '../createSequenceSelector';
|
|
11
|
-
import {
|
|
12
|
+
import { CachedSelector, NamedSelector } from '../types';
|
|
13
|
+
import { isCachedSelector } from '../_helpers/isCachedSelector';
|
|
14
|
+
import { defaultKeySelector } from '../keys/defaultKeySelector';
|
|
12
15
|
|
|
13
16
|
vi.mock('../debug/debug', async (importOriginal) => ({
|
|
14
17
|
...(await importOriginal<object>()),
|
|
@@ -307,6 +310,146 @@ describe('createChainSelector', () => {
|
|
|
307
310
|
expect(secondSelector.recomputations()).toBe(2);
|
|
308
311
|
});
|
|
309
312
|
|
|
313
|
+
test('should keep per key memoization for map levels over a cached selector', () => {
|
|
314
|
+
const mapFn = vi.fn((person?: { firstName?: string }) => person?.firstName ?? '');
|
|
315
|
+
|
|
316
|
+
const firstNameSelector = createChainSelector(personSelector).map(mapFn).build();
|
|
317
|
+
|
|
318
|
+
firstNameSelector(commonState, { id: 1 });
|
|
319
|
+
firstNameSelector(commonState, { id: 2 });
|
|
320
|
+
firstNameSelector(commonState, { id: 1 });
|
|
321
|
+
firstNameSelector(commonState, { id: 2 });
|
|
322
|
+
|
|
323
|
+
expect(mapFn).toHaveBeenCalledTimes(2);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('should propagate the key selector of the base through map levels', () => {
|
|
327
|
+
const mappedSelector = createChainSelector(personSelector)
|
|
328
|
+
.map((person) => person?.firstName)
|
|
329
|
+
.map((firstName) => firstName ?? '')
|
|
330
|
+
.build();
|
|
331
|
+
|
|
332
|
+
// A `map` level never derives a cached selector, so its key is the base's own
|
|
333
|
+
// and is handed over as is instead of being recomposed per level.
|
|
334
|
+
expect((mappedSelector as unknown as CachedSelector).keySelector).toBe(
|
|
335
|
+
personSelector.keySelector,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('should build a keyless level with the configured selector creator', () => {
|
|
340
|
+
const selectorCreator = vi.fn(createSelector);
|
|
341
|
+
|
|
342
|
+
const createSelectorOptions = () =>
|
|
343
|
+
({ selectorCreator }) as unknown as CreateSelectorOptions<typeof createSelectorCreator>;
|
|
344
|
+
|
|
345
|
+
const personIdSelector = createChainSelector(messageSelector)
|
|
346
|
+
.map((message) => message.personId, { createSelectorOptions })
|
|
347
|
+
.build();
|
|
348
|
+
|
|
349
|
+
expect(personIdSelector(commonState, { id: 100 })).toBe(1);
|
|
350
|
+
expect(selectorCreator).toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('should build a keyed level with the configured cache object', () => {
|
|
354
|
+
const cacheObject = {
|
|
355
|
+
set: vi.fn(),
|
|
356
|
+
get: vi.fn(),
|
|
357
|
+
remove: vi.fn(),
|
|
358
|
+
clear: vi.fn(),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const createSelectorOptions = () =>
|
|
362
|
+
({ cacheObject }) as unknown as CreateSelectorOptions<typeof createSelectorCreator>;
|
|
363
|
+
|
|
364
|
+
const firstNameSelector = createChainSelector(personSelector)
|
|
365
|
+
.map((person) => person?.firstName, { createSelectorOptions })
|
|
366
|
+
.build();
|
|
367
|
+
|
|
368
|
+
firstNameSelector(commonState, { id: 1 });
|
|
369
|
+
|
|
370
|
+
// The base is keyed, so the level keeps its cache — and it is the configured
|
|
371
|
+
// one that receives the entry.
|
|
372
|
+
expect(cacheObject.set).toHaveBeenCalled();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('should keep the hit rate of a chain level over an uncached selector', () => {
|
|
376
|
+
const chainMock = vi.fn((message: { personId: number }) => {
|
|
377
|
+
return createBoundSelector(personSelector, { id: message.personId });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// The base is a plain selector, so this level carries no key and is built as
|
|
381
|
+
// a plain memoized selector. Alternating between two results must still hit.
|
|
382
|
+
const personByMessageIdSelector = createChainSelector(messageSelector).chain(chainMock).build();
|
|
383
|
+
|
|
384
|
+
personByMessageIdSelector(commonState, { id: 100 });
|
|
385
|
+
personByMessageIdSelector(commonState, { id: 200 });
|
|
386
|
+
personByMessageIdSelector(commonState, { id: 100 });
|
|
387
|
+
personByMessageIdSelector(commonState, { id: 200 });
|
|
388
|
+
|
|
389
|
+
expect(chainMock).toHaveBeenCalledTimes(2);
|
|
390
|
+
expect(personByMessageIdSelector(commonState, { id: 100 }).firstName).toBe('Marry');
|
|
391
|
+
expect(personByMessageIdSelector(commonState, { id: 200 }).firstName).toBe('Harry');
|
|
392
|
+
expect(chainMock).toHaveBeenCalledTimes(2);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('should compose the key of a map level with the chain composer', () => {
|
|
396
|
+
const firstKeySelector = createPropSelector<{ first: string }>().first();
|
|
397
|
+
const secondKeySelector = createPropSelector<{ second: string }>().second();
|
|
398
|
+
|
|
399
|
+
const arrayKeyedSelector = createCachedSelector(
|
|
400
|
+
[firstKeySelector, secondKeySelector],
|
|
401
|
+
(first, second) => `${first}${second}`,
|
|
402
|
+
)({
|
|
403
|
+
keySelector: arrayComposeKeySelectors(firstKeySelector, secondKeySelector),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const mappedSelector = createChainSelector(arrayKeyedSelector)
|
|
407
|
+
.map((value) => value.length)
|
|
408
|
+
.build();
|
|
409
|
+
|
|
410
|
+
const keySelector = (mappedSelector as unknown as CachedSelector).keySelector;
|
|
411
|
+
|
|
412
|
+
// The base composes an array key, but the level is cached with whatever the
|
|
413
|
+
// chain's own composer produces — a flat cache object cannot take an array.
|
|
414
|
+
expect(keySelector(expect.anything(), { first: 'a', second: 'b' })).toBe('a:b');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('should not build a keyed cache when the key cannot vary', () => {
|
|
418
|
+
const mapFn = vi.fn((message: { personId: number }) => message.personId);
|
|
419
|
+
|
|
420
|
+
const personIdSelector = createChainSelector(messageSelector).map(mapFn).map(String).build();
|
|
421
|
+
|
|
422
|
+
expect(isCachedSelector(personIdSelector)).toBe(false);
|
|
423
|
+
|
|
424
|
+
// Dropping the cache changes nothing about memoization: a constant key means
|
|
425
|
+
// a single cache entry, which is what a plain memoized selector already is.
|
|
426
|
+
personIdSelector(commonState, { id: 100 });
|
|
427
|
+
personIdSelector(commonState, { id: 100 });
|
|
428
|
+
personIdSelector(commonState, { id: 100 });
|
|
429
|
+
|
|
430
|
+
expect(mapFn).toHaveBeenCalledTimes(1);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('should keep the key selector of a chain level whose derived selector may be cached', () => {
|
|
434
|
+
// A `chain` level cannot know at build time what it will derive, so unlike a
|
|
435
|
+
// `map` level it carries a key selector even over an uncached base. The key
|
|
436
|
+
// it resolves to is the default one, which is what makes the level itself
|
|
437
|
+
// skip the cache while still letting a level above compose from it.
|
|
438
|
+
const personSelectorByMessage: any = createChainSelector(messageSelector)
|
|
439
|
+
.chain((message) => (state: State) => state.persons.data[message.personId])
|
|
440
|
+
.build();
|
|
441
|
+
|
|
442
|
+
expect('keySelector' in personSelectorByMessage).toBe(true);
|
|
443
|
+
expect('cache' in personSelectorByMessage).toBe(false);
|
|
444
|
+
expect(personSelectorByMessage.keySelector(commonState, { id: 100 })).toBe(
|
|
445
|
+
defaultKeySelector(),
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
// Reading it still memoizes per key, which is what the cache would have done.
|
|
449
|
+
expect(personSelectorByMessage(commonState, { id: 100 }).firstName).toBe('Marry');
|
|
450
|
+
expect(personSelectorByMessage(commonState, { id: 200 }).firstName).toBe('Harry');
|
|
451
|
+
});
|
|
452
|
+
|
|
310
453
|
test('should implement aggregation for collection by single item selector', () => {
|
|
311
454
|
const personsSelector = (state: State) => state.persons;
|
|
312
455
|
|
|
@@ -336,9 +479,8 @@ describe('createChainSelector', () => {
|
|
|
336
479
|
},
|
|
337
480
|
);
|
|
338
481
|
|
|
339
|
-
(
|
|
340
|
-
fullNameNamedSelector
|
|
341
|
-
).selectorName = 'fullNameNamedSelector';
|
|
482
|
+
(fullNameNamedSelector as NamedSelector<unknown, unknown, [unknown], unknown>).selectorName =
|
|
483
|
+
'fullNameNamedSelector';
|
|
342
484
|
|
|
343
485
|
const personByMessageIdSelector: any = createChainSelector(messageSelector)
|
|
344
486
|
.chain((message) => {
|
|
@@ -368,6 +510,20 @@ describe('createChainSelector', () => {
|
|
|
368
510
|
);
|
|
369
511
|
});
|
|
370
512
|
|
|
513
|
+
test('should let an assigned selector name win over the generated one', () => {
|
|
514
|
+
const personIdSelector: any = createChainSelector(messageSelector)
|
|
515
|
+
.map((message) => message.personId)
|
|
516
|
+
.build();
|
|
517
|
+
|
|
518
|
+
expect(personIdSelector.selectorName).toContain('messageSelector');
|
|
519
|
+
|
|
520
|
+
// The generated name is exposed through a getter, and the matching setter is
|
|
521
|
+
// what lets a caller pin a readable name on a built chain.
|
|
522
|
+
personIdSelector.selectorName = 'personIdSelector';
|
|
523
|
+
|
|
524
|
+
expect(personIdSelector.selectorName).toBe('personIdSelector');
|
|
525
|
+
});
|
|
526
|
+
|
|
371
527
|
test('should build selector with chainHierarchy property for unit test', () => {
|
|
372
528
|
type FirstState = { firstValue: string };
|
|
373
529
|
|
|
@@ -52,6 +52,20 @@ describe('createPathSelector', () => {
|
|
|
52
52
|
expect(nameSelector({})).toBe('default');
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
test('should forward every argument the base selector declares', () => {
|
|
56
|
+
const baseSelector = (state: State, props: { id: number }, offset: number) => ({
|
|
57
|
+
messageId: (state.documents[props.id]?.messageId ?? 0) + offset,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const messageIdSelector = createPathSelector(baseSelector).messageId();
|
|
61
|
+
|
|
62
|
+
// One and two arguments are dispatched directly; anything else goes through
|
|
63
|
+
// the generic path, which has to pass the whole argument list on.
|
|
64
|
+
expect(messageIdSelector(commonState, { id: 1 }, 1000)).toBe(
|
|
65
|
+
(commonState.documents[1]?.messageId ?? 0) + 1000,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
55
69
|
describe('integration with re-reselect', () => {
|
|
56
70
|
test('should expose keySelector if base selector is cached', () => {
|
|
57
71
|
const personSelector = createCachedSelector(
|
|
@@ -5,18 +5,16 @@ import { createSequenceSelector } from '../createSequenceSelector';
|
|
|
5
5
|
|
|
6
6
|
describe('createSequenceSelector', () => {
|
|
7
7
|
test('combines input selector results into an ordered array', () => {
|
|
8
|
-
const selector = createSequenceSelector
|
|
9
|
-
(state) => state.persons.currentPersonId,
|
|
10
|
-
(state) => state.messages.currentMessageId,
|
|
8
|
+
const selector = createSequenceSelector([
|
|
9
|
+
(state: State) => state.persons.currentPersonId,
|
|
10
|
+
(state: State) => state.messages.currentMessageId,
|
|
11
11
|
]);
|
|
12
12
|
|
|
13
13
|
expect(selector(commonState)).toEqual([1, 200]);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
test('memoizes the result while inputs are unchanged', () => {
|
|
17
|
-
const selector = createSequenceSelector
|
|
18
|
-
(state) => state.persons.currentPersonId,
|
|
19
|
-
]);
|
|
17
|
+
const selector = createSequenceSelector([(state: State) => state.persons.currentPersonId]);
|
|
20
18
|
|
|
21
19
|
const first = selector(commonState);
|
|
22
20
|
const second = selector(commonState);
|
|
@@ -25,9 +23,7 @@ describe('createSequenceSelector', () => {
|
|
|
25
23
|
});
|
|
26
24
|
|
|
27
25
|
test('recomputes when an input selector result changes', () => {
|
|
28
|
-
const selector = createSequenceSelector
|
|
29
|
-
(state) => state.persons.currentPersonId,
|
|
30
|
-
]);
|
|
26
|
+
const selector = createSequenceSelector([(state: State) => state.persons.currentPersonId]);
|
|
31
27
|
|
|
32
28
|
const first = selector(commonState);
|
|
33
29
|
const nextState: State = {
|
|
@@ -41,9 +37,9 @@ describe('createSequenceSelector', () => {
|
|
|
41
37
|
});
|
|
42
38
|
|
|
43
39
|
test('passes props through to parametric input selectors', () => {
|
|
44
|
-
const selector = createSequenceSelector
|
|
45
|
-
(_state, props) => props.personId,
|
|
46
|
-
(state) => state.persons.currentPersonId ?? 0,
|
|
40
|
+
const selector = createSequenceSelector([
|
|
41
|
+
(_state: State, props: { personId: number }) => props.personId,
|
|
42
|
+
(state: State) => state.persons.currentPersonId ?? 0,
|
|
47
43
|
]);
|
|
48
44
|
|
|
49
45
|
expect(selector(commonState, { personId: 7 })).toEqual([7, 1]);
|
|
@@ -52,8 +48,8 @@ describe('createSequenceSelector', () => {
|
|
|
52
48
|
test('uses the provided selector creator', () => {
|
|
53
49
|
const selectorCreator = vi.fn(createSelector);
|
|
54
50
|
|
|
55
|
-
createSequenceSelector
|
|
56
|
-
[(state) => state.persons.currentPersonId],
|
|
51
|
+
createSequenceSelector(
|
|
52
|
+
[(state: State) => state.persons.currentPersonId],
|
|
57
53
|
selectorCreator as unknown as typeof createSelector,
|
|
58
54
|
);
|
|
59
55
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { generateSelectorKey } from '../generateSelectorKey';
|
|
2
|
+
|
|
3
|
+
describe('generateSelectorKey', () => {
|
|
4
|
+
test('stringifies a selector that has no dependencies', () => {
|
|
5
|
+
const selector = (state: { id: number }) => state.id;
|
|
6
|
+
|
|
7
|
+
expect(generateSelectorKey(selector)).toBe('(state) => state.id');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('appends every dependency, so selectors sharing a body still differ', () => {
|
|
11
|
+
const first = (state: { id: number }) => state.id;
|
|
12
|
+
const second = (state: { id: number }) => state.id;
|
|
13
|
+
|
|
14
|
+
const withFirst = Object.assign((state: { id: number }) => state.id, {
|
|
15
|
+
dependencies: [first],
|
|
16
|
+
});
|
|
17
|
+
const withBoth = Object.assign((state: { id: number }) => state.id, {
|
|
18
|
+
dependencies: [first, second],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(generateSelectorKey(withFirst)).not.toBe(generateSelectorKey(withBoth));
|
|
22
|
+
expect(generateSelectorKey(withBoth)).toContain('(state) => state.id');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getSelectorName } from '../getSelectorName';
|
|
2
|
+
|
|
3
|
+
describe('getSelectorName', () => {
|
|
4
|
+
test('prefers the debug name a selector was tagged with', () => {
|
|
5
|
+
const selector = Object.assign((state: unknown) => state, {
|
|
6
|
+
selectorName: 'personSelector',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
expect(getSelectorName(selector)).toBe('personSelector');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('falls back to the function name when there is no debug name', () => {
|
|
13
|
+
function messageSelector(state: unknown) {
|
|
14
|
+
return state;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
expect(getSelectorName(messageSelector)).toBe('messageSelector');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('falls back to the function name when the debug name is empty', () => {
|
|
21
|
+
const selector = Object.assign(
|
|
22
|
+
function personSelector(state: unknown) {
|
|
23
|
+
return state;
|
|
24
|
+
},
|
|
25
|
+
{ selectorName: '' },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(getSelectorName(selector)).toBe('personSelector');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound selectors write the binding into the props object in place and roll it
|
|
3
|
+
* back afterwards (see temporaryAssign.ts), which throws on a frozen target in
|
|
4
|
+
* strict mode. React freezes element props in development, and immer-based
|
|
5
|
+
* stores hand out frozen objects in production too, so the copy stays in both
|
|
6
|
+
* builds — the hooks only ever call this when props actually change.
|
|
7
|
+
*/
|
|
8
|
+
export const unfreezeProps = (props: unknown) => {
|
|
9
|
+
if (typeof props !== 'object' || props === null || !Object.isFrozen(props)) {
|
|
10
|
+
return props;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return Array.isArray(props) ? props.slice() : { ...props };
|
|
14
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createElement, Fragment, ReactNode, useCallback, useMemo } from 'react';
|
|
2
|
+
import { bench, describe } from 'vitest';
|
|
3
|
+
import { cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { Provider, shallowEqual, useSelector as useReduxSelector } from '@veksa/react-redux';
|
|
5
|
+
import { useSelector } from '../useSelector';
|
|
6
|
+
import { useMemoWith } from '../useMemoWith';
|
|
7
|
+
|
|
8
|
+
// The implementation this hook replaced: `useMemoWith` (useRef) -> `useMemo` ->
|
|
9
|
+
// `useCallback`, three hook slots and two dependency arrays per call site. Kept
|
|
10
|
+
// here so both variants are measured in the same process on the same workload.
|
|
11
|
+
function useSelectorLegacy(selector: Function, ...params: any[]) {
|
|
12
|
+
const props = params[0];
|
|
13
|
+
const memoizedProps = useMemoWith(props, shallowEqual);
|
|
14
|
+
|
|
15
|
+
const unfrozenProps = useMemo(() => {
|
|
16
|
+
return Object.isFrozen(memoizedProps) ? { ...memoizedProps } : memoizedProps;
|
|
17
|
+
}, [memoizedProps]);
|
|
18
|
+
|
|
19
|
+
const memoizedPropsSelector = useCallback(
|
|
20
|
+
(state: unknown) => {
|
|
21
|
+
return selector(state, unfrozenProps);
|
|
22
|
+
},
|
|
23
|
+
[selector, unfrozenProps],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return useReduxSelector(memoizedPropsSelector, {
|
|
27
|
+
devModeChecks: {
|
|
28
|
+
stabilityCheck: 'never',
|
|
29
|
+
identityFunctionCheck: 'never',
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type State = { count: number };
|
|
35
|
+
type Props = { factor: number };
|
|
36
|
+
|
|
37
|
+
// `useSelector` overloads are ambient-typed against the store, and the legacy
|
|
38
|
+
// variant is untyped on purpose — both are called through one loose signature.
|
|
39
|
+
type SelectorHook = (selector: (state: State, props: Props) => number, props: Props) => number;
|
|
40
|
+
|
|
41
|
+
const StoreProvider = Provider as unknown as (props: {
|
|
42
|
+
store: unknown;
|
|
43
|
+
children?: ReactNode;
|
|
44
|
+
}) => ReactNode;
|
|
45
|
+
|
|
46
|
+
const state: State = { count: 5 };
|
|
47
|
+
|
|
48
|
+
const store = {
|
|
49
|
+
getState: () => state,
|
|
50
|
+
dispatch: (action: unknown) => action,
|
|
51
|
+
subscribe: () => () => undefined,
|
|
52
|
+
replaceReducer: () => undefined,
|
|
53
|
+
} as unknown as never;
|
|
54
|
+
|
|
55
|
+
// Selectors in this kit are built once at module scope, so the hot path is a
|
|
56
|
+
// stable selector reference — the hook overhead is all that is left to measure.
|
|
57
|
+
const selectValue = (s: State, props: Props) => s.count * props.factor;
|
|
58
|
+
|
|
59
|
+
// Enough call sites for the per-hook cost to rise above React's own render
|
|
60
|
+
// overhead, small enough to keep a bench iteration in the millisecond range.
|
|
61
|
+
const CALL_SITES = 500;
|
|
62
|
+
|
|
63
|
+
const createTree = (useHook: SelectorHook) => {
|
|
64
|
+
const Child = ({ factor }: Props) => {
|
|
65
|
+
useHook(selectValue, { factor });
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return ({ factor }: Props) => {
|
|
70
|
+
const children: ReactNode[] = [];
|
|
71
|
+
|
|
72
|
+
for (let index = 0; index < CALL_SITES; index++) {
|
|
73
|
+
children.push(createElement(Child, { key: index, factor }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return createElement(StoreProvider, { store }, createElement(Fragment, null, ...children));
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const NextTree = createTree(useSelector as SelectorHook);
|
|
81
|
+
const LegacyTree = createTree(useSelectorLegacy as SelectorHook);
|
|
82
|
+
|
|
83
|
+
const benchRerender = (name: string, Tree: (props: Props) => ReactNode, changeProps: boolean) => {
|
|
84
|
+
let mounted: ReturnType<typeof render> | null = null;
|
|
85
|
+
let factor = 1;
|
|
86
|
+
|
|
87
|
+
bench(
|
|
88
|
+
name,
|
|
89
|
+
() => {
|
|
90
|
+
if (changeProps) {
|
|
91
|
+
factor = factor === 1 ? 2 : 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
mounted!.rerender(createElement(Tree, { factor }));
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
setup: () => {
|
|
98
|
+
mounted = render(createElement(Tree, { factor }));
|
|
99
|
+
},
|
|
100
|
+
teardown: () => {
|
|
101
|
+
cleanup();
|
|
102
|
+
mounted = null;
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// The dominant case: a parent re-renders, props are new objects but shallow
|
|
109
|
+
// equal, the state has not changed. Nothing recomputes — this is hook overhead
|
|
110
|
+
// and nothing else.
|
|
111
|
+
describe('re-render, shallow-equal props', () => {
|
|
112
|
+
benchRerender('next', NextTree, false);
|
|
113
|
+
benchRerender('legacy', LegacyTree, false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Props actually change, so both variants must rebuild the bound selector and
|
|
117
|
+
// let react-redux recompute the selection.
|
|
118
|
+
describe('re-render, changed props', () => {
|
|
119
|
+
benchRerender('next', NextTree, true);
|
|
120
|
+
benchRerender('legacy', LegacyTree, true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('mount', () => {
|
|
124
|
+
bench('next', () => {
|
|
125
|
+
render(createElement(NextTree, { factor: 1 }));
|
|
126
|
+
cleanup();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
bench('legacy', () => {
|
|
130
|
+
render(createElement(LegacyTree, { factor: 1 }));
|
|
131
|
+
cleanup();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -65,6 +65,75 @@ describe('useSelector', () => {
|
|
|
65
65
|
expect(new Set(propsArgs).size).toBe(1);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test('does not re-run the selector while props and state stay equal', () => {
|
|
69
|
+
const store = createMockStore(state);
|
|
70
|
+
const selector = vi.fn((s: State, props: { factor: number }) => s.count * props.factor);
|
|
71
|
+
|
|
72
|
+
const { rerender } = renderHook(({ props }) => useSelector(selector, props), {
|
|
73
|
+
wrapper: wrapperWith(store),
|
|
74
|
+
initialProps: { props: { factor: 2 } },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const callsAfterMount = selector.mock.calls.length;
|
|
78
|
+
rerender({ props: { factor: 2 } });
|
|
79
|
+
|
|
80
|
+
expect(selector.mock.calls.length).toBe(callsAfterMount);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('re-runs the selector when props change without a state change', () => {
|
|
84
|
+
const store = createMockStore(state);
|
|
85
|
+
|
|
86
|
+
const { result, rerender } = renderHook(
|
|
87
|
+
({ props }) => useSelector((s: State, p: { factor: number }) => s.count * p.factor, props),
|
|
88
|
+
{
|
|
89
|
+
wrapper: wrapperWith(store),
|
|
90
|
+
initialProps: { props: { factor: 2 } },
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(result.current).toBe(10);
|
|
95
|
+
|
|
96
|
+
rerender({ props: { factor: 3 } });
|
|
97
|
+
|
|
98
|
+
expect(result.current).toBe(15);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('passes non-object props through untouched', () => {
|
|
102
|
+
const store = createMockStore(state);
|
|
103
|
+
let receivedProps: unknown;
|
|
104
|
+
|
|
105
|
+
renderHook(
|
|
106
|
+
() =>
|
|
107
|
+
useSelector((s: State, props: string) => {
|
|
108
|
+
receivedProps = props;
|
|
109
|
+
return s.count;
|
|
110
|
+
}, 'id'),
|
|
111
|
+
{ wrapper: wrapperWith(store) },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(receivedProps).toBe('id');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('copies frozen array props without turning them into objects', () => {
|
|
118
|
+
const store = createMockStore(state);
|
|
119
|
+
let receivedProps: number[] | undefined;
|
|
120
|
+
|
|
121
|
+
renderHook(
|
|
122
|
+
() =>
|
|
123
|
+
useSelector(
|
|
124
|
+
(s: State, props: number[]) => {
|
|
125
|
+
receivedProps = props;
|
|
126
|
+
return s.count;
|
|
127
|
+
},
|
|
128
|
+
Object.freeze([1, 2]) as number[],
|
|
129
|
+
),
|
|
130
|
+
{ wrapper: wrapperWith(store) },
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(Object.isFrozen(receivedProps)).toBe(false);
|
|
134
|
+
expect(receivedProps).toEqual([1, 2]);
|
|
135
|
+
});
|
|
136
|
+
|
|
68
137
|
test('unfreezes frozen props before passing them to the selector', () => {
|
|
69
138
|
const store = createMockStore(state);
|
|
70
139
|
let receivedProps: { factor: number } | undefined;
|