@veams/status-quo 1.5.1 → 1.7.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 (127) hide show
  1. package/.turbo/turbo-build.log +12 -0
  2. package/.turbo/turbo-check$colon$types.log +4 -0
  3. package/.turbo/turbo-docs$colon$build.log +14 -0
  4. package/.turbo/turbo-lint.log +8 -0
  5. package/.turbo/turbo-test.log +15 -0
  6. package/CHANGELOG.md +24 -3
  7. package/README.md +217 -41
  8. package/dist/config/status-quo-config.d.ts +13 -0
  9. package/dist/config/status-quo-config.js +14 -0
  10. package/dist/config/status-quo-config.js.map +1 -1
  11. package/dist/hooks/__tests__/state-provider.spec.d.ts +4 -0
  12. package/dist/hooks/__tests__/state-provider.spec.js +179 -0
  13. package/dist/hooks/__tests__/state-provider.spec.js.map +1 -0
  14. package/dist/hooks/__tests__/state-selector.spec.js +11 -12
  15. package/dist/hooks/__tests__/state-selector.spec.js.map +1 -1
  16. package/dist/hooks/__tests__/state-singleton.spec.js +10 -11
  17. package/dist/hooks/__tests__/state-singleton.spec.js.map +1 -1
  18. package/dist/hooks/index.d.ts +1 -0
  19. package/dist/hooks/index.js +1 -0
  20. package/dist/hooks/index.js.map +1 -1
  21. package/dist/hooks/state-factory.js.map +1 -1
  22. package/dist/hooks/state-provider.d.ts +14 -0
  23. package/dist/hooks/state-provider.js +24 -0
  24. package/dist/hooks/state-provider.js.map +1 -0
  25. package/dist/hooks/state-subscription-selector.js +6 -2
  26. package/dist/hooks/state-subscription-selector.js.map +1 -1
  27. package/dist/hooks/state-subscription.js +1 -1
  28. package/dist/hooks/state-subscription.js.map +1 -1
  29. package/dist/index.d.ts +4 -5
  30. package/dist/index.js +2 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/react/hooks/__tests__/state-provider.spec.d.ts +4 -0
  33. package/dist/react/hooks/__tests__/state-provider.spec.js +179 -0
  34. package/dist/react/hooks/__tests__/state-provider.spec.js.map +1 -0
  35. package/dist/react/hooks/__tests__/state-selector.spec.d.ts +4 -0
  36. package/dist/react/hooks/__tests__/state-selector.spec.js +547 -0
  37. package/dist/react/hooks/__tests__/state-selector.spec.js.map +1 -0
  38. package/dist/react/hooks/__tests__/state-singleton.spec.d.ts +4 -0
  39. package/dist/react/hooks/__tests__/state-singleton.spec.js +96 -0
  40. package/dist/react/hooks/__tests__/state-singleton.spec.js.map +1 -0
  41. package/{src/hooks/index.ts → dist/react/hooks/index.d.ts} +1 -0
  42. package/dist/react/hooks/index.js +7 -0
  43. package/dist/react/hooks/index.js.map +1 -0
  44. package/dist/react/hooks/state-actions.d.ts +2 -0
  45. package/dist/react/hooks/state-actions.js +5 -0
  46. package/dist/react/hooks/state-actions.js.map +1 -0
  47. package/dist/react/hooks/state-factory.d.ts +7 -0
  48. package/dist/react/hooks/state-factory.js +13 -0
  49. package/dist/react/hooks/state-factory.js.map +1 -0
  50. package/dist/react/hooks/state-handler.d.ts +2 -0
  51. package/dist/react/hooks/state-handler.js +9 -0
  52. package/dist/react/hooks/state-handler.js.map +1 -0
  53. package/dist/react/hooks/state-provider.d.ts +14 -0
  54. package/dist/react/hooks/state-provider.js +24 -0
  55. package/dist/react/hooks/state-provider.js.map +1 -0
  56. package/dist/react/hooks/state-singleton.d.ts +6 -0
  57. package/dist/react/hooks/state-singleton.js +7 -0
  58. package/dist/react/hooks/state-singleton.js.map +1 -0
  59. package/dist/react/hooks/state-subscription-selector.d.ts +3 -0
  60. package/dist/react/hooks/state-subscription-selector.js +114 -0
  61. package/dist/react/hooks/state-subscription-selector.js.map +1 -0
  62. package/dist/react/hooks/state-subscription.d.ts +9 -0
  63. package/dist/react/hooks/state-subscription.js +53 -0
  64. package/dist/react/hooks/state-subscription.js.map +1 -0
  65. package/dist/react/index.d.ts +1 -0
  66. package/dist/react/index.js +2 -0
  67. package/dist/react/index.js.map +1 -0
  68. package/dist/store/__tests__/observable-state-handler.spec.js +66 -11
  69. package/dist/store/__tests__/observable-state-handler.spec.js.map +1 -1
  70. package/dist/store/__tests__/signal-state-handler.spec.js.map +1 -1
  71. package/dist/store/base-state-handler.d.ts +3 -5
  72. package/dist/store/base-state-handler.js +10 -9
  73. package/dist/store/base-state-handler.js.map +1 -1
  74. package/dist/store/dev-tools.js +0 -3
  75. package/dist/store/dev-tools.js.map +1 -1
  76. package/dist/store/observable-state-handler.d.ts +4 -10
  77. package/dist/store/observable-state-handler.js +4 -11
  78. package/dist/store/observable-state-handler.js.map +1 -1
  79. package/dist/store/signal-state-handler.d.ts +2 -5
  80. package/dist/store/signal-state-handler.js +3 -2
  81. package/dist/store/signal-state-handler.js.map +1 -1
  82. package/dist/store/state-singleton.js +1 -1
  83. package/dist/store/state-singleton.js.map +1 -1
  84. package/eslint.config.mjs +75 -0
  85. package/package.json +18 -18
  86. package/src/config/status-quo-config.ts +31 -1
  87. package/src/index.ts +11 -15
  88. package/src/react/hooks/__tests__/state-provider.spec.tsx +286 -0
  89. package/src/{hooks → react/hooks}/__tests__/state-selector.spec.tsx +118 -44
  90. package/src/{hooks → react/hooks}/__tests__/state-singleton.spec.tsx +21 -20
  91. package/src/react/hooks/index.ts +11 -0
  92. package/src/{hooks → react/hooks}/state-actions.tsx +1 -1
  93. package/src/{hooks → react/hooks}/state-factory.tsx +2 -2
  94. package/src/{hooks → react/hooks}/state-handler.tsx +1 -1
  95. package/src/react/hooks/state-provider.tsx +56 -0
  96. package/src/{hooks → react/hooks}/state-singleton.tsx +1 -1
  97. package/src/react/hooks/state-subscription-selector.tsx +190 -0
  98. package/src/{hooks → react/hooks}/state-subscription.tsx +5 -9
  99. package/src/react/index.ts +1 -0
  100. package/src/store/__tests__/observable-state-handler.spec.ts +92 -13
  101. package/src/store/__tests__/signal-state-handler.spec.ts +5 -1
  102. package/src/store/base-state-handler.ts +17 -22
  103. package/src/store/dev-tools.ts +3 -3
  104. package/src/store/observable-state-handler.ts +12 -22
  105. package/src/store/signal-state-handler.ts +11 -8
  106. package/src/store/state-singleton.ts +1 -1
  107. package/tsconfig.json +2 -3
  108. package/.eslintrc.cjs +0 -132
  109. package/.github/workflows/pages.yml +0 -46
  110. package/.github/workflows/release.yml +0 -33
  111. package/.nvmrc +0 -1
  112. package/.prettierrc +0 -7
  113. package/docs/assets/index-BBmpszOW.css +0 -1
  114. package/docs/assets/index-Cf8El_RO.js +0 -194
  115. package/docs/assets/statusquo-logo-8GVRbxpc.png +0 -0
  116. package/docs/index.html +0 -13
  117. package/playground/index.html +0 -12
  118. package/playground/src/App.tsx +0 -699
  119. package/playground/src/assets/philosophy-agnostic.svg +0 -18
  120. package/playground/src/assets/philosophy-separation.svg +0 -13
  121. package/playground/src/assets/philosophy-swap.svg +0 -17
  122. package/playground/src/assets/statusquo-logo.png +0 -0
  123. package/playground/src/main.tsx +0 -19
  124. package/playground/src/styles.css +0 -534
  125. package/playground/tsconfig.json +0 -12
  126. package/playground/vite.config.ts +0 -18
  127. package/src/hooks/state-subscription-selector.tsx +0 -111
@@ -1,16 +1,14 @@
1
- import React from 'react';
2
- import { act } from 'react';
1
+ import React, { act } from 'react';
3
2
  import { createRoot } from 'react-dom/client';
4
3
 
4
+ import { makeStateSingleton } from '../../../store';
5
5
  import { useStateSingleton } from '../state-singleton.js';
6
6
 
7
- import { makeStateSingleton } from '../../store';
8
-
9
- import type { StateSubscriptionHandler } from '../../types/types.js';
7
+ import type { StateSubscriptionHandler } from '../../../types/types.js';
10
8
 
11
9
  declare global {
12
10
  // React 19 requires this flag in test environments that use manual act() calls.
13
- // eslint-disable-next-line no-var
11
+
14
12
  var IS_REACT_ACT_ENVIRONMENT: boolean;
15
13
  }
16
14
 
@@ -39,7 +37,11 @@ const createStateHandler = (value: number): TestHandler => {
39
37
  };
40
38
  };
41
39
 
42
- const SingletonConsumer = ({ singleton }: { singleton: ReturnType<typeof makeStateSingleton<TestState, TestActions>> }) => {
40
+ const SingletonConsumer = ({
41
+ singleton,
42
+ }: {
43
+ singleton: ReturnType<typeof makeStateSingleton<TestState, TestActions>>;
44
+ }) => {
43
45
  useStateSingleton(singleton);
44
46
 
45
47
  return null;
@@ -77,10 +79,10 @@ describe('useStateSingleton', () => {
77
79
 
78
80
  act(() => {
79
81
  root.render(
80
- <>
82
+ <React.Fragment>
81
83
  <SingletonConsumer singleton={singleton} />
82
84
  <SingletonConsumer singleton={singleton} />
83
- </>
85
+ </React.Fragment>
84
86
  );
85
87
  });
86
88
 
@@ -91,27 +93,28 @@ describe('useStateSingleton', () => {
91
93
  expect(firstHandler.destroy).not.toHaveBeenCalled();
92
94
 
93
95
  act(() => {
94
- root.render(<></>);
96
+ root.render(<React.Fragment />);
95
97
  });
96
98
 
97
- expect(firstHandler.destroy).toHaveBeenCalledTimes(1);
99
+ expect(firstHandler.destroy).not.toHaveBeenCalled();
98
100
  });
99
101
 
100
- it('should create a new singleton instance after all consumers unmount', () => {
102
+ it('should create a new singleton instance after all consumers unmount when destroyOnNoConsumers is true', () => {
101
103
  const firstHandler = createStateHandler(1);
102
104
  const secondHandler = createStateHandler(2);
103
105
  const factory = jest.fn(() => firstHandler as StateSubscriptionHandler<TestState, TestActions>);
104
106
 
105
107
  factory.mockReturnValueOnce(firstHandler).mockReturnValueOnce(secondHandler);
106
-
107
- const singleton = makeStateSingleton<TestState, TestActions>(factory);
108
+ const singleton = makeStateSingleton<TestState, TestActions>(factory, {
109
+ destroyOnNoConsumers: true,
110
+ });
108
111
 
109
112
  act(() => {
110
113
  root.render(<SingletonConsumer singleton={singleton} />);
111
114
  });
112
115
 
113
116
  act(() => {
114
- root.render(<></>);
117
+ root.render(<React.Fragment />);
115
118
  });
116
119
 
117
120
  expect(firstHandler.destroy).toHaveBeenCalledTimes(1);
@@ -124,19 +127,17 @@ describe('useStateSingleton', () => {
124
127
  expect(singleton.getInstance().getSnapshot()).toStrictEqual({ value: 2 });
125
128
  });
126
129
 
127
- it('should keep singleton instance alive when destroyOnNoConsumers is false', () => {
130
+ it('should keep singleton instance alive by default', () => {
128
131
  const firstHandler = createStateHandler(1);
129
132
  const factory = jest.fn(() => firstHandler as StateSubscriptionHandler<TestState, TestActions>);
130
- const singleton = makeStateSingleton<TestState, TestActions>(factory, {
131
- destroyOnNoConsumers: false,
132
- });
133
+ const singleton = makeStateSingleton<TestState, TestActions>(factory);
133
134
 
134
135
  act(() => {
135
136
  root.render(<SingletonConsumer singleton={singleton} />);
136
137
  });
137
138
 
138
139
  act(() => {
139
- root.render(<></>);
140
+ root.render(<React.Fragment />);
140
141
  });
141
142
 
142
143
  expect(firstHandler.destroy).not.toHaveBeenCalled();
@@ -0,0 +1,11 @@
1
+ export { useStateActions } from './state-actions.js';
2
+ export { useStateFactory } from './state-factory.js';
3
+ export { useStateHandler } from './state-handler.js';
4
+ export {
5
+ StateProvider,
6
+ useProvidedStateActions,
7
+ useProvidedStateHandler,
8
+ useProvidedStateSubscription,
9
+ } from './state-provider.js';
10
+ export { useStateSingleton } from './state-singleton.js';
11
+ export { useStateSubscription } from './state-subscription.js';
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from 'react';
2
2
 
3
- import type { StateSubscriptionHandler } from '../types/types.js';
3
+ import type { StateSubscriptionHandler } from '../../types/types.js';
4
4
 
5
5
  export function useStateActions<V, A>(stateSubscriptionHandler: StateSubscriptionHandler<V, A>) {
6
6
  return useMemo(() => stateSubscriptionHandler.getActions(), [stateSubscriptionHandler]);
@@ -1,7 +1,7 @@
1
1
  import { useStateHandler } from './state-handler.js';
2
2
  import { useStateSubscription } from './state-subscription.js';
3
3
 
4
- import type { StateSubscriptionHandler } from '../types/types.js';
4
+ import type { StateSubscriptionHandler } from '../../types/types.js';
5
5
 
6
6
  type StateSelector<State, SelectedState> = (state: State) => SelectedState;
7
7
  type EqualityFn<SelectedState> = (current: SelectedState, next: SelectedState) => boolean;
@@ -32,7 +32,7 @@ export function useStateFactory<V, A, P extends unknown[], Sel = V>(
32
32
  const hasSelector = typeof selectorOrParams === 'function';
33
33
  const selector = (hasSelector ? selectorOrParams : identitySelector) as StateSelector<V, Sel>;
34
34
  const hasCustomEquality = hasSelector && typeof isEqualOrParams === 'function';
35
- const isEqual = (hasCustomEquality ? isEqualOrParams : Object.is) as EqualityFn<Sel>;
35
+ const isEqual = (hasCustomEquality ? isEqualOrParams : Object.is);
36
36
  const stateFactoryParams = (
37
37
  hasSelector ? (hasCustomEquality ? params : isEqualOrParams) : selectorOrParams
38
38
  ) as P;
@@ -1,6 +1,6 @@
1
1
  import { useRef } from 'react';
2
2
 
3
- import type { StateSubscriptionHandler } from '../types/types.js';
3
+ import type { StateSubscriptionHandler } from '../../types/types.js';
4
4
 
5
5
  export function useStateHandler<V, A, P extends unknown[]>(
6
6
  stateFactoryFunction: (...args: P) => StateSubscriptionHandler<V, A>,
@@ -0,0 +1,56 @@
1
+ import React, { createContext, useContext } from 'react';
2
+
3
+ import { useStateActions } from './state-actions.js';
4
+ import { useStateSubscription } from './state-subscription.js';
5
+
6
+ import type { PropsWithChildren } from 'react';
7
+ import type { StateSubscriptionHandler } from '../../types/types.js';
8
+
9
+ type StateSelector<State, SelectedState> = (state: State) => SelectedState;
10
+ type EqualityFn<SelectedState> = (current: SelectedState, next: SelectedState) => boolean;
11
+ type SharedStateHandler = StateSubscriptionHandler<unknown, unknown>;
12
+
13
+ const StateProviderContext = createContext<SharedStateHandler | null>(null);
14
+ const identitySelector = <State,>(state: State) => state;
15
+
16
+ export type StateProviderProps<V, A> = PropsWithChildren<{
17
+ instance: StateSubscriptionHandler<V, A>;
18
+ }>;
19
+
20
+ export function useProvidedStateHandler<V, A>() {
21
+ const stateHandler = useContext(StateProviderContext);
22
+
23
+ if (!stateHandler) {
24
+ throw new Error('No StateProvider instance found in the current React tree.');
25
+ }
26
+
27
+ return stateHandler as StateSubscriptionHandler<V, A>;
28
+ }
29
+
30
+ export function StateProvider<V, A>({ children, instance }: StateProviderProps<V, A>) {
31
+ return (
32
+ <StateProviderContext.Provider value={instance as SharedStateHandler}>
33
+ {children}
34
+ </StateProviderContext.Provider>
35
+ );
36
+ }
37
+
38
+ export function useProvidedStateActions<V, A>() {
39
+ const stateHandler = useProvidedStateHandler<V, A>();
40
+
41
+ return useStateActions(stateHandler);
42
+ }
43
+
44
+ export function useProvidedStateSubscription<V, A>(): [V, A];
45
+ export function useProvidedStateSubscription<V, A, Sel>(
46
+ selector: StateSelector<V, Sel>,
47
+ isEqual?: EqualityFn<Sel>
48
+ ): [Sel, A];
49
+ export function useProvidedStateSubscription<V, A, Sel = V>(
50
+ selector: StateSelector<V, Sel> = identitySelector as StateSelector<V, Sel>,
51
+ isEqual: EqualityFn<Sel> = Object.is
52
+ ) {
53
+ const stateHandler = useProvidedStateHandler<V, A>();
54
+
55
+ return useStateSubscription(stateHandler, selector, isEqual);
56
+ }
@@ -1,6 +1,6 @@
1
1
  import { useStateSubscription } from './state-subscription.js';
2
2
 
3
- import type { StateSingleton } from '../store/state-singleton.js';
3
+ import type { StateSingleton } from '../../store/state-singleton.js';
4
4
 
5
5
  type StateSelector<State, SelectedState> = (state: State) => SelectedState;
6
6
  type EqualityFn<SelectedState> = (current: SelectedState, next: SelectedState) => boolean;
@@ -0,0 +1,190 @@
1
+ import { useCallback, useRef, useSyncExternalStore } from 'react';
2
+
3
+ import { createSelectorCache, selectWithCache } from '../../utils/selector-cache.js';
4
+
5
+ import type { StateSubscriptionHandler } from '../../types/types.js';
6
+ import type { EqualityFn, Selector } from '../../utils/selector-cache.js';
7
+
8
+ type Listener = () => void;
9
+ type SharedStateSubscriptionHandler = StateSubscriptionHandler<unknown, unknown>;
10
+ type DeferredDestroy = {
11
+ refCount: number;
12
+ timeoutId: ReturnType<typeof setTimeout> | null;
13
+ };
14
+ type SnapshotCacheEntry<Source, Selected> = {
15
+ selectedSnapshot: Selected;
16
+ sourceSnapshot: Source;
17
+ version: number;
18
+ };
19
+ type ServerSnapshotCacheEntry<Source, Selected> = {
20
+ selectedSnapshot: Selected;
21
+ sourceSnapshot: Source;
22
+ };
23
+
24
+ const deferredDestroyMap = new WeakMap<SharedStateSubscriptionHandler, DeferredDestroy>();
25
+
26
+ function getDeferredDestroyState(
27
+ stateSubscriptionHandler: SharedStateSubscriptionHandler
28
+ ): DeferredDestroy {
29
+ const existingState = deferredDestroyMap.get(stateSubscriptionHandler);
30
+
31
+ if (existingState) {
32
+ return existingState;
33
+ }
34
+
35
+ const nextState: DeferredDestroy = {
36
+ refCount: 0,
37
+ timeoutId: null,
38
+ };
39
+
40
+ deferredDestroyMap.set(stateSubscriptionHandler, nextState);
41
+
42
+ return nextState;
43
+ }
44
+
45
+ export function useStateSubscriptionSelector<V, A, Sel>(
46
+ stateSubscriptionHandler: StateSubscriptionHandler<V, A>,
47
+ selector: Selector<V, Sel>,
48
+ isEqual: EqualityFn<Sel> = Object.is,
49
+ destroyOnCleanup = true
50
+ ) {
51
+ const selectorCacheRef = useRef<ReturnType<typeof createSelectorCache<Sel>> | null>(null);
52
+ // Tracks store notifications so getSnapshot can reuse the same selected value
53
+ // within one store version. This keeps useSyncExternalStore reads referentially stable.
54
+ const snapshotVersionRef = useRef(0);
55
+ // Client-side cache for selected snapshots per source snapshot/version pair.
56
+ const snapshotCacheRef = useRef<SnapshotCacheEntry<V, Sel> | null>(null);
57
+ // Separate cache for the server snapshot function used by SSR/hydration paths.
58
+ const serverSnapshotCacheRef = useRef<ServerSnapshotCacheEntry<V, Sel> | null>(null);
59
+
60
+ if (!selectorCacheRef.current) {
61
+ selectorCacheRef.current = createSelectorCache<Sel>();
62
+ }
63
+
64
+ const selectorCache = selectorCacheRef.current;
65
+
66
+ const subscribe = useCallback(
67
+ (listener: Listener) => {
68
+ const sharedStateSubscriptionHandler =
69
+ stateSubscriptionHandler as unknown as SharedStateSubscriptionHandler;
70
+ const deferredDestroyState = getDeferredDestroyState(sharedStateSubscriptionHandler);
71
+ deferredDestroyState.refCount += 1;
72
+
73
+ if (deferredDestroyState.timeoutId) {
74
+ clearTimeout(deferredDestroyState.timeoutId);
75
+ deferredDestroyState.timeoutId = null;
76
+ }
77
+
78
+ const unsubscribe = stateSubscriptionHandler.subscribe(() => {
79
+ // Invalidate the selected snapshot cache before notifying React.
80
+ // Any next getSnapshot call should recompute from the new store state.
81
+ snapshotVersionRef.current += 1;
82
+ snapshotCacheRef.current = null;
83
+ listener();
84
+ });
85
+
86
+ return () => {
87
+ unsubscribe();
88
+
89
+ if (!destroyOnCleanup) {
90
+ return;
91
+ }
92
+
93
+ const activeDeferredDestroyState = deferredDestroyMap.get(sharedStateSubscriptionHandler);
94
+
95
+ if (!activeDeferredDestroyState) {
96
+ return;
97
+ }
98
+
99
+ activeDeferredDestroyState.refCount -= 1;
100
+
101
+ if (activeDeferredDestroyState.refCount > 0) {
102
+ return;
103
+ }
104
+
105
+ activeDeferredDestroyState.refCount = 0;
106
+ activeDeferredDestroyState.timeoutId = setTimeout(() => {
107
+ const pendingDeferredDestroyState = deferredDestroyMap.get(
108
+ sharedStateSubscriptionHandler
109
+ );
110
+
111
+ if (!pendingDeferredDestroyState || pendingDeferredDestroyState.refCount > 0) {
112
+ return;
113
+ }
114
+
115
+ pendingDeferredDestroyState.timeoutId = null;
116
+ stateSubscriptionHandler.destroy();
117
+ deferredDestroyMap.delete(sharedStateSubscriptionHandler);
118
+ }, 0);
119
+ };
120
+ },
121
+ [destroyOnCleanup, stateSubscriptionHandler]
122
+ );
123
+
124
+ const selectSnapshot = useCallback(
125
+ (snapshot: V) => {
126
+ return selectWithCache(selectorCache, snapshot, selector, isEqual).value;
127
+ },
128
+ [isEqual, selector, selectorCache]
129
+ );
130
+
131
+ const selectorCacheControlRef = useRef(selectSnapshot);
132
+
133
+ if (selectorCacheControlRef.current !== selectSnapshot) {
134
+ // Selector/equality changes define a new selection strategy, so clear all caches.
135
+ selectorCacheControlRef.current = selectSnapshot;
136
+ snapshotVersionRef.current = 0;
137
+ snapshotCacheRef.current = null;
138
+ serverSnapshotCacheRef.current = null;
139
+ }
140
+
141
+ const getSnapshot = useCallback(
142
+ () => {
143
+ const sourceSnapshot = stateSubscriptionHandler.getSnapshot();
144
+ const version = snapshotVersionRef.current;
145
+ const cachedSnapshot = snapshotCacheRef.current;
146
+
147
+ if (
148
+ cachedSnapshot &&
149
+ cachedSnapshot.version === version &&
150
+ Object.is(cachedSnapshot.sourceSnapshot, sourceSnapshot)
151
+ ) {
152
+ // Same source snapshot in the same store version: return the exact same selected reference.
153
+ return cachedSnapshot.selectedSnapshot;
154
+ }
155
+
156
+ const selectedSnapshot = selectSnapshot(sourceSnapshot);
157
+ snapshotCacheRef.current = {
158
+ selectedSnapshot,
159
+ sourceSnapshot,
160
+ version,
161
+ };
162
+
163
+ return selectedSnapshot;
164
+ },
165
+ [selectSnapshot, stateSubscriptionHandler]
166
+ );
167
+
168
+ const getServerSnapshot = useCallback(
169
+ () => {
170
+ const sourceSnapshot = stateSubscriptionHandler.getInitialState();
171
+ const cachedSnapshot = serverSnapshotCacheRef.current;
172
+
173
+ if (cachedSnapshot && Object.is(cachedSnapshot.sourceSnapshot, sourceSnapshot)) {
174
+ // Keep server snapshot reads stable for hydration by reusing cached selection.
175
+ return cachedSnapshot.selectedSnapshot;
176
+ }
177
+
178
+ const selectedSnapshot = selectSnapshot(sourceSnapshot);
179
+ serverSnapshotCacheRef.current = {
180
+ selectedSnapshot,
181
+ sourceSnapshot,
182
+ };
183
+
184
+ return selectedSnapshot;
185
+ },
186
+ [selectSnapshot, stateSubscriptionHandler]
187
+ );
188
+
189
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
190
+ }
@@ -3,8 +3,8 @@ import { useEffect, useMemo } from 'react';
3
3
  import { useStateActions } from './state-actions.js';
4
4
  import { useStateSubscriptionSelector } from './state-subscription-selector.js';
5
5
 
6
- import type { StateSingleton } from '../store/state-singleton.js';
7
- import type { StateSubscriptionHandler } from '../types/types.js';
6
+ import type { StateSingleton } from '../../store/state-singleton.js';
7
+ import type { StateSubscriptionHandler } from '../../types/types.js';
8
8
 
9
9
  type StateSelector<State, SelectedState> = (state: State) => SelectedState;
10
10
  type EqualityFn<SelectedState> = (current: SelectedState, next: SelectedState) => boolean;
@@ -27,17 +27,13 @@ function isStateSingleton<V, A>(
27
27
  return 'getInstance' in source;
28
28
  }
29
29
 
30
- export function useStateSubscription<V, A>(
31
- source: StateSubscriptionHandler<V, A>
32
- ): [V, A];
30
+ export function useStateSubscription<V, A>(source: StateSubscriptionHandler<V, A>): [V, A];
33
31
  export function useStateSubscription<V, A, Sel>(
34
32
  source: StateSubscriptionHandler<V, A>,
35
33
  selector: StateSelector<V, Sel>,
36
34
  isEqual?: EqualityFn<Sel>
37
35
  ): [Sel, A];
38
- export function useStateSubscription<V, A>(
39
- source: StateSingleton<V, A>
40
- ): [V, A];
36
+ export function useStateSubscription<V, A>(source: StateSingleton<V, A>): [V, A];
41
37
  export function useStateSubscription<V, A, Sel>(
42
38
  source: StateSingleton<V, A>,
43
39
  selector: StateSelector<V, Sel>,
@@ -83,7 +79,7 @@ export function useStateSubscription<V, A, Sel = V>(
83
79
 
84
80
  if (activeReference.count <= 0) {
85
81
  singletonReferences.delete(singleton);
86
- if (singleton.destroyOnNoConsumers === false) {
82
+ if (singleton.destroyOnNoConsumers !== true) {
87
83
  return;
88
84
  }
89
85
 
@@ -0,0 +1 @@
1
+ export * from './hooks/index.js';
@@ -2,30 +2,30 @@ import { lastValueFrom, Subject, take } from 'rxjs';
2
2
 
3
3
  import { resetStatusQuoForTests, setupStatusQuo } from '../../config/status-quo-config.js';
4
4
  import { ObservableStateHandler } from '../observable-state-handler.js';
5
- import type { DistinctOptions } from '../../config/status-quo-config.js';
5
+
6
+ import type { DevToolsOptions, DistinctOptions } from '../../config/status-quo-config.js';
6
7
 
7
8
  type TestState = { test: string; test2: string };
8
9
  type TestActions = { testAction: () => void };
9
10
  type TestObservableHandlerOptions = {
10
- withDevTools?: boolean;
11
+ devTools?: DevToolsOptions;
11
12
  distinct?: DistinctOptions<TestState>;
12
13
  useDistinctUntilChanged?: boolean;
13
14
  };
14
15
 
15
16
  class TestObservableStateHandler extends ObservableStateHandler<TestState, TestActions> {
16
- constructor({ withDevTools, distinct, useDistinctUntilChanged }: TestObservableHandlerOptions = {}) {
17
+ constructor({
18
+ devTools,
19
+ distinct,
20
+ useDistinctUntilChanged,
21
+ }: TestObservableHandlerOptions = {}) {
17
22
  super({
18
23
  initialState: {
19
24
  test: 'testValue',
20
25
  test2: 'testValue2',
21
26
  },
22
27
  options: {
23
- ...(withDevTools && {
24
- devTools: {
25
- enabled: true,
26
- namespace: 'TestObservableStateHandler',
27
- },
28
- }),
28
+ ...(devTools && { devTools }),
29
29
  ...(distinct && {
30
30
  distinct,
31
31
  }),
@@ -48,12 +48,26 @@ class TestObservableStateHandler extends ObservableStateHandler<TestState, TestA
48
48
  describe('Observable State Handler', () => {
49
49
  let stateHandler: TestObservableStateHandler;
50
50
 
51
+ function mockDevToolsExtension() {
52
+ const devTools = {
53
+ init: jest.fn(),
54
+ send: jest.fn(),
55
+ subscribe: jest.fn(),
56
+ };
57
+ const connect = jest.fn(() => devTools);
58
+
59
+ window.__REDUX_DEVTOOLS_EXTENSION__ = { connect };
60
+
61
+ return { connect, devTools };
62
+ }
63
+
51
64
  beforeEach(() => {
52
65
  resetStatusQuoForTests();
53
66
  stateHandler = new TestObservableStateHandler();
54
67
  });
55
68
 
56
69
  afterEach(() => {
70
+ delete window.__REDUX_DEVTOOLS_EXTENSION__;
57
71
  resetStatusQuoForTests();
58
72
  });
59
73
 
@@ -79,7 +93,7 @@ describe('Observable State Handler', () => {
79
93
 
80
94
  stateHandler.setState(expected);
81
95
 
82
- const state = await lastValueFrom(stateHandler.getStateAsObservable().pipe(take(1)));
96
+ const state = await lastValueFrom(stateHandler.getObservable().pipe(take(1)));
83
97
 
84
98
  expect(state).toStrictEqual(expected);
85
99
  expect(stateHandler.getState()).toStrictEqual(expected);
@@ -102,13 +116,13 @@ describe('Observable State Handler', () => {
102
116
  expect(spy).toHaveBeenCalledTimes(1);
103
117
  });
104
118
 
105
- it('should expose state item observable via getObservable', async () => {
106
- const observableValue = await lastValueFrom(stateHandler.getObservable('test').pipe(take(1)));
119
+ it('should expose state item observable via getObservableItem', async () => {
120
+ const observableValue = await lastValueFrom(stateHandler.getObservableItem('test').pipe(take(1)));
107
121
 
108
122
  expect(observableValue).toBe('testValue');
109
123
  });
110
124
 
111
- it('should only call subscriber when object state has changed', async () => {
125
+ it('should only call subscriber when object state has changed', () => {
112
126
  const spy = jest.fn();
113
127
 
114
128
  const unsubscribe = stateHandler.subscribe(spy);
@@ -192,4 +206,69 @@ describe('Observable State Handler', () => {
192
206
 
193
207
  expect(spy).toHaveBeenCalledTimes(2);
194
208
  });
209
+
210
+ it('should enable devtools from global setup and fall back to the class name as namespace', () => {
211
+ const { connect, devTools } = mockDevToolsExtension();
212
+
213
+ setupStatusQuo({
214
+ devTools: {
215
+ enabled: true,
216
+ },
217
+ });
218
+
219
+ new TestObservableStateHandler();
220
+
221
+ expect(connect).toHaveBeenCalledWith(
222
+ expect.objectContaining({
223
+ instanceId: 'testobservablestatehandler',
224
+ name: 'TestObservableStateHandler',
225
+ })
226
+ );
227
+ expect(devTools.init).toHaveBeenCalledWith({
228
+ test: 'testValue',
229
+ test2: 'testValue2',
230
+ });
231
+ expect(devTools.subscribe).toHaveBeenCalledTimes(1);
232
+ });
233
+
234
+ it('should prefer per-handler devtools options over global setup', () => {
235
+ const { connect } = mockDevToolsExtension();
236
+
237
+ setupStatusQuo({
238
+ devTools: {
239
+ enabled: true,
240
+ },
241
+ });
242
+
243
+ new TestObservableStateHandler({
244
+ devTools: {
245
+ namespace: 'LocalHandler',
246
+ },
247
+ });
248
+
249
+ expect(connect).toHaveBeenCalledWith(
250
+ expect.objectContaining({
251
+ instanceId: 'localhandler',
252
+ name: 'LocalHandler',
253
+ })
254
+ );
255
+ });
256
+
257
+ it('should allow per-handler devtools settings to disable a global devtools setup', () => {
258
+ const { connect } = mockDevToolsExtension();
259
+
260
+ setupStatusQuo({
261
+ devTools: {
262
+ enabled: true,
263
+ },
264
+ });
265
+
266
+ new TestObservableStateHandler({
267
+ devTools: {
268
+ enabled: false,
269
+ },
270
+ });
271
+
272
+ expect(connect).not.toHaveBeenCalled();
273
+ });
195
274
  });
@@ -1,6 +1,7 @@
1
1
  import { resetStatusQuoForTests, setupStatusQuo } from '../../config/status-quo-config.js';
2
2
  import { SignalStateHandler } from '../signal-state-handler.js';
3
3
  import { makeStateSingleton } from '../state-singleton.js';
4
+
4
5
  import type { DistinctOptions } from '../../config/status-quo-config.js';
5
6
 
6
7
  type TestState = { test: string; test2: string };
@@ -67,7 +68,10 @@ class CounterSignalStateHandler extends SignalStateHandler<CounterState, Counter
67
68
  }
68
69
  }
69
70
 
70
- class CounterSignalBridgeStateHandler extends SignalStateHandler<CounterState, { noop: () => void }> {
71
+ class CounterSignalBridgeStateHandler extends SignalStateHandler<
72
+ CounterState,
73
+ { noop: () => void }
74
+ > {
71
75
  constructor(
72
76
  counterSingleton: ReturnType<typeof makeStateSingleton<CounterState, CounterActions>>,
73
77
  onCounterSync: (counterState: CounterState) => void