@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.
- package/.turbo/turbo-build.log +12 -0
- package/.turbo/turbo-check$colon$types.log +4 -0
- package/.turbo/turbo-docs$colon$build.log +14 -0
- package/.turbo/turbo-lint.log +8 -0
- package/.turbo/turbo-test.log +15 -0
- package/CHANGELOG.md +24 -3
- package/README.md +217 -41
- package/dist/config/status-quo-config.d.ts +13 -0
- package/dist/config/status-quo-config.js +14 -0
- package/dist/config/status-quo-config.js.map +1 -1
- package/dist/hooks/__tests__/state-provider.spec.d.ts +4 -0
- package/dist/hooks/__tests__/state-provider.spec.js +179 -0
- package/dist/hooks/__tests__/state-provider.spec.js.map +1 -0
- package/dist/hooks/__tests__/state-selector.spec.js +11 -12
- package/dist/hooks/__tests__/state-selector.spec.js.map +1 -1
- package/dist/hooks/__tests__/state-singleton.spec.js +10 -11
- package/dist/hooks/__tests__/state-singleton.spec.js.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/state-factory.js.map +1 -1
- package/dist/hooks/state-provider.d.ts +14 -0
- package/dist/hooks/state-provider.js +24 -0
- package/dist/hooks/state-provider.js.map +1 -0
- package/dist/hooks/state-subscription-selector.js +6 -2
- package/dist/hooks/state-subscription-selector.js.map +1 -1
- package/dist/hooks/state-subscription.js +1 -1
- package/dist/hooks/state-subscription.js.map +1 -1
- package/dist/index.d.ts +4 -5
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/dist/react/hooks/__tests__/state-provider.spec.d.ts +4 -0
- package/dist/react/hooks/__tests__/state-provider.spec.js +179 -0
- package/dist/react/hooks/__tests__/state-provider.spec.js.map +1 -0
- package/dist/react/hooks/__tests__/state-selector.spec.d.ts +4 -0
- package/dist/react/hooks/__tests__/state-selector.spec.js +547 -0
- package/dist/react/hooks/__tests__/state-selector.spec.js.map +1 -0
- package/dist/react/hooks/__tests__/state-singleton.spec.d.ts +4 -0
- package/dist/react/hooks/__tests__/state-singleton.spec.js +96 -0
- package/dist/react/hooks/__tests__/state-singleton.spec.js.map +1 -0
- package/{src/hooks/index.ts → dist/react/hooks/index.d.ts} +1 -0
- package/dist/react/hooks/index.js +7 -0
- package/dist/react/hooks/index.js.map +1 -0
- package/dist/react/hooks/state-actions.d.ts +2 -0
- package/dist/react/hooks/state-actions.js +5 -0
- package/dist/react/hooks/state-actions.js.map +1 -0
- package/dist/react/hooks/state-factory.d.ts +7 -0
- package/dist/react/hooks/state-factory.js +13 -0
- package/dist/react/hooks/state-factory.js.map +1 -0
- package/dist/react/hooks/state-handler.d.ts +2 -0
- package/dist/react/hooks/state-handler.js +9 -0
- package/dist/react/hooks/state-handler.js.map +1 -0
- package/dist/react/hooks/state-provider.d.ts +14 -0
- package/dist/react/hooks/state-provider.js +24 -0
- package/dist/react/hooks/state-provider.js.map +1 -0
- package/dist/react/hooks/state-singleton.d.ts +6 -0
- package/dist/react/hooks/state-singleton.js +7 -0
- package/dist/react/hooks/state-singleton.js.map +1 -0
- package/dist/react/hooks/state-subscription-selector.d.ts +3 -0
- package/dist/react/hooks/state-subscription-selector.js +114 -0
- package/dist/react/hooks/state-subscription-selector.js.map +1 -0
- package/dist/react/hooks/state-subscription.d.ts +9 -0
- package/dist/react/hooks/state-subscription.js +53 -0
- package/dist/react/hooks/state-subscription.js.map +1 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -0
- package/dist/store/__tests__/observable-state-handler.spec.js +66 -11
- package/dist/store/__tests__/observable-state-handler.spec.js.map +1 -1
- package/dist/store/__tests__/signal-state-handler.spec.js.map +1 -1
- package/dist/store/base-state-handler.d.ts +3 -5
- package/dist/store/base-state-handler.js +10 -9
- package/dist/store/base-state-handler.js.map +1 -1
- package/dist/store/dev-tools.js +0 -3
- package/dist/store/dev-tools.js.map +1 -1
- package/dist/store/observable-state-handler.d.ts +4 -10
- package/dist/store/observable-state-handler.js +4 -11
- package/dist/store/observable-state-handler.js.map +1 -1
- package/dist/store/signal-state-handler.d.ts +2 -5
- package/dist/store/signal-state-handler.js +3 -2
- package/dist/store/signal-state-handler.js.map +1 -1
- package/dist/store/state-singleton.js +1 -1
- package/dist/store/state-singleton.js.map +1 -1
- package/eslint.config.mjs +75 -0
- package/package.json +18 -18
- package/src/config/status-quo-config.ts +31 -1
- package/src/index.ts +11 -15
- package/src/react/hooks/__tests__/state-provider.spec.tsx +286 -0
- package/src/{hooks → react/hooks}/__tests__/state-selector.spec.tsx +118 -44
- package/src/{hooks → react/hooks}/__tests__/state-singleton.spec.tsx +21 -20
- package/src/react/hooks/index.ts +11 -0
- package/src/{hooks → react/hooks}/state-actions.tsx +1 -1
- package/src/{hooks → react/hooks}/state-factory.tsx +2 -2
- package/src/{hooks → react/hooks}/state-handler.tsx +1 -1
- package/src/react/hooks/state-provider.tsx +56 -0
- package/src/{hooks → react/hooks}/state-singleton.tsx +1 -1
- package/src/react/hooks/state-subscription-selector.tsx +190 -0
- package/src/{hooks → react/hooks}/state-subscription.tsx +5 -9
- package/src/react/index.ts +1 -0
- package/src/store/__tests__/observable-state-handler.spec.ts +92 -13
- package/src/store/__tests__/signal-state-handler.spec.ts +5 -1
- package/src/store/base-state-handler.ts +17 -22
- package/src/store/dev-tools.ts +3 -3
- package/src/store/observable-state-handler.ts +12 -22
- package/src/store/signal-state-handler.ts +11 -8
- package/src/store/state-singleton.ts +1 -1
- package/tsconfig.json +2 -3
- package/.eslintrc.cjs +0 -132
- package/.github/workflows/pages.yml +0 -46
- package/.github/workflows/release.yml +0 -33
- package/.nvmrc +0 -1
- package/.prettierrc +0 -7
- package/docs/assets/index-BBmpszOW.css +0 -1
- package/docs/assets/index-Cf8El_RO.js +0 -194
- package/docs/assets/statusquo-logo-8GVRbxpc.png +0 -0
- package/docs/index.html +0 -13
- package/playground/index.html +0 -12
- package/playground/src/App.tsx +0 -699
- package/playground/src/assets/philosophy-agnostic.svg +0 -18
- package/playground/src/assets/philosophy-separation.svg +0 -13
- package/playground/src/assets/philosophy-swap.svg +0 -17
- package/playground/src/assets/statusquo-logo.png +0 -0
- package/playground/src/main.tsx +0 -19
- package/playground/src/styles.css +0 -534
- package/playground/tsconfig.json +0 -12
- package/playground/vite.config.ts +0 -18
- 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 {
|
|
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
|
-
|
|
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 = ({
|
|
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).
|
|
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
|
-
|
|
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
|
|
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 '
|
|
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 '
|
|
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)
|
|
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 '
|
|
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 '
|
|
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 '
|
|
7
|
-
import type { StateSubscriptionHandler } from '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
...(
|
|
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.
|
|
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
|
|
106
|
-
const observableValue = await lastValueFrom(stateHandler.
|
|
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',
|
|
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<
|
|
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
|