@openmrs/esm-state 8.0.1-pre.3518 → 8.0.1-pre.3529
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 +1 -1
- package/dist/state.js +6 -11
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +11 -0
- package/package.json +9 -4
- package/src/state.test.ts +531 -0
- package/src/state.ts +6 -16
- package/src/utils.ts +16 -0
- package/vitest.config.ts +7 -0
package/.turbo/turbo-build.log
CHANGED
package/dist/state.js
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
/** @module @category Store */ import { shallowEqual } from "@openmrs/esm-utils";
|
|
2
2
|
import { createStore } from "zustand/vanilla";
|
|
3
|
+
import { isTestEnvironment } from "./utils.js";
|
|
3
4
|
const availableStores = {};
|
|
4
|
-
// Check if we're in a test environment (Vitest or Jest)
|
|
5
|
-
const isTestEnvironment = ()=>{
|
|
6
|
-
try {
|
|
7
|
-
return process.env.NODE_ENV === 'test' || typeof process !== 'undefined' && (process.env.VITEST === 'true' || process.env.JEST_WORKER_ID !== undefined) || typeof globalThis !== 'undefined' && ('__vitest_worker__' in globalThis || '__jest__' in globalThis);
|
|
8
|
-
} catch {
|
|
9
|
-
return false;
|
|
10
|
-
}
|
|
11
|
-
};
|
|
12
5
|
// spaEnv isn't available immediately. Wait a bit before making stores available
|
|
13
6
|
// on window in development mode.
|
|
14
|
-
setTimeout(()=>{
|
|
7
|
+
globalThis.setTimeout?.(()=>{
|
|
15
8
|
if (typeof window !== 'undefined' && window.spaEnv === 'development') {
|
|
16
9
|
window['stores'] = availableStores;
|
|
17
10
|
}
|
|
@@ -94,10 +87,12 @@ export function subscribeTo(...args) {
|
|
|
94
87
|
const [store, select, handle] = args;
|
|
95
88
|
const handler = typeof handle === 'undefined' ? select : handle;
|
|
96
89
|
const selector = typeof handle === 'undefined' ? (state)=>state : select;
|
|
97
|
-
|
|
98
|
-
|
|
90
|
+
let previous = selector(store.getState());
|
|
91
|
+
handler(previous);
|
|
92
|
+
return store.subscribe((state)=>{
|
|
99
93
|
const current = selector(state);
|
|
100
94
|
if (!shallowEqual(previous, current)) {
|
|
95
|
+
previous = current;
|
|
101
96
|
handler(current);
|
|
102
97
|
}
|
|
103
98
|
});
|
package/dist/utils.d.ts
ADDED
package/dist/utils.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if we're in a test environment (Vitest or Jest)
|
|
3
|
+
* Exported in a separate file so it can be mocked in tests
|
|
4
|
+
* @internal
|
|
5
|
+
*/ export function isTestEnvironment() {
|
|
6
|
+
try {
|
|
7
|
+
return process.env.NODE_ENV === 'test' || typeof process !== 'undefined' && (process.env.VITEST === 'true' || process.env.JEST_WORKER_ID !== undefined) || typeof globalThis !== 'undefined' && ('__vitest_worker__' in globalThis || '__jest__' in globalThis);
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmrs/esm-state",
|
|
3
|
-
"version": "8.0.1-pre.
|
|
3
|
+
"version": "8.0.1-pre.3529",
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
5
|
"description": "Frontend stores & state management for OpenMRS",
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
},
|
|
24
24
|
"source": true,
|
|
25
25
|
"scripts": {
|
|
26
|
+
"test": "cross-env TZ=UTC vitest run --passWithNoTests",
|
|
27
|
+
"test:watch": "cross-env TZ=UTC vitest watch --passWithNoTests",
|
|
28
|
+
"coverage": "cross-env TZ=UTC vitest run --coverage --passWithNoTests",
|
|
26
29
|
"build": "rimraf dist && concurrently \"swc --strip-leading-paths src -d dist\" \"tsc --project tsconfig.build.json\"",
|
|
27
30
|
"build:development": "rimraf dist && concurrently \"swc --strip-leading-paths src -d dist\" \"tsc --project tsconfig.build.json\"",
|
|
28
31
|
"typescript": "tsc --project tsconfig.build.json",
|
|
@@ -58,13 +61,15 @@
|
|
|
58
61
|
"@openmrs/esm-utils": "6.x"
|
|
59
62
|
},
|
|
60
63
|
"devDependencies": {
|
|
61
|
-
"@openmrs/esm-globals": "8.0.1-pre.
|
|
62
|
-
"@openmrs/esm-utils": "8.0.1-pre.
|
|
64
|
+
"@openmrs/esm-globals": "8.0.1-pre.3529",
|
|
65
|
+
"@openmrs/esm-utils": "8.0.1-pre.3529",
|
|
63
66
|
"@swc/cli": "^0.7.7",
|
|
64
67
|
"@swc/core": "^1.11.29",
|
|
68
|
+
"@vitest/coverage-v8": "^4.0.7",
|
|
65
69
|
"concurrently": "^9.1.2",
|
|
66
70
|
"cross-env": "^7.0.3",
|
|
67
|
-
"rimraf": "^6.0.1"
|
|
71
|
+
"rimraf": "^6.0.1",
|
|
72
|
+
"vitest": "^4.0.7"
|
|
68
73
|
},
|
|
69
74
|
"stableVersion": "8.0.0"
|
|
70
75
|
}
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { createStore } from 'zustand/vanilla';
|
|
3
|
+
import { createGlobalStore, registerGlobalStore, getGlobalStore, subscribeTo } from './state';
|
|
4
|
+
|
|
5
|
+
// Helper to generate unique store names for test isolation
|
|
6
|
+
let storeCounter = 0;
|
|
7
|
+
function getUniqueStoreName(prefix: string = 'test-store'): string {
|
|
8
|
+
return `${prefix}-${++storeCounter}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('createGlobalStore', () => {
|
|
12
|
+
it('should create a new global store with initial state', () => {
|
|
13
|
+
const storeName = getUniqueStoreName();
|
|
14
|
+
const initialState = { count: 0, name: 'test' };
|
|
15
|
+
|
|
16
|
+
const store = createGlobalStore(storeName, initialState);
|
|
17
|
+
|
|
18
|
+
expect(store).toBeDefined();
|
|
19
|
+
expect(store.getState()).toEqual(initialState);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should allow updating store state', () => {
|
|
23
|
+
const storeName = getUniqueStoreName();
|
|
24
|
+
const initialState = { count: 0 };
|
|
25
|
+
|
|
26
|
+
const store = createGlobalStore(storeName, initialState);
|
|
27
|
+
store.setState({ count: 5 });
|
|
28
|
+
|
|
29
|
+
expect(store.getState().count).toBe(5);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should warn when creating duplicate store name in non-test environment', async () => {
|
|
33
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
34
|
+
|
|
35
|
+
// Reset modules to clear cache
|
|
36
|
+
vi.resetModules();
|
|
37
|
+
|
|
38
|
+
// Mock the utils module to return false for isTestEnvironment
|
|
39
|
+
vi.doMock('./utils', () => ({
|
|
40
|
+
isTestEnvironment: () => false,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// Dynamic import to get the mocked version
|
|
44
|
+
const { createGlobalStore: createGlobalStoreNonTest } = await import('./state');
|
|
45
|
+
const storeName = getUniqueStoreName();
|
|
46
|
+
|
|
47
|
+
createGlobalStoreNonTest(storeName, { value: 1 });
|
|
48
|
+
consoleSpy.mockClear();
|
|
49
|
+
|
|
50
|
+
createGlobalStoreNonTest(storeName, { value: 2 });
|
|
51
|
+
|
|
52
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
53
|
+
expect.stringContaining(`Attempted to override the existing store ${storeName}`),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
consoleSpy.mockRestore();
|
|
57
|
+
vi.doUnmock('./utils');
|
|
58
|
+
vi.resetModules();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should allow duplicate stores in test environment without warning', () => {
|
|
62
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
63
|
+
const storeName = getUniqueStoreName();
|
|
64
|
+
|
|
65
|
+
// Ensure we're in test environment (should already be true)
|
|
66
|
+
process.env.NODE_ENV = 'test';
|
|
67
|
+
|
|
68
|
+
createGlobalStore(storeName, { value: 1 });
|
|
69
|
+
createGlobalStore(storeName, { value: 2 });
|
|
70
|
+
|
|
71
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
72
|
+
|
|
73
|
+
consoleSpy.mockRestore();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should mark store as active', () => {
|
|
77
|
+
const storeName = getUniqueStoreName();
|
|
78
|
+
const store = createGlobalStore(storeName, { active: true });
|
|
79
|
+
|
|
80
|
+
// Store should be retrievable and active
|
|
81
|
+
const retrieved = getGlobalStore(storeName);
|
|
82
|
+
expect(retrieved).toBe(store);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should reactivate inactive store with new initial state', () => {
|
|
86
|
+
const storeName = getUniqueStoreName();
|
|
87
|
+
|
|
88
|
+
// Create initial store
|
|
89
|
+
const store1 = createGlobalStore(storeName, { value: 1 });
|
|
90
|
+
|
|
91
|
+
// Get it (which should mark it as not active when using getGlobalStore on non-existent)
|
|
92
|
+
// Actually, we need to manually deactivate it by creating through getGlobalStore first
|
|
93
|
+
// Let's create a scenario where the store exists but is inactive
|
|
94
|
+
|
|
95
|
+
// Use getGlobalStore to create an inactive store
|
|
96
|
+
const inactiveStoreName = getUniqueStoreName();
|
|
97
|
+
const inactiveStore = getGlobalStore(inactiveStoreName, { value: 0 });
|
|
98
|
+
|
|
99
|
+
// Now createGlobalStore should reactivate it
|
|
100
|
+
const reactivated = createGlobalStore(inactiveStoreName, { value: 5 });
|
|
101
|
+
|
|
102
|
+
expect(reactivated).toBe(inactiveStore);
|
|
103
|
+
expect(reactivated.getState().value).toBe(5);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should use provided initial state', () => {
|
|
107
|
+
const storeName = getUniqueStoreName();
|
|
108
|
+
const complexState = {
|
|
109
|
+
user: { name: 'John', age: 30 },
|
|
110
|
+
settings: { theme: 'dark' },
|
|
111
|
+
items: [1, 2, 3],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const store = createGlobalStore(storeName, complexState);
|
|
115
|
+
|
|
116
|
+
expect(store.getState()).toEqual(complexState);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should return the same store instance when recreating with same name', () => {
|
|
120
|
+
const storeName = getUniqueStoreName();
|
|
121
|
+
|
|
122
|
+
const store1 = createGlobalStore(storeName, { value: 1 });
|
|
123
|
+
const store2 = createGlobalStore(storeName, { value: 2 });
|
|
124
|
+
|
|
125
|
+
// Should be the same instance
|
|
126
|
+
expect(store1).toBe(store2);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('registerGlobalStore', () => {
|
|
131
|
+
it('should register an existing Zustand store', () => {
|
|
132
|
+
const storeName = getUniqueStoreName();
|
|
133
|
+
const zustandStore = createStore(() => ({ count: 42 }));
|
|
134
|
+
|
|
135
|
+
const registered = registerGlobalStore(storeName, zustandStore);
|
|
136
|
+
|
|
137
|
+
expect(registered).toBe(zustandStore);
|
|
138
|
+
expect(registered.getState().count).toBe(42);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should make registered store retrievable via getGlobalStore', () => {
|
|
142
|
+
const storeName = getUniqueStoreName();
|
|
143
|
+
const zustandStore = createStore(() => ({ value: 'test' }));
|
|
144
|
+
|
|
145
|
+
registerGlobalStore(storeName, zustandStore);
|
|
146
|
+
const retrieved = getGlobalStore(storeName);
|
|
147
|
+
|
|
148
|
+
expect(retrieved).toBe(zustandStore);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should warn when registering duplicate store name in non-test environment', async () => {
|
|
152
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
153
|
+
|
|
154
|
+
// Reset modules to clear cache
|
|
155
|
+
vi.resetModules();
|
|
156
|
+
|
|
157
|
+
// Mock the utils module to return false for isTestEnvironment
|
|
158
|
+
vi.doMock('./utils', () => ({
|
|
159
|
+
isTestEnvironment: () => false,
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
// Dynamic import to get the mocked version
|
|
163
|
+
const { registerGlobalStore: registerGlobalStoreNonTest } = await import('./state');
|
|
164
|
+
const storeName = getUniqueStoreName();
|
|
165
|
+
|
|
166
|
+
const store1 = createStore(() => ({ value: 1 }));
|
|
167
|
+
const store2 = createStore(() => ({ value: 2 }));
|
|
168
|
+
|
|
169
|
+
registerGlobalStoreNonTest(storeName, store1);
|
|
170
|
+
consoleSpy.mockClear();
|
|
171
|
+
|
|
172
|
+
registerGlobalStoreNonTest(storeName, store2);
|
|
173
|
+
|
|
174
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
175
|
+
expect.stringContaining(`Attempted to override the existing store ${storeName}`),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
consoleSpy.mockRestore();
|
|
179
|
+
vi.doUnmock('./utils');
|
|
180
|
+
vi.resetModules();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should allow duplicate registrations in test environment', () => {
|
|
184
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
185
|
+
const storeName = getUniqueStoreName();
|
|
186
|
+
|
|
187
|
+
process.env.NODE_ENV = 'test';
|
|
188
|
+
|
|
189
|
+
const store1 = createStore(() => ({ value: 1 }));
|
|
190
|
+
const store2 = createStore(() => ({ value: 2 }));
|
|
191
|
+
|
|
192
|
+
registerGlobalStore(storeName, store1);
|
|
193
|
+
registerGlobalStore(storeName, store2);
|
|
194
|
+
|
|
195
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
196
|
+
|
|
197
|
+
consoleSpy.mockRestore();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should mark registered store as active', () => {
|
|
201
|
+
const storeName = getUniqueStoreName();
|
|
202
|
+
const zustandStore = createStore(() => ({ active: true }));
|
|
203
|
+
|
|
204
|
+
registerGlobalStore(storeName, zustandStore);
|
|
205
|
+
|
|
206
|
+
// Should be retrievable
|
|
207
|
+
const retrieved = getGlobalStore(storeName);
|
|
208
|
+
expect(retrieved).toBe(zustandStore);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should replace inactive store with new registration', () => {
|
|
212
|
+
const storeName = getUniqueStoreName();
|
|
213
|
+
|
|
214
|
+
// Create inactive store via getGlobalStore
|
|
215
|
+
const inactiveStore = getGlobalStore(storeName, { value: 0 });
|
|
216
|
+
|
|
217
|
+
// Register a new store with same name
|
|
218
|
+
const newStore = createStore(() => ({ value: 99 }));
|
|
219
|
+
const registered = registerGlobalStore(storeName, newStore);
|
|
220
|
+
|
|
221
|
+
expect(registered).toBe(newStore);
|
|
222
|
+
expect(registered.getState().value).toBe(99);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should return the same store instance when re-registering active store', () => {
|
|
226
|
+
const storeName = getUniqueStoreName();
|
|
227
|
+
const store1 = createStore(() => ({ value: 1 }));
|
|
228
|
+
const store2 = createStore(() => ({ value: 2 }));
|
|
229
|
+
|
|
230
|
+
const registered1 = registerGlobalStore(storeName, store1);
|
|
231
|
+
const registered2 = registerGlobalStore(storeName, store2);
|
|
232
|
+
|
|
233
|
+
// Should return the first registered store (active)
|
|
234
|
+
expect(registered1).toBe(registered2);
|
|
235
|
+
expect(registered1).toBe(store1);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('getGlobalStore', () => {
|
|
240
|
+
it('should return existing store', () => {
|
|
241
|
+
const storeName = getUniqueStoreName();
|
|
242
|
+
const created = createGlobalStore<{ value: string }>(storeName, { value: 'exists' });
|
|
243
|
+
|
|
244
|
+
const retrieved = getGlobalStore<{ value: string }>(storeName);
|
|
245
|
+
|
|
246
|
+
expect(retrieved).toBe(created);
|
|
247
|
+
expect(retrieved.getState().value).toBe('exists');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should create store on demand if not exists', () => {
|
|
251
|
+
const storeName = getUniqueStoreName();
|
|
252
|
+
|
|
253
|
+
const store = getGlobalStore(storeName, { created: 'on-demand' });
|
|
254
|
+
|
|
255
|
+
expect(store).toBeDefined();
|
|
256
|
+
expect(store.getState().created).toBe('on-demand');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should use fallback state when creating on demand', () => {
|
|
260
|
+
const storeName = getUniqueStoreName();
|
|
261
|
+
const fallbackState = { name: 'fallback', count: 123 };
|
|
262
|
+
|
|
263
|
+
const store = getGlobalStore(storeName, fallbackState);
|
|
264
|
+
|
|
265
|
+
expect(store.getState()).toEqual(fallbackState);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should create store with empty object when no fallback provided', () => {
|
|
269
|
+
const storeName = getUniqueStoreName();
|
|
270
|
+
|
|
271
|
+
const store = getGlobalStore(storeName);
|
|
272
|
+
|
|
273
|
+
expect(store).toBeDefined();
|
|
274
|
+
expect(store.getState()).toEqual({});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should mark on-demand created store as inactive', () => {
|
|
278
|
+
const storeName = getUniqueStoreName();
|
|
279
|
+
|
|
280
|
+
// Create via getGlobalStore (inactive)
|
|
281
|
+
const store1 = getGlobalStore(storeName, { value: 1 });
|
|
282
|
+
|
|
283
|
+
// Now createGlobalStore should be able to reactivate and replace state
|
|
284
|
+
const store2 = createGlobalStore(storeName, { value: 2 });
|
|
285
|
+
|
|
286
|
+
expect(store1).toBe(store2);
|
|
287
|
+
expect(store2.getState().value).toBe(2);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should return existing active store even when fallback is provided', () => {
|
|
291
|
+
const storeName = getUniqueStoreName();
|
|
292
|
+
|
|
293
|
+
const created = createGlobalStore(storeName, { value: 'original' });
|
|
294
|
+
const retrieved = getGlobalStore(storeName, { value: 'fallback' });
|
|
295
|
+
|
|
296
|
+
expect(retrieved).toBe(created);
|
|
297
|
+
expect(retrieved.getState().value).toBe('original');
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('subscribeTo', () => {
|
|
302
|
+
describe('full state subscription', () => {
|
|
303
|
+
it('should subscribe to entire store state', () => {
|
|
304
|
+
const storeName = getUniqueStoreName();
|
|
305
|
+
const store = createGlobalStore(storeName, { count: 0 });
|
|
306
|
+
const handler = vi.fn();
|
|
307
|
+
|
|
308
|
+
subscribeTo(store, handler);
|
|
309
|
+
|
|
310
|
+
// Handler should be called immediately with current state
|
|
311
|
+
expect(handler).toHaveBeenCalledWith({ count: 0 });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should call handler on state change', () => {
|
|
315
|
+
const storeName = getUniqueStoreName();
|
|
316
|
+
const store = createGlobalStore(storeName, { count: 0 });
|
|
317
|
+
const handler = vi.fn();
|
|
318
|
+
|
|
319
|
+
subscribeTo(store, handler);
|
|
320
|
+
handler.mockClear(); // Clear initial call
|
|
321
|
+
|
|
322
|
+
store.setState({ count: 1 });
|
|
323
|
+
|
|
324
|
+
expect(handler).toHaveBeenCalledWith({ count: 1 });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should return unsubscribe function', () => {
|
|
328
|
+
const storeName = getUniqueStoreName();
|
|
329
|
+
const store = createGlobalStore(storeName, { count: 0 });
|
|
330
|
+
const handler = vi.fn();
|
|
331
|
+
|
|
332
|
+
const unsubscribe = subscribeTo(store, handler);
|
|
333
|
+
|
|
334
|
+
expect(typeof unsubscribe).toBe('function');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should stop calling handler after unsubscribe', () => {
|
|
338
|
+
const storeName = getUniqueStoreName();
|
|
339
|
+
const store = createGlobalStore(storeName, { count: 0 });
|
|
340
|
+
const handler = vi.fn();
|
|
341
|
+
|
|
342
|
+
const unsubscribe = subscribeTo(store, handler);
|
|
343
|
+
handler.mockClear();
|
|
344
|
+
|
|
345
|
+
unsubscribe();
|
|
346
|
+
|
|
347
|
+
store.setState({ count: 1 });
|
|
348
|
+
store.setState({ count: 2 });
|
|
349
|
+
|
|
350
|
+
expect(handler).not.toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should handle multiple subscribers', () => {
|
|
354
|
+
const storeName = getUniqueStoreName();
|
|
355
|
+
const store = createGlobalStore(storeName, { value: 'a' });
|
|
356
|
+
const handler1 = vi.fn();
|
|
357
|
+
const handler2 = vi.fn();
|
|
358
|
+
|
|
359
|
+
subscribeTo(store, handler1);
|
|
360
|
+
subscribeTo(store, handler2);
|
|
361
|
+
|
|
362
|
+
handler1.mockClear();
|
|
363
|
+
handler2.mockClear();
|
|
364
|
+
|
|
365
|
+
store.setState({ value: 'b' });
|
|
366
|
+
|
|
367
|
+
expect(handler1).toHaveBeenCalledWith({ value: 'b' });
|
|
368
|
+
expect(handler2).toHaveBeenCalledWith({ value: 'b' });
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('selected state subscription', () => {
|
|
373
|
+
it('should subscribe to selected portion of state', () => {
|
|
374
|
+
const storeName = getUniqueStoreName();
|
|
375
|
+
const store = createGlobalStore(storeName, { user: { name: 'John' }, count: 5 });
|
|
376
|
+
const handler = vi.fn();
|
|
377
|
+
const selector = (state: any) => state.user;
|
|
378
|
+
|
|
379
|
+
subscribeTo(store, selector, handler);
|
|
380
|
+
|
|
381
|
+
expect(handler).toHaveBeenCalledWith({ name: 'John' });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should only call handler when selected state changes', () => {
|
|
385
|
+
const storeName = getUniqueStoreName();
|
|
386
|
+
const store = createGlobalStore(storeName, { user: { name: 'John' }, count: 0 });
|
|
387
|
+
const handler = vi.fn();
|
|
388
|
+
const selector = (state: any) => state.user;
|
|
389
|
+
|
|
390
|
+
subscribeTo(store, selector, handler);
|
|
391
|
+
handler.mockClear();
|
|
392
|
+
|
|
393
|
+
// Change unrelated state
|
|
394
|
+
store.setState({ user: { name: 'John' }, count: 1 });
|
|
395
|
+
|
|
396
|
+
// Handler should not be called (user didn't change)
|
|
397
|
+
expect(handler).not.toHaveBeenCalled();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should call handler when selected state changes', () => {
|
|
401
|
+
const storeName = getUniqueStoreName();
|
|
402
|
+
const store = createGlobalStore(storeName, { user: { name: 'John' }, count: 0 });
|
|
403
|
+
const handler = vi.fn();
|
|
404
|
+
const selector = (state: any) => state.user;
|
|
405
|
+
|
|
406
|
+
subscribeTo(store, selector, handler);
|
|
407
|
+
handler.mockClear();
|
|
408
|
+
|
|
409
|
+
store.setState({ user: { name: 'Jane' }, count: 0 });
|
|
410
|
+
|
|
411
|
+
expect(handler).toHaveBeenCalledWith({ name: 'Jane' });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should use shallow equality for change detection', () => {
|
|
415
|
+
const storeName = getUniqueStoreName();
|
|
416
|
+
const store = createGlobalStore(storeName, { items: [1, 2, 3], meta: 'data' });
|
|
417
|
+
const handler = vi.fn();
|
|
418
|
+
const selector = (state: any) => state.items;
|
|
419
|
+
|
|
420
|
+
subscribeTo(store, selector, handler);
|
|
421
|
+
handler.mockClear();
|
|
422
|
+
|
|
423
|
+
// Set same array reference
|
|
424
|
+
const sameItems = store.getState().items;
|
|
425
|
+
store.setState({ items: sameItems, meta: 'changed' });
|
|
426
|
+
|
|
427
|
+
// Handler should not be called (same reference)
|
|
428
|
+
expect(handler).not.toHaveBeenCalled();
|
|
429
|
+
|
|
430
|
+
// Set new array with same content - shallowEqual compares array elements
|
|
431
|
+
store.setState({ items: [1, 2, 3], meta: 'changed' });
|
|
432
|
+
|
|
433
|
+
// Handler should NOT be called (arrays are shallowEqual)
|
|
434
|
+
expect(handler).not.toHaveBeenCalled();
|
|
435
|
+
|
|
436
|
+
// Set new array with different content
|
|
437
|
+
store.setState({ items: [1, 2, 3, 4], meta: 'changed' });
|
|
438
|
+
|
|
439
|
+
// Handler should be called (different array content)
|
|
440
|
+
expect(handler).toHaveBeenCalled();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('should return unsubscribe function for selected subscription', () => {
|
|
444
|
+
const storeName = getUniqueStoreName();
|
|
445
|
+
const store = createGlobalStore(storeName, { a: 1, b: 2 });
|
|
446
|
+
const handler = vi.fn();
|
|
447
|
+
const selector = (state: any) => state.a;
|
|
448
|
+
|
|
449
|
+
const unsubscribe = subscribeTo(store, selector, handler);
|
|
450
|
+
|
|
451
|
+
expect(typeof unsubscribe).toBe('function');
|
|
452
|
+
|
|
453
|
+
handler.mockClear();
|
|
454
|
+
unsubscribe();
|
|
455
|
+
|
|
456
|
+
store.setState({ a: 5, b: 2 });
|
|
457
|
+
expect(handler).not.toHaveBeenCalled();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should work with primitive value selectors', () => {
|
|
461
|
+
const storeName = getUniqueStoreName();
|
|
462
|
+
const store = createGlobalStore(storeName, { count: 0, name: 'test' });
|
|
463
|
+
const handler = vi.fn();
|
|
464
|
+
const selector = (state: any) => state.count;
|
|
465
|
+
|
|
466
|
+
subscribeTo(store, selector, handler);
|
|
467
|
+
|
|
468
|
+
expect(handler).toHaveBeenCalledWith(0);
|
|
469
|
+
|
|
470
|
+
handler.mockClear();
|
|
471
|
+
store.setState({ count: 5, name: 'test' });
|
|
472
|
+
|
|
473
|
+
expect(handler).toHaveBeenCalledWith(5);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('should handle computed/derived values from selector', () => {
|
|
477
|
+
const storeName = getUniqueStoreName();
|
|
478
|
+
const store = createGlobalStore(storeName, { firstName: 'John', lastName: 'Doe' });
|
|
479
|
+
const handler = vi.fn();
|
|
480
|
+
const selector = (state: any) => `${state.firstName} ${state.lastName}`;
|
|
481
|
+
|
|
482
|
+
subscribeTo(store, selector, handler);
|
|
483
|
+
|
|
484
|
+
expect(handler).toHaveBeenCalledWith('John Doe');
|
|
485
|
+
|
|
486
|
+
handler.mockClear();
|
|
487
|
+
store.setState({ firstName: 'Jane', lastName: 'Doe' });
|
|
488
|
+
|
|
489
|
+
expect(handler).toHaveBeenCalledWith('Jane Doe');
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
describe('edge cases', () => {
|
|
494
|
+
it('should handle rapid state updates', () => {
|
|
495
|
+
const storeName = getUniqueStoreName();
|
|
496
|
+
const store = createGlobalStore(storeName, { count: 0 });
|
|
497
|
+
const handler = vi.fn();
|
|
498
|
+
|
|
499
|
+
subscribeTo(store, handler);
|
|
500
|
+
handler.mockClear();
|
|
501
|
+
|
|
502
|
+
// Rapid updates
|
|
503
|
+
for (let i = 1; i <= 10; i++) {
|
|
504
|
+
store.setState({ count: i });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
expect(handler).toHaveBeenCalledTimes(10);
|
|
508
|
+
expect(handler).toHaveBeenLastCalledWith({ count: 10 });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('should handle undefined state values', () => {
|
|
512
|
+
const storeName = getUniqueStoreName();
|
|
513
|
+
const store = createGlobalStore(storeName, { value: undefined as string | undefined });
|
|
514
|
+
const handler = vi.fn();
|
|
515
|
+
|
|
516
|
+
subscribeTo(store, handler);
|
|
517
|
+
|
|
518
|
+
expect(handler).toHaveBeenCalledWith({ value: undefined });
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('should handle null state values', () => {
|
|
522
|
+
const storeName = getUniqueStoreName();
|
|
523
|
+
const store = createGlobalStore(storeName, { value: null as string | null });
|
|
524
|
+
const handler = vi.fn();
|
|
525
|
+
|
|
526
|
+
subscribeTo(store, handler);
|
|
527
|
+
|
|
528
|
+
expect(handler).toHaveBeenCalledWith({ value: null });
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
});
|
package/src/state.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {} from '@openmrs/esm-globals';
|
|
|
3
3
|
import { shallowEqual } from '@openmrs/esm-utils';
|
|
4
4
|
import type { StoreApi } from 'zustand/vanilla';
|
|
5
5
|
import { createStore } from 'zustand/vanilla';
|
|
6
|
+
import { isTestEnvironment } from './utils';
|
|
6
7
|
|
|
7
8
|
interface StoreEntity {
|
|
8
9
|
value: StoreApi<unknown>;
|
|
@@ -11,22 +12,9 @@ interface StoreEntity {
|
|
|
11
12
|
|
|
12
13
|
const availableStores: Record<string, StoreEntity> = {};
|
|
13
14
|
|
|
14
|
-
// Check if we're in a test environment (Vitest or Jest)
|
|
15
|
-
const isTestEnvironment = () => {
|
|
16
|
-
try {
|
|
17
|
-
return (
|
|
18
|
-
process.env.NODE_ENV === 'test' ||
|
|
19
|
-
(typeof process !== 'undefined' && (process.env.VITEST === 'true' || process.env.JEST_WORKER_ID !== undefined)) ||
|
|
20
|
-
(typeof globalThis !== 'undefined' && ('__vitest_worker__' in globalThis || '__jest__' in globalThis))
|
|
21
|
-
);
|
|
22
|
-
} catch {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
|
|
27
15
|
// spaEnv isn't available immediately. Wait a bit before making stores available
|
|
28
16
|
// on window in development mode.
|
|
29
|
-
setTimeout(() => {
|
|
17
|
+
globalThis.setTimeout?.(() => {
|
|
30
18
|
if (typeof window !== 'undefined' && window.spaEnv === 'development') {
|
|
31
19
|
window['stores'] = availableStores;
|
|
32
20
|
}
|
|
@@ -134,11 +122,13 @@ export function subscribeTo<T, U>(...args: SubscribeToArgs<T, U>): () => void {
|
|
|
134
122
|
const handler = typeof handle === 'undefined' ? (select as unknown as (state: U) => void) : handle;
|
|
135
123
|
const selector = typeof handle === 'undefined' ? (state: T) => state as unknown as U : (select as (state: T) => U);
|
|
136
124
|
|
|
137
|
-
|
|
138
|
-
|
|
125
|
+
let previous = selector(store.getState());
|
|
126
|
+
handler(previous);
|
|
127
|
+
return store.subscribe((state) => {
|
|
139
128
|
const current = selector(state);
|
|
140
129
|
|
|
141
130
|
if (!shallowEqual(previous, current)) {
|
|
131
|
+
previous = current;
|
|
142
132
|
handler(current);
|
|
143
133
|
}
|
|
144
134
|
});
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if we're in a test environment (Vitest or Jest)
|
|
3
|
+
* Exported in a separate file so it can be mocked in tests
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export function isTestEnvironment() {
|
|
7
|
+
try {
|
|
8
|
+
return (
|
|
9
|
+
process.env.NODE_ENV === 'test' ||
|
|
10
|
+
(typeof process !== 'undefined' && (process.env.VITEST === 'true' || process.env.JEST_WORKER_ID !== undefined)) ||
|
|
11
|
+
(typeof globalThis !== 'undefined' && ('__vitest_worker__' in globalThis || '__jest__' in globalThis))
|
|
12
|
+
);
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|