@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.
Files changed (36) hide show
  1. package/README.md +11 -3
  2. package/dist/hooks/__tests__/state-selector.spec.js +22 -17
  3. package/dist/hooks/__tests__/state-selector.spec.js.map +1 -1
  4. package/dist/hooks/__tests__/state-singleton.spec.js +1 -1
  5. package/dist/hooks/__tests__/state-singleton.spec.js.map +1 -1
  6. package/dist/hooks/state-subscription-selector.d.ts +2 -4
  7. package/dist/hooks/state-subscription-selector.js +3 -11
  8. package/dist/hooks/state-subscription-selector.js.map +1 -1
  9. package/dist/store/__tests__/observable-state-handler.spec.js +4 -4
  10. package/dist/store/__tests__/observable-state-handler.spec.js.map +1 -1
  11. package/dist/store/__tests__/signal-state-handler.spec.js +44 -8
  12. package/dist/store/__tests__/signal-state-handler.spec.js.map +1 -1
  13. package/dist/store/base-state-handler.d.ts +8 -4
  14. package/dist/store/base-state-handler.js +16 -4
  15. package/dist/store/base-state-handler.js.map +1 -1
  16. package/dist/store/observable-state-handler.d.ts +1 -0
  17. package/dist/store/observable-state-handler.js +2 -7
  18. package/dist/store/observable-state-handler.js.map +1 -1
  19. package/dist/store/signal-state-handler.d.ts +2 -2
  20. package/dist/store/signal-state-handler.js +2 -1
  21. package/dist/store/signal-state-handler.js.map +1 -1
  22. package/dist/types/types.d.ts +2 -1
  23. package/dist/utils/selector-cache.d.ts +13 -0
  24. package/dist/utils/selector-cache.js +22 -0
  25. package/dist/utils/selector-cache.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/hooks/__tests__/state-selector.spec.tsx +31 -20
  28. package/src/hooks/__tests__/state-singleton.spec.tsx +1 -1
  29. package/src/hooks/state-subscription-selector.tsx +5 -25
  30. package/src/store/__tests__/observable-state-handler.spec.ts +4 -4
  31. package/src/store/__tests__/signal-state-handler.spec.ts +68 -11
  32. package/src/store/base-state-handler.ts +47 -4
  33. package/src/store/observable-state-handler.ts +5 -9
  34. package/src/store/signal-state-handler.ts +5 -4
  35. package/src/types/types.ts +2 -1
  36. 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: Listener): () => void;
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;AAmBxE,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;IAED,SAAS,CAAC,QAAkB;QAC1B,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,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,EAAE,CAAC;QACb,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"}
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"}
@@ -1,5 +1,6 @@
1
1
  export interface StateSubscriptionHandler<V, A> {
2
- subscribe: (listener: () => void) => () => void;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veams/status-quo",
3
- "version": "1.3.2",
3
+ "version": "1.5.0",
4
4
  "description": "The manager to rule states in frontend.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -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 = (listener: () => void) => {
63
- this.listeners.add(listener);
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(listener);
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.listeners.forEach((listener) => listener());
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 = (listener: () => void) => {
120
- this.listeners.add(listener);
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(listener);
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.listeners.forEach((listener) => listener());
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.listeners.forEach((listener) => listener());
180
+ const nextMirrorState = this.state;
181
+ this.listeners.forEach((listener) => listener(nextMirrorState));
174
182
  });
175
183
  }
176
184
 
177
- subscribe = (listener: () => void) => {
178
- this.listeners.add(listener);
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(listener);
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: SelectorFn<V, Sel>,
37
+ selector: Selector<V, Sel>,
43
38
  isEqual: EqualityFn<Sel> = Object.is,
44
39
  destroyOnCleanup = true
45
40
  ) {
46
- const selectorCache = useMemo<SelectorCache<Sel>>(
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
- const nextSelection = selector(snapshot);
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(2); // 1. test (first setter), 2. test2 (second setter)
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(2);
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(1);
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(1);
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(2);
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(2);
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(1);
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(1);
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: { subscribe: (listener: (value: T) => void) => () => void; getSnapshot?: () => T },
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 unsubscribe = service.subscribe(onChange);
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) onChange(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
- let initialized = false;
75
- const subscription = this.getStateAsObservable().subscribe(() => {
76
- if (!initialized) {
77
- initialized = true;
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: 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
 
@@ -1,5 +1,6 @@
1
1
  export interface StateSubscriptionHandler<V, A> {
2
- subscribe: (listener: () => void) => () => void;
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
+ }