@veams/status-quo 1.3.2 → 1.5.0
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 +11 -3
- package/dist/hooks/__tests__/state-selector.spec.js +22 -17
- package/dist/hooks/__tests__/state-selector.spec.js.map +1 -1
- package/dist/hooks/__tests__/state-singleton.spec.js +1 -1
- package/dist/hooks/__tests__/state-singleton.spec.js.map +1 -1
- package/dist/hooks/state-subscription-selector.d.ts +2 -4
- package/dist/hooks/state-subscription-selector.js +3 -11
- package/dist/hooks/state-subscription-selector.js.map +1 -1
- package/dist/store/__tests__/observable-state-handler.spec.js +4 -4
- package/dist/store/__tests__/observable-state-handler.spec.js.map +1 -1
- package/dist/store/__tests__/signal-state-handler.spec.js +44 -8
- package/dist/store/__tests__/signal-state-handler.spec.js.map +1 -1
- package/dist/store/base-state-handler.d.ts +8 -4
- package/dist/store/base-state-handler.js +16 -4
- package/dist/store/base-state-handler.js.map +1 -1
- package/dist/store/observable-state-handler.d.ts +1 -0
- package/dist/store/observable-state-handler.js +2 -7
- package/dist/store/observable-state-handler.js.map +1 -1
- package/dist/store/signal-state-handler.d.ts +2 -2
- package/dist/store/signal-state-handler.js +2 -1
- package/dist/store/signal-state-handler.js.map +1 -1
- package/dist/types/types.d.ts +2 -1
- package/dist/utils/selector-cache.d.ts +13 -0
- package/dist/utils/selector-cache.js +22 -0
- package/dist/utils/selector-cache.js.map +1 -0
- package/package.json +1 -1
- package/src/hooks/__tests__/state-selector.spec.tsx +31 -20
- package/src/hooks/__tests__/state-singleton.spec.tsx +1 -1
- package/src/hooks/state-subscription-selector.tsx +5 -25
- package/src/store/__tests__/observable-state-handler.spec.ts +4 -4
- package/src/store/__tests__/signal-state-handler.spec.ts +68 -11
- package/src/store/base-state-handler.ts +47 -4
- package/src/store/observable-state-handler.ts +5 -9
- package/src/store/signal-state-handler.ts +5 -4
- package/src/types/types.ts +2 -1
- package/src/utils/selector-cache.ts +43 -0
|
@@ -12,13 +12,13 @@ type SignalStateHandlerProps<S> = {
|
|
|
12
12
|
useDistinctUntilChanged?: boolean;
|
|
13
13
|
};
|
|
14
14
|
};
|
|
15
|
-
type Listener = () => void;
|
|
16
15
|
export declare abstract class SignalStateHandler<S, A> extends BaseStateHandler<S, A> {
|
|
17
16
|
private readonly state;
|
|
18
17
|
private readonly distinctOptions;
|
|
19
18
|
protected constructor({ initialState, options }: SignalStateHandlerProps<S>);
|
|
20
19
|
getSignal(): Signal<S>;
|
|
21
|
-
subscribe(listener:
|
|
20
|
+
subscribe(listener: () => void): () => void;
|
|
21
|
+
subscribe(listener: (value: S) => void): () => void;
|
|
22
22
|
protected getStateValue(): S;
|
|
23
23
|
protected setStateValue(nextState: S): void;
|
|
24
24
|
}
|
|
@@ -20,6 +20,7 @@ export class SignalStateHandler extends BaseStateHandler {
|
|
|
20
20
|
if (!initialized) {
|
|
21
21
|
initialized = true;
|
|
22
22
|
previousSnapshot = nextState;
|
|
23
|
+
listener(nextState);
|
|
23
24
|
return;
|
|
24
25
|
}
|
|
25
26
|
if (this.distinctOptions.enabled && this.distinctOptions.comparator(previousSnapshot, nextState)) {
|
|
@@ -27,7 +28,7 @@ export class SignalStateHandler extends BaseStateHandler {
|
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
30
|
previousSnapshot = nextState;
|
|
30
|
-
listener();
|
|
31
|
+
listener(nextState);
|
|
31
32
|
});
|
|
32
33
|
}
|
|
33
34
|
getStateValue() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"signal-state-handler.js","sourceRoot":"","sources":["../../src/store/signal-state-handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAE9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"signal-state-handler.js","sourceRoot":"","sources":["../../src/store/signal-state-handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAE9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AAiBxE,MAAM,OAAgB,kBAAyB,SAAQ,gBAAsB;IAC1D,KAAK,CAAY;IACjB,eAAe,CAA+C;IAE/E,YAAsB,EAAE,YAAY,EAAE,OAAO,EAA8B;QACzE,KAAK,CAAC,YAAY,CAAC,CAAC;QAEpB,IAAI,CAAC,KAAK,GAAG,MAAM,CAAI,YAAY,CAAC,CAAC;QACrC,IAAI,CAAC,eAAe,GAAG,sBAAsB,CAAC,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,uBAAuB,CAAC,CAAC;QACnG,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IACvC,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,KAAK,CAAC;IACpB,CAAC;IAID,SAAS,CAAC,QAA4B;QACpC,IAAI,WAAW,GAAG,KAAK,CAAC;QACxB,IAAI,gBAAgB,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC;QAExC,OAAO,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,SAAS,EAAE,EAAE;YACxC,IAAI,CAAC,WAAW,EAAE,CAAC;gBACjB,WAAW,GAAG,IAAI,CAAC;gBACnB,gBAAgB,GAAG,SAAS,CAAC;gBAC7B,QAAQ,CAAC,SAAS,CAAC,CAAC;gBACpB,OAAO;YACT,CAAC;YAED,IAAI,IAAI,CAAC,eAAe,CAAC,OAAO,IAAI,IAAI,CAAC,eAAe,CAAC,UAAU,CAAC,gBAAgB,EAAE,SAAS,CAAC,EAAE,CAAC;gBACjG,gBAAgB,GAAG,SAAS,CAAC;gBAC7B,OAAO;YACT,CAAC;YAED,gBAAgB,GAAG,SAAS,CAAC;YAC7B,QAAQ,CAAC,SAAS,CAAC,CAAC;QACtB,CAAC,CAAC,CAAC;IACL,CAAC;IAES,aAAa;QACrB,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC;IAC1B,CAAC;IAES,aAAa,CAAC,SAAY;QAClC,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,SAAS,CAAC;IAC/B,CAAC;CACF"}
|
package/dist/types/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export interface StateSubscriptionHandler<V, A> {
|
|
2
|
-
subscribe
|
|
2
|
+
subscribe(listener: () => void): () => void;
|
|
3
|
+
subscribe(listener: (value: V) => void): () => void;
|
|
3
4
|
getSnapshot: () => V;
|
|
4
5
|
destroy: () => void;
|
|
5
6
|
getInitialState: () => V;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type Selector<Value, Selected> = (value: Value) => Selected;
|
|
2
|
+
export type EqualityFn<Selected> = (current: Selected, next: Selected) => boolean;
|
|
3
|
+
type SelectorCache<Selected> = {
|
|
4
|
+
hasValue: boolean;
|
|
5
|
+
value: Selected | undefined;
|
|
6
|
+
};
|
|
7
|
+
type SelectionResult<Selected> = {
|
|
8
|
+
value: Selected;
|
|
9
|
+
hasChanged: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function createSelectorCache<Selected>(): SelectorCache<Selected>;
|
|
12
|
+
export declare function selectWithCache<Value, Selected>(selectorCache: SelectorCache<Selected>, value: Value, selector: Selector<Value, Selected>, isEqual?: EqualityFn<Selected>): SelectionResult<Selected>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function createSelectorCache() {
|
|
2
|
+
return {
|
|
3
|
+
hasValue: false,
|
|
4
|
+
value: undefined,
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
export function selectWithCache(selectorCache, value, selector, isEqual = Object.is) {
|
|
8
|
+
const nextSelection = selector(value);
|
|
9
|
+
if (selectorCache.hasValue && isEqual(selectorCache.value, nextSelection)) {
|
|
10
|
+
return {
|
|
11
|
+
value: selectorCache.value,
|
|
12
|
+
hasChanged: false,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
selectorCache.hasValue = true;
|
|
16
|
+
selectorCache.value = nextSelection;
|
|
17
|
+
return {
|
|
18
|
+
value: nextSelection,
|
|
19
|
+
hasChanged: true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=selector-cache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"selector-cache.js","sourceRoot":"","sources":["../../src/utils/selector-cache.ts"],"names":[],"mappings":"AAaA,MAAM,UAAU,mBAAmB;IACjC,OAAO;QACL,QAAQ,EAAE,KAAK;QACf,KAAK,EAAE,SAAS;KACjB,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,eAAe,CAC7B,aAAsC,EACtC,KAAY,EACZ,QAAmC,EACnC,UAAgC,MAAM,CAAC,EAAE;IAEzC,MAAM,aAAa,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAEtC,IAAI,aAAa,CAAC,QAAQ,IAAI,OAAO,CAAC,aAAa,CAAC,KAAiB,EAAE,aAAa,CAAC,EAAE,CAAC;QACtF,OAAO;YACL,KAAK,EAAE,aAAa,CAAC,KAAiB;YACtC,UAAU,EAAE,KAAK;SAClB,CAAC;IACJ,CAAC;IAED,aAAa,CAAC,QAAQ,GAAG,IAAI,CAAC;IAC9B,aAAa,CAAC,KAAK,GAAG,aAAa,CAAC;IAEpC,OAAO;QACL,KAAK,EAAE,aAAa;QACpB,UAAU,EAAE,IAAI;KACjB,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -50,7 +50,7 @@ type CounterMirrorActions = {
|
|
|
50
50
|
class TestStateHandler implements StateSubscriptionHandler<TestState, TestActions> {
|
|
51
51
|
private readonly initialState: TestState;
|
|
52
52
|
private state: TestState;
|
|
53
|
-
private readonly listeners = new Set<() => void>();
|
|
53
|
+
private readonly listeners = new Set<(value: TestState) => void>();
|
|
54
54
|
|
|
55
55
|
destroy = jest.fn();
|
|
56
56
|
|
|
@@ -59,13 +59,16 @@ class TestStateHandler implements StateSubscriptionHandler<TestState, TestAction
|
|
|
59
59
|
this.state = initialState;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
subscribe
|
|
63
|
-
|
|
62
|
+
subscribe(listener: () => void): () => void;
|
|
63
|
+
subscribe(listener: (value: TestState) => void): () => void;
|
|
64
|
+
subscribe(listener: ((value: TestState) => void) | (() => void)) {
|
|
65
|
+
const typedListener = listener as (value: TestState) => void;
|
|
66
|
+
this.listeners.add(typedListener);
|
|
64
67
|
|
|
65
68
|
return () => {
|
|
66
|
-
this.listeners.delete(
|
|
69
|
+
this.listeners.delete(typedListener);
|
|
67
70
|
};
|
|
68
|
-
}
|
|
71
|
+
}
|
|
69
72
|
|
|
70
73
|
getSnapshot = () => {
|
|
71
74
|
return this.state;
|
|
@@ -100,14 +103,15 @@ class TestStateHandler implements StateSubscriptionHandler<TestState, TestAction
|
|
|
100
103
|
};
|
|
101
104
|
|
|
102
105
|
private emitStateChange() {
|
|
103
|
-
this.
|
|
106
|
+
const nextState = this.state;
|
|
107
|
+
this.listeners.forEach((listener) => listener(nextState));
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
class CounterStateHandler implements StateSubscriptionHandler<CounterState, CounterActions> {
|
|
108
112
|
private readonly initialState: CounterState;
|
|
109
113
|
private state: CounterState;
|
|
110
|
-
private readonly listeners = new Set<() => void>();
|
|
114
|
+
private readonly listeners = new Set<(value: CounterState) => void>();
|
|
111
115
|
|
|
112
116
|
destroy = jest.fn();
|
|
113
117
|
|
|
@@ -116,13 +120,16 @@ class CounterStateHandler implements StateSubscriptionHandler<CounterState, Coun
|
|
|
116
120
|
this.state = this.initialState;
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
subscribe
|
|
120
|
-
|
|
123
|
+
subscribe(listener: () => void): () => void;
|
|
124
|
+
subscribe(listener: (value: CounterState) => void): () => void;
|
|
125
|
+
subscribe(listener: ((value: CounterState) => void) | (() => void)) {
|
|
126
|
+
const typedListener = listener as (value: CounterState) => void;
|
|
127
|
+
this.listeners.add(typedListener);
|
|
121
128
|
|
|
122
129
|
return () => {
|
|
123
|
-
this.listeners.delete(
|
|
130
|
+
this.listeners.delete(typedListener);
|
|
124
131
|
};
|
|
125
|
-
}
|
|
132
|
+
}
|
|
126
133
|
|
|
127
134
|
getSnapshot = () => {
|
|
128
135
|
return this.state;
|
|
@@ -139,7 +146,8 @@ class CounterStateHandler implements StateSubscriptionHandler<CounterState, Coun
|
|
|
139
146
|
count: this.state.count + 1,
|
|
140
147
|
};
|
|
141
148
|
|
|
142
|
-
this.
|
|
149
|
+
const nextState = this.state;
|
|
150
|
+
this.listeners.forEach((listener) => listener(nextState));
|
|
143
151
|
},
|
|
144
152
|
};
|
|
145
153
|
};
|
|
@@ -150,7 +158,7 @@ class CounterMirrorStateHandler
|
|
|
150
158
|
{
|
|
151
159
|
private readonly initialState: CounterMirrorState;
|
|
152
160
|
private state: CounterMirrorState;
|
|
153
|
-
private readonly listeners = new Set<() => void>();
|
|
161
|
+
private readonly listeners = new Set<(value: CounterMirrorState) => void>();
|
|
154
162
|
private readonly unsubscribeFromCounter: () => void;
|
|
155
163
|
|
|
156
164
|
destroy = jest.fn(() => {
|
|
@@ -165,22 +173,25 @@ class CounterMirrorStateHandler
|
|
|
165
173
|
mirroredCount: initialCounterState.count,
|
|
166
174
|
};
|
|
167
175
|
this.state = this.initialState;
|
|
168
|
-
this.unsubscribeFromCounter = counterStateHandler.subscribe(() => {
|
|
169
|
-
const nextCounterState = counterStateHandler.getSnapshot();
|
|
176
|
+
this.unsubscribeFromCounter = counterStateHandler.subscribe((nextCounterState: CounterState) => {
|
|
170
177
|
this.state = {
|
|
171
178
|
mirroredCount: nextCounterState.count,
|
|
172
179
|
};
|
|
173
|
-
this.
|
|
180
|
+
const nextMirrorState = this.state;
|
|
181
|
+
this.listeners.forEach((listener) => listener(nextMirrorState));
|
|
174
182
|
});
|
|
175
183
|
}
|
|
176
184
|
|
|
177
|
-
subscribe
|
|
178
|
-
|
|
185
|
+
subscribe(listener: () => void): () => void;
|
|
186
|
+
subscribe(listener: (value: CounterMirrorState) => void): () => void;
|
|
187
|
+
subscribe(listener: ((value: CounterMirrorState) => void) | (() => void)) {
|
|
188
|
+
const typedListener = listener as (value: CounterMirrorState) => void;
|
|
189
|
+
this.listeners.add(typedListener);
|
|
179
190
|
|
|
180
191
|
return () => {
|
|
181
|
-
this.listeners.delete(
|
|
192
|
+
this.listeners.delete(typedListener);
|
|
182
193
|
};
|
|
183
|
-
}
|
|
194
|
+
}
|
|
184
195
|
|
|
185
196
|
getSnapshot = () => {
|
|
186
197
|
return this.state;
|
|
@@ -31,7 +31,7 @@ const createStateHandler = (value: number): TestHandler => {
|
|
|
31
31
|
const actions = { noop: jest.fn() };
|
|
32
32
|
|
|
33
33
|
return {
|
|
34
|
-
subscribe: () => () => undefined,
|
|
34
|
+
subscribe: (_listener: (() => void) | ((value: TestState) => void)) => () => undefined,
|
|
35
35
|
getSnapshot: () => snapshot,
|
|
36
36
|
getInitialState: () => snapshot,
|
|
37
37
|
getActions: () => actions,
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
|
2
|
+
import { createSelectorCache, selectWithCache } from '../utils/selector-cache.js';
|
|
2
3
|
|
|
3
4
|
import type { StateSubscriptionHandler } from '../types/types.js';
|
|
5
|
+
import type { EqualityFn, Selector } from '../utils/selector-cache.js';
|
|
4
6
|
|
|
5
|
-
type SelectorCache<SelectedState> = {
|
|
6
|
-
hasValue: boolean;
|
|
7
|
-
value: SelectedState | undefined;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
type SelectorFn<State, SelectedState> = (state: State) => SelectedState;
|
|
11
|
-
type EqualityFn<SelectedState> = (current: SelectedState, next: SelectedState) => boolean;
|
|
12
7
|
type Listener = () => void;
|
|
13
8
|
type SharedStateSubscriptionHandler = StateSubscriptionHandler<unknown, unknown>;
|
|
14
9
|
type DeferredDestroy = {
|
|
@@ -39,17 +34,11 @@ function getDeferredDestroyState(
|
|
|
39
34
|
|
|
40
35
|
export function useStateSubscriptionSelector<V, A, Sel>(
|
|
41
36
|
stateSubscriptionHandler: StateSubscriptionHandler<V, A>,
|
|
42
|
-
selector:
|
|
37
|
+
selector: Selector<V, Sel>,
|
|
43
38
|
isEqual: EqualityFn<Sel> = Object.is,
|
|
44
39
|
destroyOnCleanup = true
|
|
45
40
|
) {
|
|
46
|
-
const selectorCache = useMemo<
|
|
47
|
-
() => ({
|
|
48
|
-
hasValue: false,
|
|
49
|
-
value: undefined,
|
|
50
|
-
}),
|
|
51
|
-
[stateSubscriptionHandler]
|
|
52
|
-
);
|
|
41
|
+
const selectorCache = useMemo(() => createSelectorCache<Sel>(), [stateSubscriptionHandler]);
|
|
53
42
|
|
|
54
43
|
const subscribe = useCallback(
|
|
55
44
|
(listener: Listener) => {
|
|
@@ -103,16 +92,7 @@ export function useStateSubscriptionSelector<V, A, Sel>(
|
|
|
103
92
|
|
|
104
93
|
const selectSnapshot = useCallback(
|
|
105
94
|
(snapshot: V) => {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (selectorCache.hasValue && isEqual(selectorCache.value as Sel, nextSelection)) {
|
|
109
|
-
return selectorCache.value as Sel;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
selectorCache.hasValue = true;
|
|
113
|
-
selectorCache.value = nextSelection;
|
|
114
|
-
|
|
115
|
-
return nextSelection;
|
|
95
|
+
return selectWithCache(selectorCache, snapshot, selector, isEqual).value;
|
|
116
96
|
},
|
|
117
97
|
[isEqual, selector, selectorCache]
|
|
118
98
|
);
|
|
@@ -127,7 +127,7 @@ describe('Observable State Handler', () => {
|
|
|
127
127
|
|
|
128
128
|
unsubscribe();
|
|
129
129
|
|
|
130
|
-
expect(spy).toHaveBeenCalledTimes(
|
|
130
|
+
expect(spy).toHaveBeenCalledTimes(3); // 1. initial, 2. test (first setter), 3. test2 (second setter)
|
|
131
131
|
});
|
|
132
132
|
|
|
133
133
|
it('should respect global distinct setup when disabled', () => {
|
|
@@ -146,7 +146,7 @@ describe('Observable State Handler', () => {
|
|
|
146
146
|
|
|
147
147
|
unsubscribe();
|
|
148
148
|
|
|
149
|
-
expect(spy).toHaveBeenCalledTimes(
|
|
149
|
+
expect(spy).toHaveBeenCalledTimes(3);
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
it('should respect global custom distinct comparator from setupStatusQuo', () => {
|
|
@@ -167,7 +167,7 @@ describe('Observable State Handler', () => {
|
|
|
167
167
|
|
|
168
168
|
unsubscribe();
|
|
169
169
|
|
|
170
|
-
expect(spy).toHaveBeenCalledTimes(
|
|
170
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
173
|
it('should prefer per-handler distinct options over global setup', () => {
|
|
@@ -190,6 +190,6 @@ describe('Observable State Handler', () => {
|
|
|
190
190
|
|
|
191
191
|
unsubscribe();
|
|
192
192
|
|
|
193
|
-
expect(spy).toHaveBeenCalledTimes(
|
|
193
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
194
194
|
});
|
|
195
195
|
});
|
|
@@ -46,6 +46,8 @@ class TestSignalStateHandler extends SignalStateHandler<TestState, TestActions>
|
|
|
46
46
|
|
|
47
47
|
type CounterState = { count: number };
|
|
48
48
|
type CounterActions = { increase: () => void };
|
|
49
|
+
type CounterBucketSelection = { bucket: number };
|
|
50
|
+
type CounterBucketState = { bucket: number };
|
|
49
51
|
|
|
50
52
|
class CounterSignalStateHandler extends SignalStateHandler<CounterState, CounterActions> {
|
|
51
53
|
constructor(initialCount = 0) {
|
|
@@ -78,16 +80,49 @@ class CounterSignalBridgeStateHandler extends SignalStateHandler<CounterState, {
|
|
|
78
80
|
|
|
79
81
|
const counterStateHandler = counterSingleton.getInstance();
|
|
80
82
|
|
|
81
|
-
this.bindSubscribable<CounterState>(
|
|
82
|
-
|
|
83
|
-
subscribe: (listener) =>
|
|
84
|
-
counterStateHandler.subscribe(() => listener(counterStateHandler.getSnapshot())),
|
|
85
|
-
getSnapshot: () => counterStateHandler.getSnapshot(),
|
|
86
|
-
},
|
|
83
|
+
this.bindSubscribable<CounterState, CounterState>(
|
|
84
|
+
counterStateHandler,
|
|
87
85
|
(nextCounterState) => {
|
|
88
86
|
onCounterSync(nextCounterState);
|
|
89
87
|
this.setState({ count: nextCounterState.count }, 'sync-counter');
|
|
90
|
-
}
|
|
88
|
+
},
|
|
89
|
+
(counterState) => counterState
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getActions(): { noop: () => void } {
|
|
94
|
+
return {
|
|
95
|
+
noop: () => undefined,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class CounterSignalBucketBridgeStateHandler extends SignalStateHandler<
|
|
101
|
+
CounterBucketState,
|
|
102
|
+
{ noop: () => void }
|
|
103
|
+
> {
|
|
104
|
+
constructor(
|
|
105
|
+
counterSingleton: ReturnType<typeof makeStateSingleton<CounterState, CounterActions>>,
|
|
106
|
+
onCounterSync: (selection: CounterBucketSelection) => void
|
|
107
|
+
) {
|
|
108
|
+
super({
|
|
109
|
+
initialState: {
|
|
110
|
+
bucket: -1,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const counterStateHandler = counterSingleton.getInstance();
|
|
115
|
+
|
|
116
|
+
this.bindSubscribable<CounterState, CounterBucketSelection>(
|
|
117
|
+
counterStateHandler,
|
|
118
|
+
(nextSelection) => {
|
|
119
|
+
onCounterSync(nextSelection);
|
|
120
|
+
this.setState({ bucket: nextSelection.bucket }, 'sync-counter-bucket');
|
|
121
|
+
},
|
|
122
|
+
(counterState) => ({
|
|
123
|
+
bucket: Math.floor(counterState.count / 2),
|
|
124
|
+
}),
|
|
125
|
+
(current, next) => current.bucket === next.bucket
|
|
91
126
|
);
|
|
92
127
|
}
|
|
93
128
|
|
|
@@ -165,7 +200,7 @@ describe('Signal State Handler', () => {
|
|
|
165
200
|
|
|
166
201
|
unsubscribe();
|
|
167
202
|
|
|
168
|
-
expect(spy).toHaveBeenCalledTimes(
|
|
203
|
+
expect(spy).toHaveBeenCalledTimes(3);
|
|
169
204
|
});
|
|
170
205
|
|
|
171
206
|
it('should respect global distinct setup when disabled', () => {
|
|
@@ -184,7 +219,7 @@ describe('Signal State Handler', () => {
|
|
|
184
219
|
|
|
185
220
|
unsubscribe();
|
|
186
221
|
|
|
187
|
-
expect(spy).toHaveBeenCalledTimes(
|
|
222
|
+
expect(spy).toHaveBeenCalledTimes(3);
|
|
188
223
|
});
|
|
189
224
|
|
|
190
225
|
it('should respect global custom distinct comparator from setupStatusQuo', () => {
|
|
@@ -205,7 +240,7 @@ describe('Signal State Handler', () => {
|
|
|
205
240
|
|
|
206
241
|
unsubscribe();
|
|
207
242
|
|
|
208
|
-
expect(spy).toHaveBeenCalledTimes(
|
|
243
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
209
244
|
});
|
|
210
245
|
|
|
211
246
|
it('should prefer per-handler distinct options over global setup', () => {
|
|
@@ -228,7 +263,7 @@ describe('Signal State Handler', () => {
|
|
|
228
263
|
|
|
229
264
|
unsubscribe();
|
|
230
265
|
|
|
231
|
-
expect(spy).toHaveBeenCalledTimes(
|
|
266
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
232
267
|
});
|
|
233
268
|
|
|
234
269
|
it('should notify another signal state handler for each singleton counter update', () => {
|
|
@@ -250,4 +285,26 @@ describe('Signal State Handler', () => {
|
|
|
250
285
|
|
|
251
286
|
bridgeStateHandler.destroy();
|
|
252
287
|
});
|
|
288
|
+
|
|
289
|
+
it('should support selector + equality filtering for bindSubscribable', () => {
|
|
290
|
+
const counterSingleton = makeStateSingleton(() => new CounterSignalStateHandler(0), {
|
|
291
|
+
destroyOnNoConsumers: false,
|
|
292
|
+
});
|
|
293
|
+
const syncSpy = jest.fn();
|
|
294
|
+
const bridgeStateHandler = new CounterSignalBucketBridgeStateHandler(counterSingleton, syncSpy);
|
|
295
|
+
const counterStateHandler = counterSingleton.getInstance();
|
|
296
|
+
|
|
297
|
+
counterStateHandler.getActions().increase(); // count 1 -> bucket 0 (no change)
|
|
298
|
+
counterStateHandler.getActions().increase(); // count 2 -> bucket 1
|
|
299
|
+
counterStateHandler.getActions().increase(); // count 3 -> bucket 1 (no change)
|
|
300
|
+
counterStateHandler.getActions().increase(); // count 4 -> bucket 2
|
|
301
|
+
|
|
302
|
+
expect(syncSpy).toHaveBeenCalledTimes(3);
|
|
303
|
+
expect(syncSpy).toHaveBeenNthCalledWith(1, { bucket: 0 });
|
|
304
|
+
expect(syncSpy).toHaveBeenNthCalledWith(2, { bucket: 1 });
|
|
305
|
+
expect(syncSpy).toHaveBeenNthCalledWith(3, { bucket: 2 });
|
|
306
|
+
expect(bridgeStateHandler.getState()).toStrictEqual({ bucket: 2 });
|
|
307
|
+
|
|
308
|
+
bridgeStateHandler.destroy();
|
|
309
|
+
});
|
|
253
310
|
});
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { withDevTools } from './dev-tools.js';
|
|
2
|
+
import { createSelectorCache, selectWithCache } from '../utils/selector-cache.js';
|
|
2
3
|
|
|
3
4
|
import type { StateSubscriptionHandler } from '../types/types.js';
|
|
4
5
|
import type { DevTools, MessagePayload } from './dev-tools.js';
|
|
6
|
+
import type { EqualityFn, Selector } from '../utils/selector-cache.js';
|
|
5
7
|
|
|
6
8
|
type DevToolsOptions = {
|
|
7
9
|
enabled?: boolean;
|
|
8
10
|
namespace: string;
|
|
9
11
|
};
|
|
10
12
|
|
|
13
|
+
type Subscribable<T> = {
|
|
14
|
+
subscribe: (listener: (value: T) => void) => () => void;
|
|
15
|
+
getSnapshot?: () => T;
|
|
16
|
+
};
|
|
17
|
+
|
|
11
18
|
const defaultDevToolsOptions = { enabled: false, namespace: 'Store' };
|
|
12
19
|
|
|
13
20
|
const devToolsFeatures = {
|
|
@@ -78,17 +85,53 @@ export abstract class BaseStateHandler<S, A> implements StateSubscriptionHandler
|
|
|
78
85
|
|
|
79
86
|
protected abstract getStateValue(): S;
|
|
80
87
|
protected abstract setStateValue(nextState: S): void;
|
|
88
|
+
|
|
81
89
|
protected bindSubscribable<T>(
|
|
82
|
-
service:
|
|
83
|
-
onChange: (value: T) => void
|
|
90
|
+
service: Subscribable<T>,
|
|
91
|
+
onChange: (value: T) => void,
|
|
92
|
+
selector?: Selector<T, T>,
|
|
93
|
+
isEqual?: EqualityFn<T>
|
|
94
|
+
): void;
|
|
95
|
+
protected bindSubscribable<T, Sel>(
|
|
96
|
+
service: Subscribable<T>,
|
|
97
|
+
onChange: (value: Sel) => void,
|
|
98
|
+
selector: Selector<T, Sel>,
|
|
99
|
+
isEqual?: EqualityFn<Sel>
|
|
100
|
+
): void;
|
|
101
|
+
protected bindSubscribable<T, Sel = T>(
|
|
102
|
+
service: Subscribable<T>,
|
|
103
|
+
onChange: (value: Sel) => void,
|
|
104
|
+
selector?: Selector<T, Sel>,
|
|
105
|
+
isEqual: EqualityFn<Sel> = Object.is
|
|
84
106
|
) {
|
|
85
|
-
const
|
|
107
|
+
const selectorFn = (selector ?? ((value: T) => value as unknown as Sel)) as Selector<T, Sel>;
|
|
108
|
+
const selectorCache = createSelectorCache<Sel>();
|
|
109
|
+
let receivedSyncValue = false;
|
|
110
|
+
|
|
111
|
+
const notifySelectedValue = (value: T) => {
|
|
112
|
+
receivedSyncValue = true;
|
|
113
|
+
const { value: nextSelection, hasChanged } = selectWithCache(
|
|
114
|
+
selectorCache,
|
|
115
|
+
value,
|
|
116
|
+
selectorFn,
|
|
117
|
+
isEqual
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (!hasChanged) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onChange(nextSelection);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const unsubscribe = service.subscribe(notifySelectedValue);
|
|
86
128
|
this.subscriptions = [...(this.subscriptions ?? []), { unsubscribe }];
|
|
87
129
|
|
|
88
|
-
if (service.getSnapshot)
|
|
130
|
+
if (service.getSnapshot && !receivedSyncValue) notifySelectedValue(service.getSnapshot());
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
abstract subscribe(listener: () => void): () => void;
|
|
134
|
+
abstract subscribe(listener: (value: S) => void): () => void;
|
|
92
135
|
abstract getActions(): A;
|
|
93
136
|
|
|
94
137
|
private handleDevToolsEvents = (message: MessagePayload) => {
|
|
@@ -70,15 +70,11 @@ export abstract class ObservableStateHandler<S, A> extends BaseStateHandler<S, A
|
|
|
70
70
|
return this.getObservable(key);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
subscribe(listener: () => void)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
listener();
|
|
73
|
+
subscribe(listener: () => void): () => void;
|
|
74
|
+
subscribe(listener: (value: S) => void): () => void;
|
|
75
|
+
subscribe(listener: (value: S) => void) {
|
|
76
|
+
const subscription = this.getStateAsObservable().subscribe((nextState) => {
|
|
77
|
+
listener(nextState);
|
|
82
78
|
});
|
|
83
79
|
return () => subscription.unsubscribe();
|
|
84
80
|
}
|
|
@@ -18,8 +18,6 @@ type SignalStateHandlerProps<S> = {
|
|
|
18
18
|
};
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
type Listener = () => void;
|
|
22
|
-
|
|
23
21
|
export abstract class SignalStateHandler<S, A> extends BaseStateHandler<S, A> {
|
|
24
22
|
private readonly state: Signal<S>;
|
|
25
23
|
private readonly distinctOptions: ReturnType<typeof resolveDistinctOptions<S>>;
|
|
@@ -36,7 +34,9 @@ export abstract class SignalStateHandler<S, A> extends BaseStateHandler<S, A> {
|
|
|
36
34
|
return this.state;
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
subscribe(listener:
|
|
37
|
+
subscribe(listener: () => void): () => void;
|
|
38
|
+
subscribe(listener: (value: S) => void): () => void;
|
|
39
|
+
subscribe(listener: (value: S) => void) {
|
|
40
40
|
let initialized = false;
|
|
41
41
|
let previousSnapshot = this.state.value;
|
|
42
42
|
|
|
@@ -44,6 +44,7 @@ export abstract class SignalStateHandler<S, A> extends BaseStateHandler<S, A> {
|
|
|
44
44
|
if (!initialized) {
|
|
45
45
|
initialized = true;
|
|
46
46
|
previousSnapshot = nextState;
|
|
47
|
+
listener(nextState);
|
|
47
48
|
return;
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -53,7 +54,7 @@ export abstract class SignalStateHandler<S, A> extends BaseStateHandler<S, A> {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
previousSnapshot = nextState;
|
|
56
|
-
listener();
|
|
57
|
+
listener(nextState);
|
|
57
58
|
});
|
|
58
59
|
}
|
|
59
60
|
|
package/src/types/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export interface StateSubscriptionHandler<V, A> {
|
|
2
|
-
subscribe
|
|
2
|
+
subscribe(listener: () => void): () => void;
|
|
3
|
+
subscribe(listener: (value: V) => void): () => void;
|
|
3
4
|
getSnapshot: () => V;
|
|
4
5
|
destroy: () => void;
|
|
5
6
|
getInitialState: () => V;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type Selector<Value, Selected> = (value: Value) => Selected;
|
|
2
|
+
export type EqualityFn<Selected> = (current: Selected, next: Selected) => boolean;
|
|
3
|
+
|
|
4
|
+
type SelectorCache<Selected> = {
|
|
5
|
+
hasValue: boolean;
|
|
6
|
+
value: Selected | undefined;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type SelectionResult<Selected> = {
|
|
10
|
+
value: Selected;
|
|
11
|
+
hasChanged: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function createSelectorCache<Selected>(): SelectorCache<Selected> {
|
|
15
|
+
return {
|
|
16
|
+
hasValue: false,
|
|
17
|
+
value: undefined,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function selectWithCache<Value, Selected>(
|
|
22
|
+
selectorCache: SelectorCache<Selected>,
|
|
23
|
+
value: Value,
|
|
24
|
+
selector: Selector<Value, Selected>,
|
|
25
|
+
isEqual: EqualityFn<Selected> = Object.is
|
|
26
|
+
): SelectionResult<Selected> {
|
|
27
|
+
const nextSelection = selector(value);
|
|
28
|
+
|
|
29
|
+
if (selectorCache.hasValue && isEqual(selectorCache.value as Selected, nextSelection)) {
|
|
30
|
+
return {
|
|
31
|
+
value: selectorCache.value as Selected,
|
|
32
|
+
hasChanged: false,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
selectorCache.hasValue = true;
|
|
37
|
+
selectorCache.value = nextSelection;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
value: nextSelection,
|
|
41
|
+
hasChanged: true,
|
|
42
|
+
};
|
|
43
|
+
}
|